Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion .github/workflows/build-lint-test.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,39 @@
name: Build, Lint & Test

on:
# No `branches` filter: every pull request runs, whatever it targets. The old
# `"*"` glob matched a single path segment, so a PR into `perf/improve_errors`
# silently ran nothing at all.
pull_request:
branches: "*"
types:
- opened
- edited
- synchronize
- reopened

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15

# A throwaway database for the Content Engine's Postgres smoke test. The
# suite skips itself when `DATABASE_TEST_URL` is unset, and refuses to run
# against a database whose name does not contain "test".
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: vitnode_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- name: Checkout
uses: actions/checkout@v7
Expand Down Expand Up @@ -47,3 +68,8 @@ jobs:

- name: Run tests
run: pnpm test
env:
DATABASE_TEST_URL: postgres://postgres:postgres@localhost:5432/vitnode_test

- name: Run type tests
run: pnpm test:types
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@vitnode/blog": "workspace:*",
"@vitnode/example": "workspace:*",
"@vitnode/config": "workspace:*",
"@vitnode/nodemailer": "workspace:*",
"dotenv": "^17.4.2",
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/vitnode.api.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { google } from "@ai-sdk/google";
import { blogApiPlugin } from "@vitnode/blog/config.api";
// import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local";
import { buildApiConfig } from "@vitnode/core/vitnode.config";
import { exampleApiPlugin } from "@vitnode/example/config.api";
import { NodeCronAdapter } from "@vitnode/node-cron";
import { NodemailerEmailAdapter } from "@vitnode/nodemailer";
// import { S3StorageAdapter } from "@vitnode/s3";
Expand All @@ -17,7 +18,7 @@ export const POSTGRES_URL =
process.env.POSTGRES_URL ?? "postgresql://root:root@localhost:5432/vitnode";

export const vitNodeApiConfig = buildApiConfig({
plugins: [blogApiPlugin()],
plugins: [blogApiPlugin(), exampleApiPlugin()],
ai: {
models: [
{
Expand Down
9 changes: 9 additions & 0 deletions apps/docs/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->
1 change: 1 addition & 0 deletions apps/docs/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
135 changes: 135 additions & 0 deletions apps/docs/content/docs/dev/content-engine/admincp.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
---
title: Generated AdminCP
description: The list, form and delete screens you get for free - and the one route that serves all of them.
icon: LayoutDashboard
---

Registering a content type in `buildPlugin` is the entire frontend integration.
There is no page to write.

```tsx title="src/config.tsx"
contentTypes: [
contentTypeAdmin({
definition: articleContentType,
icon: <NotebookPenIcon />,
}),
],
```

You get a nav item, a breadcrumb, and a screen at:

```text
/admin/content/example/article
```

## What the screen does

- **List** - a `DataTable` with the columns from `admin.list.columns`
- **Search** - across `admin.list.searchableFields`, wildcards escaped
- **Sorting** - limited to `admin.list.orderableFields` plus the system columns
- **Pagination** - the standard cursor pagination, capped at 100 per page
- **Create / Edit** - `AutoForm` dialogs, lazy-loaded on open
- **Delete** - a confirmation dialog
- **Empty, loading and error states** - out of the box

## What "lazy-loaded on open" actually means

The dialog body is only in the tree while that dialog is open. A 25-row table
renders 25 edit buttons and, at most, **one** form - Base UI's portal mounts the
body on open and unmounts it on close, so no other row's form, and none of its
`react-hook-form` state, exists.

The form itself is one `next/dynamic` boundary shared by the create and the edit
dialog, because they render the same component. That is worth about 465 KB of
chunks, so it is downloaded once:

- the **first** dialog you open shows the `<Loader />` while those chunks arrive,
- every dialog after that opens instantly, with no loader.

<Callout type="info" title="A missing loader is the good outcome">
No spinner on the second dialog means the code was already there. Splitting
create and edit into separate lazy modules would either download the same form
twice or land back on a shared chunk anyway - so the loader would be a few
milliseconds of theatre either way.
</Callout>

## Mutation feedback

Every mutation closes its dialog, refreshes the list and raises a `sonner` toast
with a description. On create there is no row to name yet, so the description is
the title that was just typed.

Failures get an error toast, and the description depends on what actually went
wrong - a delete blocked by a foreign key does not read like a crashed server:

| Status | The person is told |
| --- | --- |
| 400 | some of these values are not valid |
| 403 | you do not have permission to do this |
| 404 | this record no longer exists |
| 409 | this record is still referenced by other content |
| anything else | the generic server error |

<Callout type="info" title="The database never speaks to the user">
The generated routes translate Postgres error codes into a status and a generic
sentence. Constraint names, column names and values stay on the server; the
detail goes to `core_logs`.
</Callout>

## One route, every content type

Core ships a single catch-all page that is synced into your app like any other
plugin route:

```text
packages/vitnode/src/routes/admin/content/[...slug]/page.tsx
packages/vitnode/src/routes/breadcrumb/admin/content/[...slug]/page.tsx
```

The slug maps back onto a content type id - `/admin/content/example/article`
resolves `example.article` from the registered plugins at request time. Add a
tenth content type and the file count stays at two.

## Field to component

| Kind | Component |
| --- | --- |
| `text` | `AutoFormInput` |
| `textarea` | `AutoFormTextarea` |
| `number` | number input, or `AutoFormNullableNumber` when nullable |
| `boolean` | `AutoFormSwitch` |
| `enum` | `AutoFormSelect`, or `AutoFormRadioGroup` with `display: "radio"` |
| `dateTime` | `AutoFormDateTime` |
| `user` | `AutoFormCombobox`, async |
| `relation` | `AutoFormCombobox`, async |

These are the components every other VitNode admin screen uses. The engine adds
no second form system - `AutoForm` does the work, exactly as it does in the blog
plugin.

## The server/client boundary

The page is a server component; the form is a client one. A definition cannot
cross that boundary - it holds `target` thunks and Zod schemas, neither of which
serialise.

So the server projects the definition into a plain JSON **spec** - field kinds,
resolved labels, enum options, validation bounds - and the client rebuilds the
form schema from it:

```text
server client
────── ──────
definition ──► buildContentFormSpec ──► buildFormSchemaFromSpec ──► AutoForm
(plain JSON)
```

The relation pickers go through a server action rather than a client fetch, so
the browser never needs the API origin and the request is gated by the content
type's own `can_view`.

## Permissions

The page checks `can_view` server-side and 404s without it. The create, edit and
delete controls check their own permissions client-side - and the routes behind
them check again, which is the check that actually matters.
Loading
Loading