diff --git a/docs/skills/README.md b/docs/skills/README.md new file mode 100644 index 0000000..aa9d548 --- /dev/null +++ b/docs/skills/README.md @@ -0,0 +1,13 @@ +# NextPress AI Agent Skills + +Structured, source-grounded skills for AI coding agents working on NextPress. Each skill is a folder with a `SKILL.md` (entry point: rules + workflow) and a `reference.md` (exact API signatures with `file:line` citations). + +| Skill | Track | Scope | +|-------|-------|-------| +| [theme-development](theme-development/SKILL.md) | Themes | `themes/{slug}/` — layouts, template hierarchy, block overrides, `theme.json` | +| [plugin-development](plugin-development/SKILL.md) | Plugins | `plugins/{slug}/` — content types, fields, blocks, hooks, admin pages, API routes via `PluginContext` | +| [core-development](core-development/SKILL.md) | Core engine | `packages/*`, `apps/web/` — services, tRPC, Prisma, guardrails | + +## Usage + +These follow the [Agent Skills](https://www.anthropic.com/news/skills) convention: an agent loads `SKILL.md` first, then pulls the sibling `reference.md` only when writing real code. To use them with a local agent, copy or symlink the folders into your agent's skills directory. diff --git a/docs/skills/core-development/SKILL.md b/docs/skills/core-development/SKILL.md new file mode 100644 index 0000000..0407cc3 --- /dev/null +++ b/docs/skills/core-development/SKILL.md @@ -0,0 +1,89 @@ +--- +name: nextpress-core-development +description: Contribute to the NextPress CMS engine itself — packages/core services, packages/api tRPC routers, packages/db Prisma schema/migrations, packages/blocks and packages/editor, and apps/web glue. Use when changing engine behavior that every theme, plugin, and site depends on. This is the highest-risk track: changes must preserve the dependency direction, framework-free core, siteId multi-tenant scoping, permission guards on every mutation, HTML sanitization, and test coverage. +--- + +# NextPress Core Development + +Core work changes the engine every theme, plugin, and site depends on. Correctness alone is not enough — a change that violates layering, drops `siteId` scoping, or skips a permission guard will be rejected regardless of whether it "works". Treat the guardrails below as non-negotiable. + +## The hard guardrails (violating any one fails review) + +1. **Dependency direction — never reversed.** + `apps/web → packages/api → packages/core → { packages/blocks, packages/db }`, and `packages/editor → packages/blocks`. + - `packages/blocks` must **not** import `packages/core` (`BlockData` lives in blocks to break the cycle; core re-exports it). + - `packages/api` must **not** import `apps/*`. The app injects revalidation via `setRevalidationCallbacks(...)`; the API layer calls the injected callbacks, no-op when unset. +2. **core is framework-free.** No runtime React/Next imports in `packages/core`. The **only** tolerated exception is type-only `import type { ... } from "react"` (currently just `ComponentType` in `theme-types.ts`). No JSX runtime, no `next/*`. +3. **siteId scoping on every query.** Every DB read/write is scoped by `auth.siteId` (or an explicit `siteId` param) in the `where`. Adding a query without `siteId` scoping is a cross-tenant data leak. The schema backs this with `@@unique([siteId, ...])` and siteId-leading indexes plus `onDelete: Cascade`. +4. **Permission guard on every mutation.** Call `assertCan(auth, "")` (or `permissionProcedure("")`) at the top of every mutating service method. Enforcement lives in **services**, not routers — routers just pass `ctx.auth`. Ownership via `can(auth, "edit_own_content", { ownerId })`. +5. **Sanitize all user HTML.** Any user HTML must pass `DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR })` with an explicit allowlist before `dangerouslySetInnerHTML` (blocks layer). SVG uploads are rejected. +6. **Throw CmsError subclasses**, not raw `Error`: `NotFoundError`, `ValidationError`, `AuthorizationError`, `AuthenticationError`. +7. **Ship tests.** New core behavior lands with Vitest tests. Mock `@nextpress/db`, dynamic-import the service after mocking, and build `AuthContext` role fixtures for permission paths. + +## Package map + +| Package | Name | Depends on | Runtime | +|---|---|---|---| +| `packages/db` | `@nextpress/db` | (leaf) | Prisma client singleton, repositories, schema | +| `packages/blocks` | `@nextpress/blocks` | (leaf) | server-safe block registry + SSR render components | +| `packages/core` | `@nextpress/core` | `blocks`, `db` | framework-free CMS logic — services, hooks, engines | +| `packages/api` | `@nextpress/api` | `core`, `db` | tRPC routers | +| `packages/editor` | `@nextpress/editor` | `blocks` | **client-only** (`"use client"`) block editor | +| `packages/ui` | `@nextpress/ui` | (none) | admin design system | +| `apps/web` | — | `api`, `core`, `editor`, `ui` | Next.js app, route groups, lib glue | + +## Commands + +Root scripts delegate to Turbo (`turbo.json` pipeline: `build/lint/typecheck/test` all `dependsOn ["^build"]`; `dev` is persistent + uncached): + +```bash +pnpm dev # turbo dev +pnpm build # turbo build +pnpm lint # turbo lint +pnpm typecheck # turbo typecheck (each pkg: tsc --noEmit) +pnpm test # turbo test (vitest run in core + api) +``` + +Per-package / single file: + +```bash +pnpm --filter @nextpress/core test +pnpm --filter @nextpress/core typecheck +pnpm --filter @nextpress/core exec vitest run src/__tests__/unit/content-service-unit.test.ts +``` + +**Prisma — no convenience scripts are wired.** There is no `db:migrate`/`db:seed` script anywhere; run the CLI through the db package directly: + +```bash +pnpm --filter @nextpress/db exec prisma generate +pnpm --filter @nextpress/db exec prisma migrate dev --name +pnpm --filter @nextpress/db exec prisma db seed +``` + +Do not invent script names — use `--filter @nextpress/db exec prisma ...`. + +## Where things live + +- **Services** (`packages/core/src//`): plain object literals of async methods exported as a singleton (`export const contentService = { async create(auth, input) {...} }`). Prisma is a module singleton (`import { prisma } from "@nextpress/db"`), not injected. +- **tRPC** (`packages/api/src/`): `trpc.ts` defines `publicProcedure`, `authedProcedure`, `permissionProcedure(slug)`; `root.ts` composes routers; `context.ts` carries `{ session, auth }`. +- **Auth/permissions** (`packages/core/src/auth/`): pure functions, no DB — `can()`, `assertCan()`, role/permission types, the 28 permission slugs. +- **DB** (`packages/db/`): `client.ts` singleton, `repositories/*`, `prisma/schema.prisma` (28 models), `prisma/seeds/`. +- **Blocks vs editor**: `packages/blocks` server-safe (register via `registerBlock`, side-effect on import); `packages/editor` client-only. + +The exact procedure/service/error/permission/schema/test patterns with `file:line` citations and copy-ready snippets are in **[reference.md](reference.md)**. Load it before writing engine code. + +## Workflow + +1. Read the README's Architecture → Key Interfaces sections and this skill's guardrails. +2. Pick a well-scoped change; keep it within one layer where possible. +3. Discuss schema or public-interface changes first — they ripple across themes and plugins. +4. Implement following the service/tRPC/error/permission patterns in reference.md. +5. Add/adjust Vitest tests (and Playwright for app-level flows). +6. Run `pnpm lint`, `pnpm typecheck`, and the relevant `--filter ... test` locally before opening a PR. + +## Known gaps (verified absent — do not assume they exist) + +- No wired Prisma/db npm scripts — use `--filter @nextpress/db exec prisma ...`. +- `packages/db/src/extensions/site-scoped.ts` and `soft-delete.ts` are **empty stubs** — siteId scoping is enforced manually in service `where` clauses today, not via a Prisma client extension. +- `packages/core/src/validation/sanitize.ts` is an **empty stub** — HTML sanitization actually lives in `packages/blocks` (DOMPurify). +- No packages-level Playwright config — E2E specs live under `apps/*`. diff --git a/docs/skills/core-development/reference.md b/docs/skills/core-development/reference.md new file mode 100644 index 0000000..e65ae9b --- /dev/null +++ b/docs/skills/core-development/reference.md @@ -0,0 +1,174 @@ +# NextPress Core — Engine Reference + +All patterns below are the real conventions from the codebase, cited with `file:line`. + +## tRPC procedures + +`packages/api/src/trpc.ts` — `initTRPC` with `superjson` transformer. + +```ts +publicProcedure = t.procedure; // :19 +authedProcedure = t.procedure.use(enforceAuth); // :30 — throws UNAUTHORIZED if !ctx.session || !ctx.auth +permissionProcedure(permission: PermissionSlug) // :65 — authedProcedure + can(); throws FORBIDDEN with reason +anyPermissionProcedure(permissions[]) // :81 — super_admin bypass + some(can().granted) +``` + +`context.ts`: `TrpcContext { session: SessionUser | null; auth: AuthContext | null }`; `AuthedTrpcContext` narrows both non-null. + +`root.ts:18` — flat `router({...})` of the sub-routers; `export type AppRouter = typeof appRouter`. + +### Example query + guarded mutation (`routers/content.ts`) + +```ts +// Public query +getBySlug: publicProcedure + .input(z.object({ siteId: z.string().cuid(), slug: z.string() })) + .query(async ({ input }) => contentService.getBySlug(input.siteId, input.slug)), // :51 + +// Authed mutation — permission enforced INSIDE the service, then revalidate +create: authedProcedure + .input(createContentEntrySchema) + .mutation(async ({ ctx, input }) => { + const entry = await contentService.create(ctx.auth, input); + if (entry.status === "PUBLISHED") await revalidateEntry(entry); + return entry; + }), // :84 +``` + +**Pattern:** routers stay thin and pass `ctx.auth` (or `ctx.auth.siteId`) straight into services. Permission checks live in the service. `permissionProcedure` is available when you want router-level gating too. + +### Revalidation callback injection (`routers/content.ts:19`) + +The app layer registers callbacks; the API layer calls them, no-op if unset: + +```ts +// module scope in the API package +let onEntryChange: ((entry: ContentEntryDto) => Promise) | null = null; +export function setRevalidationCallbacks(cb: { onEntryChange; onEntryDelete }) { /* assign */ } +async function revalidateEntry(entry) { if (onEntryChange) await onEntryChange(entry); } +``` + +Guardrail comment in source: **`packages/api` must NOT import from `apps/web`.** The arrow is `apps/web → packages/api`, never reversed. Mutations revalidate only when `status === "PUBLISHED"` (or on trash/delete to purge public pages). + +## Service pattern + +`packages/core/src//-service.ts` — object literal of async methods, exported singleton. Prisma is a module singleton. + +```ts +import { prisma } from "@nextpress/db"; +import { assertCan, canPublishContent } from "../auth/permissions"; +import { NotFoundError, ValidationError } from "../errors/cms-error"; + +export const contentService = { + async create(auth: AuthContext, input: CreateContentEntryInput) { + assertCan(auth, "create_content"); // permission guard first + if (input.status === "PUBLISHED") canPublishContent(auth); + // every where clause is siteId-scoped: + const existing = await prisma.contentEntry.findUnique({ + where: { siteId_slug: { siteId: auth.siteId, slug: input.slug } }, + }); + if (existing) throw new ValidationError("Slug already exists"); + // ... + }, + async delete(auth: AuthContext, id: string) { + assertCan(auth, "delete_media"); + const row = await prisma.mediaAsset.findFirst({ where: { id, siteId: auth.siteId } }); + if (!row) throw new NotFoundError("media", id); + // ... + }, +}; +``` + +Read methods take `siteId` as an explicit first param (`getBySlug(siteId, slug)`), so callers must supply the tenant scope. Storage keys embed `auth.siteId` (media) — no cross-tenant access. + +## Error hierarchy (`packages/core/src/errors/cms-error.ts`) + +Base `CmsError extends Error` with `(message, code, statusCode=500, details?)`. Subclasses: + +| Class | code | status | +|---|---|---| +| `AuthenticationError` | `UNAUTHENTICATED` | 401 | +| `AuthorizationError` | `FORBIDDEN` | 403 (+details) | +| `NotFoundError(resource, id?)` | `NOT_FOUND` | 404 | +| `ValidationError` | `VALIDATION_ERROR` | 400 | + +Code constants live in `errors/error-codes.ts`. Always throw these — never a raw `Error`. + +## Auth / permissions (`packages/core/src/auth/`) + +Pure functions, no DB, no side effects. + +```ts +can(auth, permission, resource?): PermissionResult // permissions.ts:34 +assertCan(auth, permission, resource?): void // :74 — throws AuthorizationError with {permission, role, siteId} +assertAuthenticated(auth): asserts auth is AuthContext // :93 +``` + +`PermissionResult = { granted: true } | { granted: false; reason: string }`. + +`can()` behavior: `super_admin` bypasses everything; otherwise checks `auth.permissions.has(permission)`; for `edit_own_content` / `delete_own_content` it applies ownership logic and falls back to the `_others_` variant when a `resource.ownerId` is provided. + +Named shortcuts (compose `can()`, never bypass): `canCreateContent`, `canEditContent(auth, ownerId)`, `canDeleteContent(auth, ownerId)`, `canPublishContent`, `canUploadMedia`, `canModerateComments`, `canManageUsers`, `canManageSettings`, `canManagePlugins`, `canManageAppearance`, `canAccessAdmin`. + +**`RoleSlug`** = `super_admin | admin | editor | author | contributor | subscriber` (`auth-types.ts:74`). + +**`AuthContext`** = `{ user: SessionUser; siteId: string; role: RoleSlug; permissions: Set }` (`:98`) — resolved per-request (role can differ per site; not baked into the JWT beyond the user identity). + +**28 `PermissionSlug` values** (`auth-types.ts:22`): `create_content, edit_own_content, edit_others_content, delete_own_content, delete_others_content, publish_content, manage_categories, manage_tags, upload_media, delete_media, moderate_comments, list_users, create_users, edit_users, delete_users, promote_users, switch_themes, customize_theme, manage_menus, activate_plugins, manage_plugins, manage_settings, manage_content_types, manage_fields, manage_taxonomies, manage_sites, read, edit_profile`. Plugins extend via module augmentation of `PermissionMap`. + +`ROLE_DEFINITIONS` (`roles.ts:30`) is the **seed-data** source for role→permission mapping (DB is authoritative at runtime). `super_admin` is intentionally absent — it bypasses. + +## Prisma / DB (`packages/db`) + +- `src/client.ts`: `PrismaClient` singleton via `globalForPrisma` on `globalThis` (HMR-safe). Dev logs `query/error/warn`, prod `error`. +- `src/index.ts`: re-exports `prisma` + 28 model/enum types (`User, Site, ContentEntry, ContentType, FieldDefinition, MediaAsset, Comment, Revision, ...`; enums `ContentStatus, CommentStatus, FieldType, BlockTemplateMode`). +- `src/repositories/*`: repository-per-aggregate (14 files). +- `src/extensions/site-scoped.ts` and `soft-delete.ts`: **empty stubs** — not implemented. Scope by `siteId` manually in `where`. +- `prisma/schema.prisma`: 28 models; `siteId` appears ~49×. Per-model pattern: `siteId String` + `site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)`, compound uniques (`@@unique([siteId, slug])`, `@@unique([siteId, contentTypeId, slug])`), and siteId-leading composite indexes (e.g. `@@index([siteId, contentTypeId, status, publishedAt(sort: Desc)])`). + +**Add a migration:** edit `schema.prisma`, then: + +```bash +pnpm --filter @nextpress/db exec prisma migrate dev --name +``` + +There is a `prisma/migrations/manual/` dir for hand-written SQL when needed. + +## Blocks vs editor split + +- **`@nextpress/blocks` = server-safe.** Deps: `isomorphic-dompurify`, `zod` only (no React runtime dep, no `"use client"`). Render components sanitize with a tight allowlist: + +```ts +dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content, { + ALLOWED_TAGS: ["strong","em","a","code","br","span","mark"], + ALLOWED_ATTR: ["href","target","rel","class"], +}) }} +``` + +- **`@nextpress/editor` = client-only** (`editor.tsx:1` `"use client";`). Depends on `@nextpress/blocks`. +- **Registration** (`packages/blocks/src/registry.ts`): module-scoped `Map` singleton. Blocks register at import via side-effect: each block file calls `registerBlock({ type, attributesSchema, defaultAttributes, version, migrate?, renderComponent, ... })`. API: `registerBlock, unregisterBlock, getBlockDefinition, getAllBlockDefinitions, getBlocksByCategory, isBlockRegistered, overrideRenderComponent, migrateBlockAttributes, validateBlockAttributes`. `renderComponent: null` = editor-only. + +## Testing + +- **Vitest.** `packages/core/vitest.config.ts`: `globals: true`, `environment: "node"`, `include: ["src/__tests__/**/*.test.ts"]`, v8 coverage. Resolve aliases map `@nextpress/db → ../db/src` and `@nextpress/blocks → ../blocks/src`. +- Layout: `packages/core/src/__tests__/{unit,integration,mocks}/` + `helpers.ts`, `setup.ts`. API tests in `packages/api/src/__tests__/`. +- **Pattern** (`__tests__/unit/content-service-unit.test.ts`): mock db first, then dynamic-import the service: + +```ts +vi.mock("@nextpress/db", () => ({ prisma: mockPrisma })); // from ../mocks/prisma +const { contentService } = await import("../../content/content-service"); +// build explicit AuthContext fixtures per role to assert permission behavior +``` + +- Commands: `pnpm --filter @nextpress/core test`, single file via `... exec vitest run `. +- Playwright E2E config lives under `apps/*` (not in the packages tree). + +## Guardrail checklist before opening a PR + +- [ ] Dependency direction respected (`api → core → {blocks, db}`, `editor → blocks`; no `blocks → core`, no `api → apps`). +- [ ] No runtime React/Next in `packages/core` (type-only `import type` only). +- [ ] Every new query scoped by `siteId`. +- [ ] Every mutation has `assertCan(...)` / `permissionProcedure(...)`. +- [ ] User HTML sanitized via DOMPurify allowlist. +- [ ] Throws `CmsError` subclasses, not raw `Error`. +- [ ] Tests added; `pnpm lint`, `pnpm typecheck`, and the relevant `test` pass locally. diff --git a/docs/skills/plugin-development/SKILL.md b/docs/skills/plugin-development/SKILL.md new file mode 100644 index 0000000..5603d3e --- /dev/null +++ b/docs/skills/plugin-development/SKILL.md @@ -0,0 +1,84 @@ +--- +name: nextpress-plugin-development +description: Build, edit, or review NextPress CMS plugins — content types, custom fields, block types, admin pages, editor sidebar panels, custom API routes, settings, taxonomies, and hook callbacks (actions + filters). Use when working anywhere under plugins/{slug}/ in a NextPress repo, creating a new plugin, or extending CMS behavior through PluginContext. Plugins never import Prisma or core internals directly — PluginContext is the entire sanctioned API surface. +--- + +# NextPress Plugin Development + +A NextPress plugin extends CMS behavior through **one controlled object: `PluginContext`**. Plugins register content types, custom fields, block types, admin pages, sidebar panels, API routes, settings, taxonomies, and hook callbacks — all via `ctx.*`. They never touch Prisma or core internals directly; the context is the entire sanctioned surface, and every registration is source-tagged with the plugin slug for clean deactivation. + +## Golden rules + +1. **`PluginContext` is the only door.** No `import { prisma }`, no core service imports. Use `ctx.hooks`, `ctx.content`, `ctx.blocks`, `ctx.admin`, `ctx.api`, `ctx.settings`, `ctx.taxonomies`. +2. **A plugin is a default-exported `PluginDefinition`** in `index.ts` with `slug` + `onActivate(ctx)` (and optional `onDeactivate`/`onUninstall`). The `slug` MUST match `plugin.json` `slug` and the directory name. +3. **`onActivate` runs at server startup** for every active plugin. Do your registrations there. +4. **Hooks auto-remove on deactivate.** All `ctx.*` registrations are tagged by source; you rarely need manual cleanup. Use `onUninstall` only to remove persisted DB data. +5. **Actions vs filters.** Actions are side effects (return `void`). Filters transform and **return** the first argument. Pick the right kind for the hook. +6. **Zod is the source of truth** for block attributes and field validation. +7. **Directories starting with `_` are never loaded** (so `_template` is a scaffold, not an active plugin). +8. **`ctx.api` routes are auto-prefixed** to `/api/v1/plugins/{slug}{path}`. + +## Directory layout + +``` +plugins/{slug}/ +├── plugin.json # Manifest: name, slug, version, dependencies, permissions, settings, contentTypes, taxonomies +├── index.ts # default export PluginDefinition — onActivate(ctx) / onDeactivate / onUninstall +├── components/ # block edit/render components, admin panels, sidebar panels +├── api/ # route handler functions used by ctx.api.registerRoute +└── lib/ # plugin-local helpers +``` + +## Workflow to create a plugin + +1. Copy `plugins/_template/` to `plugins/my-plugin/`. Set `slug` in both `plugin.json` and `index.ts` to match the directory. +2. Study the shipped plugins as end-to-end examples: + - `plugins/seo-toolkit/` — custom fields + `render:meta_tags` filter + settings + sidebar panel. + - `plugins/contact-form/` — custom block + private content type + `ctx.api` route + `content:after_save` action. + - `plugins/form-generator/` — settings-bound route + admin pages. + - (`plugins/analytics/` ships only components — its `index.ts` is empty, so it is not a `PluginDefinition` reference.) +3. Inside `onActivate(ctx)`, register only what you need. Await the async registrations (`ctx.content.*`, `ctx.taxonomies.*`, `ctx.settings.*`). +4. Declare dependencies, new permissions, and settings schema in `plugin.json`. + +## The two imports every plugin uses + +```ts +import type { PluginDefinition } from "@nextpress/core/plugin/plugin-types"; +import type { PluginContext } from "@nextpress/core/plugin/plugin-context"; + +const myPlugin: PluginDefinition = { + slug: "my-plugin", + async onActivate(ctx: PluginContext) { /* register here */ }, + async onDeactivate(ctx) { /* optional */ }, + async onUninstall(ctx) { /* remove persisted data */ }, +}; +export default myPlugin; +``` + +## What you can register (quick map) + +| Call | Purpose | +|---|---| +| `ctx.hooks.addAction(hook, cb, priority?)` | React to a lifecycle event (side effect) | +| `ctx.hooks.addFilter(hook, cb, priority?)` | Transform data flowing through the CMS | +| `ctx.content.registerType(input)` | Create a custom content type | +| `ctx.content.registerFields(typeSlug, fields)` | Add custom fields to a type | +| `ctx.content.registerMetaField(field)` | Add a global meta field | +| `ctx.blocks.register(def)` / `ctx.blocks.unregister(type)` | Add/remove a block type | +| `ctx.admin.registerPage(item)` | Add an admin nav item | +| `ctx.admin.registerSidebarPanel(panel)` | Add an editor sidebar panel | +| `ctx.api.registerRoute(method, path, handler)` | Add a REST endpoint under `/api/v1/plugins/{slug}` | +| `ctx.settings.get()` / `ctx.settings.update(values)` | Read/write plugin settings (site-scoped) | +| `ctx.taxonomies.register(input)` | Create a custom taxonomy | + +The **exact signatures, input shapes, the full list of 17 hook events (actions vs filters) with their payload types, the `plugin.json` manifest fields, and real registration snippets** are in **[reference.md](reference.md)**. Load it before writing registration code — the hook names and input shapes are precise and easy to get wrong from memory. + +## Common tasks → where to look in reference.md + +- "React when a post is published / a comment is submitted" → hook events (actions) + `ctx.hooks.addAction`. +- "Change meta tags / excerpts / block list before render" → hook events (filters) + `ctx.hooks.addFilter`. +- "Add a custom field to posts" → `ctx.content.registerFields` + `CreateFieldDefinitionInput` + the 15 field types. +- "Register a new block" → `ctx.blocks.register` + `BlockDefinition` (Zod attributes, `version`, `migrate`). +- "Add a settings page / editor sidebar panel" → `ctx.admin.registerPage` / `registerSidebarPanel`. +- "Expose an API endpoint" → `ctx.api.registerRoute` (remember the `/api/v1/plugins/{slug}` prefix). +- "Declare a dependency on another plugin" → `plugin.json` `dependencies` (topological activation order). diff --git a/docs/skills/plugin-development/reference.md b/docs/skills/plugin-development/reference.md new file mode 100644 index 0000000..eea1e81 --- /dev/null +++ b/docs/skills/plugin-development/reference.md @@ -0,0 +1,228 @@ +# NextPress Plugin — API Reference + +All contracts below are from `packages/core/src/plugin/`, `packages/core/src/hooks/`, and the shipped plugins. Cited with `file:line`. + +## PluginDefinition + +`packages/core/src/plugin/plugin-types.ts:49`: + +```ts +interface PluginDefinition { + slug: string; // must match plugin.json slug + dir name + onActivate: (ctx: PluginContext) => void | Promise; + onDeactivate?: (ctx: PluginContext) => void | Promise; + onUninstall?: (ctx: PluginContext) => void | Promise; +} +``` + +Default-exported from `index.ts`. `onActivate` runs once per server startup for active plugins. + +## PluginContext + +`packages/core/src/plugin/plugin-context.ts:29` — `class PluginContext(slug: string, auth: AuthContext)`, public `readonly slug`. + +### ctx.hooks (`:39`) + +```ts +addAction(hook: K, callback: (...args: HookArgs) => void | Promise, priority?: number): void +addFilter(hook: K, callback: (...args: HookArgs) => HookReturn | Promise>, priority?: number): void +``` + +`priority` default `10`; lower runs earlier. Filters must **return** the (possibly transformed) first argument. + +### ctx.content (`:61`) + +```ts +registerType(input: Omit & { slug: string }): Promise +registerFields(contentTypeSlug: string, fields: CreateFieldDefinitionInput[]): Promise<...> +registerMetaField(field: CreateFieldDefinitionInput): Promise<...> +``` + +### ctx.blocks (`:83`) + +```ts +register(definition: Omit): void // source auto-set to plugin slug +unregister(type: string): void +``` + +### ctx.admin (`:100`) + +```ts +registerPage(item: Omit & { slug: string }): void +registerSidebarPanel(panel: Omit & { slug: string }): void +getPages(); getSidebarPanels(); +``` + +### ctx.api (`:126`) + +```ts +registerRoute(method: "GET"|"POST"|"PUT"|"DELETE", path: string, handler: (req: Request) => Promise): void +getRoutes(); +``` + +Final URL is `/api/v1/plugins/${slug}${path}` (e.g. registering `"/submit"` in the `contact-form` plugin serves `/api/v1/plugins/contact-form/submit`). + +### ctx.settings (`:149`) — site-scoped, stored under group `plugin:${slug}` + +```ts +get(): Promise> +update(values: Record): Promise +``` + +### ctx.taxonomies (`:169`) + +```ts +register(input: { + slug: string; name: string; description?: string; + hierarchical?: boolean; contentTypes: string[]; +}): Promise<...> +``` + +## Input shapes + +### CreateContentTypeInput (`content-type/content-type-types.ts:6`) + +`slug, nameSingular, namePlural, description?, hierarchical=false, hasArchive=true, isPublic=true, menuIcon="file-text", menuPosition=20, supports[], settings={}`. + +`supports` enum: `title | editor | excerpt | thumbnail | comments | revisions | custom-fields | page-attributes`. + +### CreateFieldDefinitionInput (`fields/field-types.ts:51`) + +`contentTypeId?, key (regex ^[a-z][a-z0-9_]*$), name, description?, fieldType, isRequired=false, defaultValue?, validation?, options?, group="custom-fields", sortOrder=0`. + +**fieldType enum (15 types)** (`field-types.ts:5`): `TEXT, TEXTAREA, RICHTEXT, NUMBER, BOOLEAN, DATE, DATETIME, SELECT, MULTISELECT, MEDIA, RELATION, COLOR, URL, EMAIL, JSON`. + +`validation` fields: `{ min, max, minLength, maxLength, precision, pattern, accept, maxFiles, contentType, maxItems }`. + +### BlockDefinition (`packages/blocks/src/types.ts:60`, register omits `source`) + +`type, title, description?, icon, category (text|media|layout|embed|widgets|theme|plugin), keywords?, attributesSchema (Zod), defaultAttributes, version, migrate?, allowsInnerBlocks, allowedInnerBlockTypes?, renderComponent (ComponentType|null)`. `renderComponent: null` = editor-only block. Increment `version` and provide `migrate(old, fromVersion)` on breaking attribute changes. + +### AdminMenuItem (`hooks/hook-types.ts:92`) + +`slug, label, href, icon?, parentSlug?, position?, capability?`. + +### SidebarPanel (`hooks/hook-types.ts:102`) + +`slug, title, icon?, component: () => Promise<{ default: React.ComponentType }>, contentTypes?, position?`. + +## Hook events (full list) + +Registry: `hooks/hook-types.ts:27` (`HookRegistry`). An event whose handler returns `void` is an **action**; one that returns a value is a **filter**. + +### Actions + +| Event | Args | +|---|---| +| `content:before_save` | `[entry: ContentSavePayload]` | +| `content:after_save` | `[entry: ContentEntryDto]` | +| `content:before_delete` | `[entryId: string, siteId: string]` | +| `content:after_delete` | `[entryId: string, siteId: string]` | +| `content:status_change` | `[entry: ContentEntryDto, oldStatus: string, newStatus: string]` | +| `content:published` | `[entry: ContentEntryDto]` | +| `user:registered` | `[userId: string, email: string]` | +| `user:login` | `[userId: string]` | +| `comment:submitted` | `[commentId: string, contentEntryId: string]` | +| `comment:approved` | `[commentId: string]` | +| `media:uploaded` | `[mediaId: string, siteId: string]` | + +### Filters (args → return) + +| Event | Args → Returns | +|---|---| +| `render:blocks` | `[blocks: BlockData[], entry: ContentEntryDto]` → `BlockData[]` | +| `render:meta_tags` | `[tags: Record, entry: ContentEntryDto]` → `Record` | +| `render:excerpt` | `[excerpt: string, entry: ContentEntryDto]` → `string` | +| `admin:menu_items` | `[items: AdminMenuItem[]]` → `AdminMenuItem[]` | +| `admin:editor_sidebar_panels` | `[panels: SidebarPanel[]]` → `SidebarPanel[]` | +| `api:response` | `[data: unknown, endpoint: string]` → `unknown` | + +`ContentSavePayload` (`hook-types.ts:81`): `{ id?, title, slug, blocks: BlockData[], status, contentTypeSlug, siteId, authorId }`. + +Plugins may declare **new** hook events by module-augmenting `HookRegistry`. + +## plugin.json manifest + +Schema: `plugin-types.ts:19` (`pluginManifestSchema`). + +| Field | Type / default | Meaning | +|---|---|---| +| `name` | string (req) | display name | +| `slug` | `^[a-z0-9-]+$` (req) | unique id; must match `PluginDefinition.slug` + dir name | +| `version` | string (req) | stored on install | +| `description` | string? | — | +| `author` / `authorUrl` | string? / url? | — | +| `requires` | string? | min CMS version | +| `dependencies` | string[] = [] | plugin slugs that must be active first (topological activation order) | +| `permissions` | `{ slug, name, description?, group="plugin" }[]` = [] | upserted into DB on activate, deleted on uninstall | +| `settings` | `Record` = {} | JSON Schema for plugin settings | +| `contentTypes` | string[] = [] | declared content types | +| `taxonomies` | string[] = [] | declared taxonomies | + +## Real registration snippets + +**Custom field + meta-tags filter (seo-toolkit)** + +```ts +await ctx.content.registerFields(typeSlug, [ + { key: "_seo_title", name: "SEO Title", fieldType: "TEXT", + group: "seo", sortOrder: 0, validation: { maxLength: 70 } }, +]); + +ctx.hooks.addFilter("render:meta_tags", async (tags, entry) => { + return { ...tags, description: (entry.fields._seo_description as string) ?? tags.description }; +}, 5); // priority 5 — runs before default priority-10 handlers + +ctx.admin.registerSidebarPanel({ + slug: "seo-inspector", title: "SEO", contentTypes: ["post", "page"], + position: 100, component: () => import("./components/seo-sidebar"), +}); +``` + +**Block + private content type + route + action (contact-form)** + +```ts +ctx.blocks.register({ + type: "plugin/contact-form", title: "Contact Form", icon: "mail", + category: "widgets", keywords: ["form", "contact", "email"], + attributesSchema: contactFormSchema, defaultAttributes: { /* ... */ }, + version: 1, allowsInnerBlocks: false, renderComponent: null, +}); + +await ctx.content.registerType({ + slug: "form_submission", nameSingular: "Form Submission", + namePlural: "Form Submissions", isPublic: false, + menuIcon: "inbox", supports: ["title", "custom-fields"], +}); + +ctx.api.registerRoute("POST", "/submit", async (req) => { + // served at /api/v1/plugins/contact-form/submit + return new Response(JSON.stringify({ ok: true }), { status: 200 }); +}); + +ctx.hooks.addAction("content:after_save", async (entry) => { /* side effect */ }); +``` + +**Settings-bound route + admin page (form-generator)** + +```ts +ctx.api.registerRoute("POST", "/submit", createSubmitHandler({ getSettings, onSuccess })); +ctx.admin.registerPage({ + slug: "form-generator-settings", label: "Form Generator", + href: "/admin/settings/form-generator", icon: "clipboard-list", + capability: "manage_settings", +}); +``` + +## Import paths + +```ts +import type { PluginDefinition } from "@nextpress/core/plugin/plugin-types"; +import type { PluginContext } from "@nextpress/core/plugin/plugin-context"; +``` + +Block-related types (when registering blocks) come from `@nextpress/blocks`. + +## Lifecycle (plugin-manager.ts) + +`discover()` scans `plugins/*/plugin.json` (dirs starting `_` skipped) → `activate(slug, auth)` validates dependencies → dynamic `import(dir/index.ts)` → `new PluginContext(slug, auth)` → `onActivate` (on throw, `hooks.removeBySource(slug)` rolls back) → upserts permissions + `pluginInstall` row. `deactivate` calls `onDeactivate` then `hooks.removeBySource`. `uninstall` deactivates, calls `onUninstall`, deletes permissions + install row. `bootActivePlugins` topologically sorts by `dependencies` (Kahn's algorithm) at startup; missing dependencies prevent activation. diff --git a/docs/skills/theme-development/SKILL.md b/docs/skills/theme-development/SKILL.md new file mode 100644 index 0000000..2a8cd16 --- /dev/null +++ b/docs/skills/theme-development/SKILL.md @@ -0,0 +1,73 @@ +--- +name: nextpress-theme-development +description: Build, edit, or review NextPress CMS themes — layouts, the WordPress-style template hierarchy, block render overrides, theme.json manifests, and per-theme styling. Use when working anywhere under themes/{slug}/ in a NextPress repo, creating a new theme, changing how published content is presented, overriding a core block's render, or wiring theme customizations. Themes are presentation-only: they receive resolved content and render it, never touching Prisma, tRPC, or business logic. +--- + +# NextPress Theme Development + +A NextPress theme is **layout + a WordPress-style template hierarchy + optional block render overrides + styling**. Themes are the lowest-risk track: sandboxed to one `themes/{slug}/` directory, they receive already-resolved content as plain data and render it with React Server Components. You never need Prisma, tRPC, or the permission engine to ship a theme. + +## Golden rules + +1. **Presentation only.** No DB access, no tRPC, no core service imports. Templates receive a `TemplateContext` and render it. +2. **Templates are synchronous server components.** Signature is `({ context }: TemplateProps)`. None are `async` — data is already resolved and passed in. +3. **`index.tsx` is the required fallback.** Every theme must provide `templates/index.tsx`. It is always the last candidate in resolution. +4. **Render blocks through ``**, never by hand. Import from `@nextpress/blocks`. +5. **Sanitize any HTML** you inject with `DOMPurify.sanitize` and an explicit allowlist before `dangerouslySetInnerHTML` — follow the pattern in the existing block overrides. +6. **Theme is a startup singleton.** Switching or reloading a theme requires a dev-server restart/reset; it is not hot-swapped mid-request. + +## Directory layout + +``` +themes/{slug}/ +├── theme.json # Manifest: name, slug, version, supports, settings schema, templateChoices +├── layout.tsx # Root shell — default export ThemeLayout({ children, customizations }) +├── templates/ +│ ├── index.tsx # REQUIRED fallback +│ ├── single.tsx # any single entry +│ ├── single-{type}.tsx # single entry of a specific content type +│ ├── page.tsx # hierarchical content +│ ├── archive.tsx # content listing +│ ├── home.tsx # homepage +│ ├── search.tsx # search results +│ ├── taxonomy.tsx # taxonomy archive +│ └── 404.tsx # not found +├── blocks/ # optional per-block render overrides — filename maps to core/{filename} +│ └── paragraph.tsx # overrides core/paragraph +├── components/ # theme-local components (header, footer, sidebar, ...) +└── styles/theme.css # theme CSS (import it in layout.tsx if you rely on it) +``` + +## Workflow to create a theme + +1. Copy `themes/_template/` to `themes/my-theme/`. Study `themes/default/` (simple, Tailwind-only) and `themes/twentytwentysix/` (richer, imports its own CSS, reads customizations) as worked references. +2. Edit `theme.json` — set `name`, `slug` (regex `^[a-z0-9-]+$`), `version`, `supports`, and the `settings` JSON-Schema (see [reference.md](reference.md)). +3. Write `layout.tsx` — default export `ThemeLayout({ children, customizations })`. +4. Start from `templates/index.tsx`, then add `single`, `page`, `archive`, `home`, `search`, `404` as needed. +5. Add block overrides in `blocks/` only if you need to restyle a core block's markup. +6. Add `styles/theme.css`; if the theme depends on it (not pure Tailwind utilities), `import "./styles/theme.css";` at the top of `layout.tsx`. + +## The one mental model that matters: template resolution + +The resolver picks the **most specific** template that exists, falling back to less specific ones, ending at `index`. Examples: + +``` +Single post "hello-world" with per-entry template "full-width": + full-width → single-post-hello-world → single-post → single → index + +Category "tech" archive: + taxonomy-category-tech → taxonomy-category → taxonomy → archive → index + +Homepage: + front-page → home → index +``` + +You only need to provide the templates you actually want to specialize; everything else falls through to `index`. The full per-page-type candidate ordering, the exact `TemplateContext`/`TemplateEntry`/`BlockRenderProps` shapes, `theme.json` fields, block-override mechanics, styling wiring, and import paths are in **[reference.md](reference.md)** — load it before writing real template code. + +## Common tasks → where to look in reference.md + +- "What fields does my template get?" → `TemplateContext` and `TemplateEntry`. +- "Which template fires for this URL?" → the resolution hierarchy table. +- "Override how a core block looks" → block overrides + `BlockRenderProps`. +- "Add a per-entry template option to the editor sidebar" → `templateChoices` in `theme.json`. +- "Wire up dark mode / accent color / fonts" → `supports` + `settings` + `customizations`. diff --git a/docs/skills/theme-development/reference.md b/docs/skills/theme-development/reference.md new file mode 100644 index 0000000..2d36b81 --- /dev/null +++ b/docs/skills/theme-development/reference.md @@ -0,0 +1,162 @@ +# NextPress Theme — API Reference + +All shapes below are the real contracts from `packages/core/src/theme/` and `packages/blocks/src/`. Cited with `file:line`. + +## theme.json (ThemeManifest) + +Schema: `packages/core/src/theme/theme-types.ts:22` (`themeManifestSchema`). + +| Field | Type | Default / rule | +|---|---|---| +| `name` | string (min 1) | required | +| `slug` | string, regex `^[a-z0-9-]+$` | required | +| `version` | string | required | +| `description` | string | optional | +| `author` | string | optional | +| `authorUrl` | string (url) | optional | +| `screenshot` | string | optional | +| `supports.menuLocations` | string[] | `["primary","footer"]` | +| `supports.widgetAreas` | string[] | `[]` | +| `supports.customColors` | boolean | `true` | +| `supports.darkMode` | boolean | `false` | +| `settings` | JSON-Schema object | `{}` | +| `templates` | string[] | `[]` (auto-discovered from `templates/`) | +| `templateChoices` | `{ slug, name, description?, contentTypes: string[] }[]` | `[]` | + +`settings` is a JSON-Schema `type:"object"` with `properties`, each having `type`, optional `enum`, `default`. Real keys in shipped themes: `accentColor`, `fontFamily`, `showSidebar`, `postsPerPage`, `footerText` (default), plus `layout`, `sidebarPosition`, `showExcerpts`, `excerptLength`, `showAuthorBio`, `showRelatedPosts`, `showReadingTime`, `showShareButtons`, `darkMode` (twentytwentysix). These resolved values arrive at runtime as `context.customizations` / `customizations`. + +`templateChoices[].slug` values (e.g. `full-width`, `sidebar-right`, `no-title`, `cover`) become the per-entry template override that the resolver checks first. + +## Template resolution hierarchy + +`packages/core/src/theme/template-resolver.ts`. `resolveTemplate(templates, ctx)` builds a candidate list via `buildHierarchy(ctx)` and returns the first template that exists; ultimate fallback is `"index"`. + +`ResolveContext` fields: `pageType` (`"single" | "archive" | "taxonomy" | "search" | "404" | "home"`), `contentTypeSlug?`, `entrySlug?`, `entryTemplate?: string | null`, `isHierarchical?`, `taxonomySlug?`, `termSlug?`. + +Candidate ordering per `pageType` (first match wins, `index` always appended last): + +| pageType | candidate order | +|---|---| +| `single` | `entryTemplate?`, `single-{type}-{slug}`, (if hierarchical) `page-{slug}` then `page`, `single-{type}`, `single`, `index` | +| `archive` | `archive-{type}`, `archive`, `index` | +| `taxonomy` | `taxonomy-{tax}-{term}`, `taxonomy-{tax}`, `taxonomy`, `archive`, `index` | +| `search` | `search`, `archive`, `index` | +| `404` | `404`, `index` | +| `home` | `front-page`, `home`, `index` | + +`getTemplateChoicesForType(themeChoices, contentTypeSlug)` builds the editor sidebar list: a blank "Default Template" option first, then choices whose `contentTypes` is empty or includes the type. + +## Template props + +`TemplateProps` = `{ context: TemplateContext }` (`theme-types.ts:90`). `TemplateComponent = ComponentType`. **Templates are synchronous (not async) function components.** + +`TemplateContext` (`theme-types.ts:54`): + +```ts +interface TemplateContext { + entry: TemplateEntry | null; // null for archive/search/404 + entries?: TemplateEntry[]; + pagination?: { page: number; totalPages: number; total: number }; + term?: { name: string; slug: string; taxonomy: string }; + searchQuery?: string; + site: { name: string; tagline: string | null; url: string }; + customizations: Record; +} +``` + +`TemplateEntry` (`theme-types.ts:72`): + +```ts +interface TemplateEntry { + id: string; + title: string; + slug: string; + excerpt: string | null; + blocks: BlockData[]; + status: string; + contentType: { slug: string; nameSingular: string }; + author: { name: string | null; displayName: string | null; image: string | null }; + publishedAt: Date | null; + createdAt: Date; + template: string | null; // per-entry override slug + fields: Record; // custom field values, incl. _seo_* keys + terms: Array<{ name: string; slug: string; taxonomy: { slug: string } }>; + featuredImage: { url: string; alt: string | null; width: number | null; height: number | null } | null; +} +``` + +### Layout props + +`LoadedTheme.layout` = `ComponentType<{ children: React.ReactNode; customizations: Record }>` (`theme-types.ts:136`). Both shipped themes implement exactly: + +```ts +interface Props { children: React.ReactNode; customizations: Record; } +export default function ThemeLayout({ children, customizations }: Props) { /* ... */ } +``` + +The manager accepts `default` or a named `ThemeLayout` export; if neither, it wraps children in a passthrough. + +### Template body patterns (from shipped themes) + +- `single.tsx` / `page.tsx`: destructure `{ entry }` from `context`, early-return `null` if falsy, render ``. +- `archive.tsx`: use `{ entries = [], pagination }`; twentytwentysix also reads `term`, `customizations`. +- `home.tsx` / `search.tsx`: re-render the archive template with spread props, reading `context.site` / `context.searchQuery`. +- `index.tsx` can simply delegate to `HomeTemplate`. +- Reading customizations: `context.customizations.showSidebar`, `...showAuthorBio`, `...showShareButtons`, and branching on `entry.template === "cover" | "full-width"`. + +## Block render overrides + +`BlockRenderProps` (`packages/blocks/src/types.ts:107`): + +```ts +interface BlockRenderProps> { + attributes: TAttributes; + children?: React.ReactNode; + className?: string; + blockData: BlockData; // { id, type, attributes, innerBlocks } +} +``` + +**How overrides register:** the theme manager auto-loads every `.tsx` in the theme's `blocks/` dir, maps filename → block type `core/{filename}`, and calls `overrideRenderComponent("core/{filename}", component)` (`theme-manager.ts:157`). So `blocks/paragraph.tsx` overrides `core/paragraph`, `blocks/heading.tsx` overrides `core/heading`. `overrideRenderComponent(type, component)` (`registry.ts:57`) replaces **only** `renderComponent` on the already-registered base block — the base block must exist. + +Override component signature (typed example, `themes/default/blocks/paragraph.tsx:9`): + +```ts +import type { BlockRenderProps } from "@nextpress/blocks"; +import type { ParagraphAttributes } from "@nextpress/blocks/blocks/paragraph"; + +export default function ThemedParagraph({ attributes, className }: BlockRenderProps) { + // sanitize before injecting HTML — see rule below +} +``` + +**Renderer behavior** (`renderer.tsx`): `` wraps in `
`, and per block runs `migrateBlockAttributes` → `validateBlockAttributes` (Zod) before rendering `"} />`. Inner blocks recurse only when the block's `allowsInnerBlocks` is true. Blocks with `renderComponent: null` are skipped. + +**Sanitization rule:** all shipped overrides sanitize `attributes.content` with `DOMPurify.sanitize(content, { ALLOWED_TAGS: [...], ALLOWED_ATTR: [...] })` before `dangerouslySetInnerHTML`. Always follow this — never inject raw HTML. + +## Import paths + +```ts +import { BlockRenderer } from "@nextpress/blocks"; +import type { BlockRenderProps } from "@nextpress/blocks"; +import type { ParagraphAttributes } from "@nextpress/blocks/blocks/paragraph"; +import type { TemplateProps } from "@nextpress/core/theme/theme-types"; +``` + +Local relative imports for theme components: `./components/header`, `./components/footer`, `../components/sidebar`, etc. + +## Styling wiring + +- CSS lives at `themes/{slug}/styles/theme.css`; the manager records its path when present. +- **twentytwentysix** imports it directly: `import "./styles/theme.css";` at the top of `layout.tsx` — the reliable way to guarantee the CSS loads. +- **default** uses Tailwind utilities inline and scopes its CSS rules under a root class (`np-theme-default`) rather than importing. +- The layout sets a root wrapper class (e.g. `np-theme-default`, or `np-twentytwentysix np-font-{font} np-dark?`) derived from `customizations.fontFamily` / `customizations.darkMode`. +- Blocks receive `np-block np-block-core-{type}` from the renderer; theme overrides can append their own classes (e.g. `np-drop-cap`). + +## Loader conventions (theme-manager.ts) + +- Active theme resolved from the `themes/` dir; directories starting with `_` are ignored (so `_template` never loads). +- Template slug = filename minus `.tsx`; component = `mod.default` or the first export. +- Layout = `mod.default || mod.ThemeLayout`, else an internal passthrough. +- Block override type = `core/{filename}`. +- Theme loads once at startup (singleton) — restart the dev server to pick up a theme switch.