헤드리스 CMS 핵심: 타입 있는 컬렉션과 싱글턴, 초안, 리비전, 예약 발행, 다국어, 미리보기
설치
genpm add @core/content포함 내용
- src/lib/content/에 소스 코드, 파일 11개. (46.4kB)
- 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 적용 후 주입될 정확한 트리입니다. 고정 대상:
// Lectura pública (ContentSource): solo lo publicado, salvo `draft: true` (vista previa autorizada).
import { and, asc, count, desc, eq, gte, inArray, lt, notExists, or, type SQL, sql } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import type { z } from 'zod';
import type { ContentEntry, ContentListQuery, ContentReadOptions, ContentSource } from '../contracts/index.ts';
import { type Executor, getDb } from '../db/index.ts';
import { type Collection, defaultLocale, getCollection, SINGLETON_SLUG } from './registry.ts';
import { type ContentRow, contentEntries } from './schema.ts';
function toEntry<D>(row: ContentRow, draft: boolean): ContentEntry<D> | null {
const data = draft ? (row.draft ?? row.data) : row.data;
if (!data) return null;
return {
id: row.id,
collection: row.collection,
slug: row.slug,
locale: row.locale,
status: row.status,
data: data as D,
publishedAt: row.publishedAt,
updatedAt: row.updatedAt,
};
}
const visible = (draft: boolean | undefined) =>
draft ? inArray(contentEntries.status, ['draft', 'scheduled', 'published']) : eq(contentEntries.status, 'published');
/** Idiomas a probar en orden: el pedido y, si la colección es multilingüe, el de por defecto. */
function locales(c: Collection, locale: string | undefined): string[] {
const def = defaultLocale();
if (!c.localized) return [def];
return locale && locale !== def ? [locale, def] : [def];
}
export async function getEntry<D = Record<string, unknown>>(
collection: string,
slug: string,
opts: ContentReadOptions = {},
db: Executor = getDb(),
): Promise<ContentEntry<D> | null> {
const c = getCollection(collection);
for (const locale of locales(c, opts.locale)) {
const [row] = await db
.select()
.from(contentEntries)
.where(
and(
eq(contentEntries.collection, collection),
eq(contentEntries.slug, slug),
eq(contentEntries.locale, locale),
visible(opts.draft),
),
);
const entry = row ? toEntry<D>(row, !!opts.draft) : null;
if (entry) return entry;
}
return null;
}
const FIELD_RE = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
/** Ruta a un campo anidado de `data` en `where` (hasta 3 niveles): `series.slug`. */
const PATH_RE = /^[a-zA-Z][a-zA-Z0-9_]{0,63}(\.[a-zA-Z][a-zA-Z0-9_]{0,63}){0,2}$/;
/**
* Consulta de `listEntries`/`countEntries` de este paquete: la de @core/contracts más un rango de `publishedAt`
* (`publishedFrom` incluido, `publishedBefore` excluido). En `where`, las claves con punto miran campos anidados.
*/
export type EntryListQuery = ContentListQuery & { publishedFrom?: Date; publishedBefore?: Date };
function orderOf(orderBy: string | undefined): SQL {
const key = orderBy ?? '-publishedAt';
const dir = key.startsWith('-') ? desc : asc;
const field = key.replace(/^-/, '');
if (field === 'publishedAt') return dir(contentEntries.publishedAt);
if (field === 'updatedAt') return dir(contentEntries.updatedAt);
if (field === 'slug') return dir(contentEntries.slug);
if (!FIELD_RE.test(field)) throw new Error(`invalid orderBy: ${orderBy}`);
return dir(sql`${contentEntries.data} ->> ${field}`);
}
/**
* Lista entradas visibles de una colección en un idioma (con fallback al idioma por defecto si no hay ninguna
* traducida). `where` compara por igualdad campos de `data`.
*/
function listConditions(collection: string, query: EntryListQuery, db: Executor): SQL {
const c = getCollection(collection);
const conds: SQL[] = [eq(contentEntries.collection, collection), visible(query.draft)!];
for (const [k, v] of Object.entries(query.contains ?? {})) {
if (!FIELD_RE.test(k)) throw new Error(`invalid contains field: ${k}`);
const col = query.draft ? sql`coalesce(${contentEntries.draft}, ${contentEntries.data})` : sql`${contentEntries.data}`;
conds.push(sql`(${col} -> ${k}) @> ${JSON.stringify([v])}::jsonb`);
}
for (const [k, v] of Object.entries(query.where ?? {})) {
if (!PATH_RE.test(k)) throw new Error(`invalid where field: ${k}`);
const col = query.draft ? sql`coalesce(${contentEntries.draft}, ${contentEntries.data})` : sql`${contentEntries.data}`;
const field = k.includes('.') ? sql`${col} #>> ${`{${k.split('.').join(',')}}`}::text[]` : sql`${col} ->> ${k}`;
conds.push(v === null ? sql`${field} is null` : sql`${field} = ${String(v)}`);
}
if (query.publishedFrom) conds.push(gte(contentEntries.publishedAt, query.publishedFrom));
if (query.publishedBefore) conds.push(lt(contentEntries.publishedAt, query.publishedBefore));
// Por cada grupo de traducciones, la versión en el idioma pedido o, si no existe visible, la del idioma por defecto.
const [wanted, fallback] = locales(c, query.locale);
const t = alias(contentEntries, 't');
const translated = db
.select({ one: sql`1` })
.from(t)
.where(and(eq(t.groupId, contentEntries.groupId), eq(t.locale, wanted!), query.draft ? inArray(t.status, ['draft', 'scheduled', 'published']) : eq(t.status, 'published')));
const localeCond = fallback
? or(eq(contentEntries.locale, wanted!), and(eq(contentEntries.locale, fallback), notExists(translated)))!
: eq(contentEntries.locale, wanted!);
return and(...conds, localeCond)!;
}
export async function listEntries<D = Record<string, unknown>>(
collection: string,
query: EntryListQuery = {},
db: Executor = getDb(),
): Promise<ContentEntry<D>[]> {
const limit = Math.min(Math.max(query.limit ?? 50, 1), 500);
const rows = await db
.select()
.from(contentEntries)
.where(listConditions(collection, query, db))
.orderBy(orderOf(query.orderBy), asc(contentEntries.id))
.limit(limit)
.offset(Math.max(query.offset ?? 0, 0));
return rows.map((r) => toEntry<D>(r, !!query.draft)).filter((e): e is ContentEntry<D> => e !== null);
}
/** Total de entradas visibles con los mismos filtros que `listEntries` (para paginar). */
export async function countEntries(collection: string, query: EntryListQuery = {}, db: Executor = getDb()): Promise<number> {
const [row] = await db.select({ n: count() }).from(contentEntries).where(listConditions(collection, query, db));
return row?.n ?? 0;
}
export const getSingleton = <D = Record<string, unknown>>(name: string, opts?: ContentReadOptions, db?: Executor) =>
getEntry<D>(name, SINGLETON_SLUG, opts, db);
/** `ContentSource` de este paquete (para blog, blocks, site y kits). */
export const contentSource: ContentSource = {
getEntry: (collection, slug, opts) => getEntry(collection, slug, opts),
listEntries: (collection, query) => listEntries(collection, query),
};
/** Lecturas tipadas por el esquema de la colección: `await reader(pages).get('about')`. */
export function reader<S extends z.ZodObject>(c: Collection<S>) {
type D = z.infer<S>;
return {
get: (slug: string, opts?: ContentReadOptions) =>
getEntry<D>(c.name, c.kind === 'singleton' ? SINGLETON_SLUG : slug, opts),
list: (query?: ContentListQuery) => listEntries<D>(c.name, query),
singleton: (opts?: ContentReadOptions) => getEntry<D>(c.name, SINGLETON_SLUG, opts),
};
}
이 패키지는 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개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?