Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
38873fc
feat(cli): ✨ vitnode generate for plugin API modules, routes and serv…
claude Nov 9, 2026
b1e47cb
feat(cli): ✨ vitnode generate resource --kind api
claude Nov 9, 2026
ec0c1c5
feat(cli): 🩺 vitnode doctor and vitnode inspect routes|jobs|events
claude Nov 9, 2026
1e69a90
feat(core): 🔑 typed staff permission references with defineStaffPermi…
claude Nov 9, 2026
c427203
feat(cli): ✨ vitnode generate resource --kind content
claude Nov 9, 2026
751aa03
fix(cli): 🐛 generator edits, permission references and review findings
claude Nov 9, 2026
3411527
docs: 📝 fix generator guides after review
claude Nov 9, 2026
78e1327
docs: 📝 align generator docs with the end-to-end run
claude Nov 9, 2026
ee86535
refactor(blog): ♻️ split inline routes into routes/ and drop .admin f…
claude Nov 9, 2026
b5b286d
refactor(forum): ♻️ split stats.routes.ts and topics.routes.ts into r…
claude Nov 9, 2026
e992ada
refactor(newsroom): ♻️ move routes into routes/ folders
claude Nov 9, 2026
0e71b8e
refactor(payments-demo): ♻️ split purchases.routes.ts and purchase-st…
claude Nov 9, 2026
d8fb9a5
refactor(core): ♻️ drop .admin from admin module file names
claude Nov 9, 2026
176e5af
refactor(core): ♻️ rename inaccurate admin route and schema files
claude Nov 9, 2026
386bff2
refactor(core): ♻️ move admin plugins and roles schemas next to their…
claude Nov 9, 2026
593b62f
refactor(core): ♻️ split admin file alt routes into one file per endp…
claude Nov 9, 2026
b725afa
refactor(core): ♻️ split admin notification danger, delivery and main…
claude Nov 9, 2026
c22aa80
refactor(core): ♻️ split admin AI access, assist, history and transla…
claude Nov 9, 2026
c30a8b6
refactor(core): ♻️ split admin report reason routes into one file per…
claude Nov 9, 2026
044272b
refactor(core): ♻️ split admin warning reason and threshold routes in…
claude Nov 9, 2026
200287c
refactor(core): ♻️ point admin views at the moved admin schemas and r…
claude Nov 9, 2026
98a1da6
docs: 📝 place API endpoints in routes/ folders across guides
claude Nov 9, 2026
aba0a72
fix(cli): 🐛 report plugin factories that throw instead of "no factory"
claude Nov 9, 2026
3fac897
fix(cli): 🐛 edit only effective properties, check endpoints tree-wide
claude Nov 9, 2026
3bbe931
feat(cli): ✨ put generated endpoints in the module's routes/ folder
claude Nov 9, 2026
1329211
feat(cli): ✨ list generate, doctor and inspect in the root help
claude Nov 9, 2026
1b4f594
fix(cli): 🐛 give g route's permission step its own manual steps
claude Nov 9, 2026
a459c20
docs: 📝 document routes/ layout and new generator, doctor and help be…
claude Nov 9, 2026
3686b93
fix(cli): 🐛 resolve a plugin's API definition once per doctor run
claude Nov 9, 2026
e94697e
fix(core): 🐛 refuse colliding staff permission reference keys
claude Nov 9, 2026
d2c8062
refactor(core): ♻️ split AI estimate route and rename run schemas
claude Nov 9, 2026
2677f00
refactor(core): ♻️ move cron, queue and content helpers to lib
claude Nov 9, 2026
262915b
refactor(core): ♻️ split client config route from its schema
claude Nov 9, 2026
b3518e6
refactor(core): ♻️ split moderation routes into one endpoint per file
claude Nov 9, 2026
cbad861
refactor(core): ♻️ split notification routes, schemas, tasks and crons
claude Nov 9, 2026
15a5f99
refactor(core): ♻️ split page layout routes and move layout helpers t…
claude Nov 9, 2026
0ed9c1a
refactor(core): ♻️ extract shared search schemas and helpers from routes
claude Nov 9, 2026
d43fb6c
refactor(core): ♻️ split user SSO routes and align user helper and sc…
claude Nov 9, 2026
083d405
refactor(core): ♻️ point core consumers at the moved middleware, pass…
claude Nov 9, 2026
1c4eb8f
docs: 📝 show doctor's single API definition failure and the reference…
claude Nov 9, 2026
72b6051
refactor(core): ♻️ point admin routes at the moved core paths
claude Nov 9, 2026
c2daeaa
style(core): 🎨 sort the AI view's schema import after the path change
claude Nov 9, 2026
fc5137a
fix(cli): 🐛 check every mount of a shared module for endpoint collisions
claude Nov 9, 2026
7172540
fix(core): 🐛 keep the API paths the file migration moved importable
claude Nov 9, 2026
7215ff3
docs: 📝 list the API imports the file migration moved
claude Nov 9, 2026
c9289ff
refactor(names): ✨ Simplify name generation functions and remove unus…
aXenDeveloper Nov 10, 2026
2648aab
feat(core): ✨ typed queue tasks with payload schemas
claude Nov 10, 2026
35f1e51
feat(core): ✨ typed cache entries with defineCacheEntry
claude Nov 10, 2026
6b1f7e9
feat(core): ✨ respondJson sends only the fields a response schema dec…
claude Nov 10, 2026
046b5b2
feat(core): ✨ schema-defined events with defineEvent
claude Nov 10, 2026
f4ae9ed
fix(core): 🐛 close response projection leaks and reject sparse JSON a…
claude Nov 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion apps/web/content/docs/content-engine/access-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ The constants are exported from `@vitnode/core/content`: `CONTENT_ACCESS_ACTIONS

`createContentAccess(c)` from `@vitnode/core/content/server` checks the current viewer inside a Hono route. Roles are read from the database once per request; the results are memoized for that request.

```ts title="plugins/forum/src/api/modules/forum/stats.routes.ts"
```ts title="plugins/forum/src/api/modules/forum/routes/show-topic-slug.route.ts"
import { createContentAccess } from "@vitnode/core/content/server";

const access = createContentAccess(c);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ A collection holds at most 5,000 containers (`limitReached`, 409). The whole tre

`createContentContainerService` is the one way to read and change a collection. The AdminCP routes and the public routes both use it, so your code gets the same rules, cache expiry and events. Call it inside any Hono handler with the request context. Here is a route you could add to the newsroom plugin. It files a desk under an **Archive** desk and locks it:

```ts title="src/api/modules/desks/archive.route.ts"
```ts title="src/api/modules/desks/routes/archive.route.ts"
import { createContentContainerService } from "@vitnode/core/content/server";
import { HTTPException } from "hono/http-exception";
import { z } from "zod";
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/content-engine/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export const announceStoryListener = buildEventListener({

### Add it to a top-level module

VitNode collects listeners only from the `events` array of a module passed directly to `buildApiPlugin`. A listener on a nested module is silently ignored.
VitNode collects listeners only from the `events` array of a module passed directly to `buildApiPlugin`. A listener on a nested module never runs: `buildApiPlugin` throws in development to say so, and [`vitnode doctor`](/docs/dev/cli/doctor) reports it. Production ignores it.

```ts title="plugins/newsroom/src/api/modules/admin/admin.module.ts (excerpt)"
export const adminModule = buildModule({
Expand Down
136 changes: 136 additions & 0 deletions apps/web/content/docs/content-engine/generate.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
---
title: Generate a content type
description: Scaffold an administrative VitNode Content Engine content type - definition, model, admin module, AdminCP screen and labels - with vitnode generate resource --kind content.
icon: Wand
---

`vitnode g resource <name> --kind content` writes the files [First content type](/docs/content-engine/create-a-content-type) walks through by hand: the definition, the model and its table export, the staff API mount, the AdminCP sidebar entry and screen, and the labels. The Content Engine then generates the CRUD routes and permissions, exactly as for a hand-written content type. The generator never writes a migration and never applies one.

The result is **administrative only**: staff create and edit records in the AdminCP. It has no public API, no member writes, no drafts and no ownership rules, because each of those changes who can read or write the records. Turn them on in the definition when you decide, following the linked guides.

## Before you begin

- A plugin made with [Create plugin](/docs/dev/plugins/create), with a terminal in its folder (here `plugins/site-notes`).
- A database, to apply the migration at the end.

<Steps>

<Step>

### Preview the content type

Name the resource, and list the fields as `name:type` with `text`, `richText`, `boolean`, `int` or `number`. A `?` after a name makes the field optional, and the first required `text` field becomes the title:

```bash
pnpm vitnode g resource articles --kind content --fields "title:text,body:richText,featured?:boolean" --singular Article --dry-run
```

```txt
would create src/content/articles.ts content type sitenotes.articles
would create src/database/articles.ts model and table site_notes_articles
would create src/api/modules/admin/admin.module.ts module "admin"; mount articlesContent in the admin module
would create src/admin/nav.tsx AdminCP sidebar entry
would create src/admin/content.tsx AdminCP screens, re-using the sidebar list
would update src/api/builders.ts bind the Content Engine admin builder
would update src/config.api.ts register adminModule in buildApiPlugin
would update src/locales/en.json AdminCP labels for the content type
```

The names are derived, never guessed from meaning:

| Value | Derived as | Override |
| ----------------- | ----------------------------------------------------- | ---------------- |
| Content type id | Plugin name without dashes, a dot, the resource name | `--content-id` |
| Table | Plugin name in snake_case, `_`, the resource name | `--table` |
| Permission module | The part of the id after the dot: `articles` | - |
| AdminCP path | `/admin/content/<plugin>/<resource>` | - |
| Id strategy | `serial`, the Content Engine default | `--id-type uuid` or `bigint` |
| Singular label | The plural, `Articles` | `--singular` |

The generator declares no permissions. The Content Engine gives the permission module `can_view`, `can_create`, `can_edit` and `can_delete`, but only when the plugin does not declare that module in `permissionStaff`, inline or through [`defineStaffPermissions`](/docs/dev/working-with-users/staff-permission-refs). A plugin that already declares `articles` with all four is fine. One that declares only some of them would leave staff routes nobody can be granted, so the command stops before writing:

```txt
✖ The plugin declares the permission module "articles" without can_create, can_edit, can_delete.
The Content Engine adds can_view, can_create, can_edit, can_delete to "articles" only when the plugin does not declare it, and src/config.api.ts declares it with can_view.
The generated staff routes would require permissions nobody can be granted. Nothing was written.
Add the missing ones to permissionStaff.admin["articles"] in src/config.api.ts:
{ permission: "can_create", dependsOn: ["can_view"] },
{ permission: "can_edit", dependsOn: ["can_view"] },
{ permission: "can_delete", dependsOn: ["can_view"] },
Or remove the "articles" entry, so the Content Engine declares all four itself.
```

It also stops, with the steps to finish by hand, when it cannot read the plugin's permission declarations: `permissionStaff` has to be an object literal or `staffPermissions.config`, not a variable such as `permissionStaff: permissions`.

</Step>

<Step>

### Generate it

Run the command without `--dry-run`. The definition it writes:

```ts title="plugins/site-notes/src/content/articles.ts"
import { defineContentType, field } from "@vitnode/core/content";

export const articlesContentType = defineContentType({
id: "sitenotes.articles",
tableName: "site_notes_articles",
fields: {
title: field.text({ maxLength: 200, required: true }),
body: field.richText({ required: true }),
featured: field.boolean({ defaultValue: false }),
},
admin: {
path: "site-notes/articles",
titleField: "title",
list: {
columns: ["title", "updatedAt"],
},
},
});
```

In an existing plugin, the generator adds to what is there: the model joins the `contentTypes` of an existing `buildContentAdminModule(...)` in the `admin` module, the entry joins `adminNav.contentTypes`, and labels are only added, never overwritten. It stops before writing, with the lines to add by hand, when `src/admin/content.tsx` builds its own `adminContent` instead of re-exporting `adminNav`.

</Step>

<Step>

### Build and migrate

Migrations are generated from the plugin's build output, so the order matters:

```bash
pnpm vitnode build # in plugins/site-notes, or keep pnpm dev running
cd ../../apps/api # the app with drizzle.config.ts; apps/web in a single-app setup
pnpm vitnode db generate # writes the migration for site_notes_articles
pnpm vitnode db migrate # applies it, after asking
```

Review the migration before you apply it. The root `pnpm db:migrate` script of a Turborepo workspace generates and applies in one go without asking, so it skips that review. See [Database commands](/docs/dev/cli/database).

</Step>

</Steps>

## Check the result

Run `pnpm vitnode plugin validate` in the plugin: **AdminCP navigation** and **Database schema** pass. Then open **AdminCP → Site notes → Articles**, choose **Create Article**, and save one. `pnpm vitnode inspect routes` lists the generated staff routes, such as `POST /site-notes/admin/content/articles` guarded by `articles.can_create`, and restricted administrators see the four permissions under **Site notes → Articles**.

The guides below continue the hand-written `newsroom.article` tutorial; apply their configuration to `articlesContentType` the same way.

## Related

<Cards>
<Card
title="Drafts & publishing"
description="Add a publication workflow to the content type"
href="/docs/content-engine/publication"
/>
<Card
title="Public API"
description="Decide which fields the public may read"
href="/docs/content-engine/public-api"
/>
</Cards>
1 change: 1 addition & 0 deletions apps/web/content/docs/content-engine/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"activity-logs",
"---Customization---",
"admincp",
"generate",
"services",
"events",
"live-refresh",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/content-engine/services.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ story?.title; // string, and a typo is a compile error

`publicService` returns exactly what the [public API](/docs/content-engine/public-api) would, so a custom public route can never leak a draft:

```ts title="plugins/newsroom/src/api/modules/stories/by-category.route.ts"
```ts title="plugins/newsroom/src/api/modules/stories/routes/by-category.route.ts"
handler: async c => {
const { publicService } = storyContent;
if (!publicService) throw new Error("Stories have no public API.");
Expand Down
16 changes: 12 additions & 4 deletions apps/web/content/docs/content-engine/threads-editing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -262,18 +262,18 @@ Add `delivery` to the comment type. This is an excerpt:

Delivery claims the type's `publicApi.path` across the whole site. That's why this guide picked `article-comments`. The Blog plugin already delivers `comments`, and two plugins can't share a path.

The redirect needs the article's slug, and a comment only knows its article's id. Add a small public route that answers it. Create `src/api/modules/articles/articles.module.ts`:
The redirect needs the article's slug, and a comment only knows its article's id. Add a small public route that answers it. Create `src/api/modules/articles/routes/show-slug.route.ts`:

```ts title="plugins/newsroom/src/api/modules/articles/articles.module.ts"
```ts title="plugins/newsroom/src/api/modules/articles/routes/show-slug.route.ts"
import { z } from "@hono/zod-openapi";
import { setPageCacheTags } from "@vitnode/core/api/lib/page-cache";
import { contentPublicListTag } from "@vitnode/core/content";
import { HTTPException } from "hono/http-exception";

import { buildModule, buildRoute } from "@/api/builders";
import { buildRoute } from "@/api/builders";
import { articleContent } from "@/database/articles";

const articleSlugRoute = buildRoute({
export const articleSlugRoute = buildRoute({
route: {
method: "get",
path: "/{id}/slug",
Expand Down Expand Up @@ -311,6 +311,14 @@ const articleSlugRoute = buildRoute({
return c.json({ id: article.id, slug: article.slug }, 200);
},
});
```

Group it in a module. Create `src/api/modules/articles/articles.module.ts`:

```ts title="plugins/newsroom/src/api/modules/articles/articles.module.ts"
import { buildModule } from "@/api/builders";

import { articleSlugRoute } from "./routes/show-slug.route";

export const articlesModule = buildModule({
name: "articles",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/content/docs/dev/advanced/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"rate-limiter",
"rate-limiter-debugging",
"queue",
"queue-typed-payloads",
"queue-reference",
"redis"
]
}
Loading
Loading