ヘッドレス CMS の中核:型付きコレクションとシングルトン、下書き、履歴、予約公開、多言語、プレビュー
インストール
genpm add @core/content含まれるもの
- src/lib/content/ にソースコード(11 ファイル)。 (46.4 KB)
- src/lib/content/AGENTS.md に AI ルール、加えて IDE 用のルールファイル。
- .env.example に追加される環境変数: CONTENT_PREVIEW_SECRET, CONTENT_DEFAULT_LOCALE。
- @core/contracts, @core/db, @core/jobs を自動で解決します。
README
このパッケージには README がありません。
これは 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 適用後に組み込まれる正確なツリーです。固定先:
// Definición de colecciones y singletons. Cada proceso importa las mismas definiciones (p. ej. src/genpm/content.ts).
import { z } from 'zod';
import type { AdminField } from '../contracts/index.ts';
export const SINGLETON_SLUG = '_';
const NAME_RE = /^[a-z][a-z0-9_]{0,47}$/;
export type CollectionOptions<S extends z.ZodObject> = {
schema: S;
label?: { singular: string; plural: string };
/** Campos para el panel (@core/admin). Si faltan, se deducen del esquema. */
fields?: AdminField[];
/** Campo de `data` usado como título. Default `title` si existe. */
titleField?: string;
/** Las entradas tienen versión por idioma. Default true. */
localized?: boolean;
};
export type Collection<S extends z.ZodObject = z.ZodObject> = {
name: string;
kind: 'collection' | 'singleton';
schema: S;
label: { singular: string; plural: string };
fields: AdminField[];
titleField: string | null;
localized: boolean;
};
const registry = new Map<string, Collection>();
function register<S extends z.ZodObject>(kind: Collection['kind'], name: string, opts: CollectionOptions<S>): Collection<S> {
if (!NAME_RE.test(name)) throw new Error(`invalid collection name: ${name}`);
const shape = opts.schema.shape as Record<string, z.ZodType>;
const human = name.replaceAll('_', ' ').replace(/^./, (c) => c.toUpperCase());
const c: Collection<S> = {
name,
kind,
schema: opts.schema,
label: opts.label ?? { singular: human, plural: human },
fields: opts.fields ?? Object.keys(shape).map((k) => ({ name: k, label: k, type: inferFieldType(shape[k]!) })),
titleField: opts.titleField ?? ('title' in shape ? 'title' : null),
localized: opts.localized ?? true,
};
registry.set(name, c as unknown as Collection);
return c;
}
/** Colección: muchas entradas con slug (páginas, posts, FAQs). */
export const defineCollection = <S extends z.ZodObject>(name: string, opts: CollectionOptions<S>) =>
register('collection', name, opts);
/** Singleton: una sola entrada por idioma (home, ajustes, pie de página). */
export const defineSingleton = <S extends z.ZodObject>(name: string, opts: CollectionOptions<S>) =>
register('singleton', name, opts);
export function getCollection(name: string): Collection {
const c = registry.get(name);
if (!c) throw new ContentError('unknown_collection', `unknown collection: ${name} (call defineCollection first)`);
return c;
}
export const listCollections = (): Collection[] => [...registry.values()];
/** Solo tests. */
export const clearCollections = () => registry.clear();
function inferFieldType(t: z.ZodType): AdminField['type'] {
const def = (t as unknown as { def?: { type?: string; innerType?: z.ZodType } }).def;
const type = def?.type;
if ((type === 'optional' || type === 'nullable' || type === 'default') && def?.innerType) return inferFieldType(def.innerType);
if (type === 'number') return 'number';
if (type === 'boolean') return 'boolean';
if (type === 'date') return 'date';
if (type === 'enum') return 'select';
if (type === 'string') return 'text';
return 'json';
}
export type ContentErrorCode =
| 'unknown_collection'
| 'not_found'
| 'invalid_slug'
| 'invalid_data'
| 'slug_taken'
| 'invalid_state'
| 'invalid_token'
| 'forbidden';
export class ContentError extends Error {
constructor(
readonly code: ContentErrorCode,
message: string = code,
readonly issues?: Array<{ path: string; message: string }>,
) {
super(message);
this.name = 'ContentError';
}
}
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*){0,5}$/;
export function assertSlug(slug: string): string {
if (slug !== SINGLETON_SLUG && (slug.length > 200 || !SLUG_RE.test(slug)))
throw new ContentError('invalid_slug', `invalid slug: ${JSON.stringify(slug)}`);
return slug;
}
/** `Hola, Mundo ñandú!` → `hola-mundo-nandu`. */
export function slugify(input: string): string {
return input
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 120);
}
export const defaultLocale = () => process.env.CONTENT_DEFAULT_LOCALE ?? 'en';
export function validateData(c: Collection, data: unknown, partial: boolean): Record<string, unknown> {
const schema = partial ? c.schema.partial() : c.schema;
const r = schema.safeParse(data);
if (!r.success)
throw new ContentError(
'invalid_data',
`invalid ${c.name} data`,
r.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
);
return r.data as Record<string, unknown>;
}
このパッケージは MCP サーバーを宣言していません。
| バージョン | コミット | 公開日 | スキャン |
|---|---|---|---|
| 1.1.0 | 23978fe | 3 時間前 | スキャン合格 |
- npm
- zod ^4.0.0
- 提案
- GenPM は npm コマンドを提案し、あなたが承認した場合にのみ実行します。
- スキャン
- スキャン合格 · 指摘 0 件
- コミット
- v1.1.0 → 23978fee0b532853e3112d070817a3e274c4ed49 · 取得後に検証済み
- スクリプト
- なし。GenPM はパッケージのコードを実行しません。
- ライセンス
- MIT
- 品質
- 100/100
- 認識されたライセンス達成
- AGENTS.md に目的の説明がある達成
- AGENTS.md に統合手順がある達成
- AGENTS.md に規約や禁止事項がある達成
- テストを含む達成
- セキュリティスキャンに合格達成
- 過去 6 か月以内に公開達成
- 認証済みの公開者達成
- 概要とキーワード達成
- 報告
- 問題を見つけましたか?