Skip to content
Merged
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { Activity } from "react";

- `fetcher` from `@vitnode/core/tanstack/fetcher` is universal - one call, SSR and browser. Never hand-write `createIsomorphicFn().server(...).client(...)` for a fetch.
- A call names the plugin, never an API module: `fetcher({ plugin: "@vitnode/core", method: "get", module: "users", path: "/devices" })`. `plugin`, `module` (any nesting depth, `admin/advanced/cron`), `path`, `method`, `args` and the response are all inferred from the plugin API registry; an invalid combination is a compile error at the offending property.
- The registry is `ApiPluginRegistry` in `@vitnode/core/lib/fetcher/registry`. Core registers itself; an app's `src/api-registry.gen.ts` registers every configured plugin that exports `config.api`, with type-only imports; a plugin registers its own API in its `global.d.ts` so its pages can call it. Never import a plugin API factory as a value outside `vitnode.api.config.ts`.
- The registry is `ApiPluginRegistry` in `@vitnode/core/lib/fetcher/registry`. Core registers itself; an app's `src/api-registry.gen.ts` registers every configured plugin that exports `config.api`, with type-only imports; `vitnode build`/`vitnode dev` write a plugin's own entry to its `types/api-registry.gen.d.ts` (kept out of the published package) so its pages can call it. Never import a plugin API factory as a value outside `vitnode.api.config.ts`.
- `@vitnode/core/tanstack/fetcher/server` is for work that is genuinely server-only: server functions, `allowSaveCookies` cookie relay, cron/jobs, upstream secrets or a different `origin`. Same call shape, more options.
- Shared `views/*` query modules call `fetcher` directly - no injected transport, no browser-only twin. `@vitnode/core/lib/fetcher-client` is the framework-neutral browser primitive the universal fetcher is built on, for code that never renders on a server.
- Write the route inline at the call site - never build a request object elsewhere and pass it in.
Expand Down Expand Up @@ -81,6 +81,11 @@ import { Activity } from "react";
- Document every new feature. Keep it simple, SEO-friendly, and understandable at any skill level.
- Friendly and lightly funny tone - don't overdo it.
- Skip big comments; code is self-documenting.
- Developer-facing APIs in feature code (exports of `@vitnode/core` and plugins that app or plugin authors call) need concise English JSDoc on the original declaration:
- Say what it is for, plus constraints, side effects, runtime (server-only, browser-only or universal) and `@throws` only when they are real and non-obvious.
- Document configuration properties one by one, including non-obvious defaults and constraints.
- Give major entry points a minimal `@example` with real imports.
- A behavior change updates the JSDoc in the same change. Keep tutorials in the MDX docs.
- Request images with a comment: `// Image prompt: {here_prompt_to_generate_image}`
- Put install commands in tabbed code blocks with correct syntax highlighting:

Expand Down
51 changes: 31 additions & 20 deletions apps/web/content/docs/dev/captcha/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ Enable captcha verification on any custom Hono endpoint with `withCaptcha: true`
import { buildRoute } from "@/api/builders"

export const createCommentRoute = buildRoute({
withCaptcha: true, // [!code ++]
route: {
withCaptcha: true, // [!code ++]
method: "post",
path: "/",
responses: { 201: { description: "Comment created" } },
Expand All @@ -64,36 +64,47 @@ export const createCommentRoute = buildRoute({

### 3. Render the Frontend Widget

Include `<Captcha />` in your form and supply the received token to the fetcher:
Pass the app's captcha config to `AutoForm`. It loads the widget, keeps the submit button disabled until the captcha is ready, and hands `onSubmit` a fresh token to forward as the fetcher's `captchaToken`:

```tsx title="plugins/blog/src/views/comment-form.tsx"
import { Captcha } from "@vitnode/core/components/captcha"
import { AutoForm } from "@vitnode/core/components/form/auto-form"
import { AutoFormTextarea } from "@vitnode/core/components/form/fields/textarea"
import { useMiddlewareConfigQuery } from "@vitnode/core/tanstack/auth"
import { fetcher } from "@vitnode/core/tanstack/fetcher"
import { z } from "zod"

const formSchema = z.object({ text: z.string().min(1) })

export const CommentForm = () => {
const [captchaToken, setCaptchaToken] = React.useState<string>()

const handleSubmit = async () => {
await fetcher({
plugin: "@acme/blog",
method: "post",
module: "comments",
path: "/",
captchaToken, // [!code ++]
args: { body: { text: "Nice article!" } },
})
}
const { data: config } = useMiddlewareConfigQuery()

return (
<form onSubmit={handleSubmit}>
<textarea />
<Captcha onVerify={setCaptchaToken} />
<button disabled={!captchaToken}>Submit Comment</button>
</form>
<AutoForm
captcha={config.captcha} // [!code ++]
fields={[
{
id: "text",
component: (props) => <AutoFormTextarea {...props} label="Comment" />,
},
]}
formSchema={formSchema}
onSubmit={async (values, form, { captchaToken }) => {
await fetcher({
plugin: "@acme/blog",
method: "post",
module: "comments",
path: "/",
captchaToken, // [!code ++]
args: { body: values },
})
}}
/>
)
}
```

`useMiddlewareConfigQuery` suspends until the config loads, so render the form under a `Suspense` boundary. Outside `AutoForm`, use the `useCaptcha` hook from `@vitnode/core/hooks/use-captcha`: it returns `isReady`, `getToken()` and `onReset()`, and for Turnstile your form must contain an element with `id="vitnode_captcha"`.

---

## Supported Providers
Expand Down
28 changes: 17 additions & 11 deletions apps/web/content/docs/dev/email/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,39 +71,45 @@ use this for every send.

Configure your delivery transport in `apps/api/src/vitnode.api.config.ts`:

Each provider ships as its own package. Install one, then pass it as `email.adapter` - the sender address belongs to the adapter.

### 1. Resend Adapter

```ts title="apps/api/src/vitnode.api.config.ts"
import { ResendEmailAdapter } from '@vitnode/core/api/adapters/email/resend'
import { ResendEmailAdapter } from '@vitnode/resend'

export const vitNodeApiConfig = buildApiConfig({
email: {
from: 'noreply@yourdomain.com',
adapter: ResendEmailAdapter({
apiKey: process.env.RESEND_API_KEY!,
apiKey: process.env.RESEND_API_KEY,
from: process.env.RESEND_FROM_EMAIL,
}),
},
})
```

### 2. SMTP Adapter
See [Resend](/docs/dev/email/resend) for installation and setup.

### 2. SMTP Adapter (Nodemailer)

```ts title="apps/api/src/vitnode.api.config.ts"
import { SmtpEmailAdapter } from '@vitnode/core/api/adapters/email/smtp'
import { NodemailerEmailAdapter } from '@vitnode/nodemailer'

export const vitNodeApiConfig = buildApiConfig({
email: {
from: 'noreply@yourdomain.com',
adapter: SmtpEmailAdapter({
host: process.env.SMTP_HOST!,
port: Number(process.env.SMTP_PORT ?? 587),
user: process.env.SMTP_USER!,
password: process.env.SMTP_PASSWORD!,
adapter: NodemailerEmailAdapter({
from: process.env.NODE_MAILER_FROM,
host: process.env.NODE_MAILER_HOST,
port: Number(process.env.NODE_MAILER_PORT ?? 587),
user: process.env.NODE_MAILER_USER,
password: process.env.NODE_MAILER_PASSWORD,
}),
},
})
```

`port` defaults to `587` and `secure` to `false`. See [Nodemailer](/docs/dev/email/nodemailer) for installation and setup.

---

## Testing Email in AdminCP
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/events/built-in-events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1292,7 +1292,7 @@ categories yet.
},
categoryId: {
description:
"The post's first category. An article is in many categories now and this payload has room for one - watch content.blog.post.created for the whole set.",
"The post's first category. An article is in many categories now and this payload has room for one. content.blog.post.created only carries the contentId, so read the full set from the article's relations.",
type: 'number',
},
}}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/content/docs/dev/fetcher.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ The response is status-aware: `response.status` is typed as a union of declared
Each plugin exports a lightweight contract type from its `config.api.ts`:

```ts title="plugins/site-notes/src/config.api.ts"
import type { ApiPluginContract } from '@vitnode/core/lib/fetcher'
import type { ApiPluginContract } from '@vitnode/core/api/lib/plugin'

export type VitNodeApiPlugin = ApiPluginContract<
ReturnType<typeof siteNotesApiPlugin>
Expand Down
81 changes: 54 additions & 27 deletions apps/web/content/docs/dev/plugins/admin/dashboard-widgets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ icon: LayoutDashboard
import { BarChart3Icon, Settings2Icon } from 'lucide-react'

Dashboard widgets are **AdminCP plugin extensions**. Define the component and
register it in the same plugin factory that owns the feature; the host dashboard
then discovers it automatically.
list it on the plugin's `adminNav` export, next to its sidebar entries; the
AdminCP dashboard then discovers it automatically.

{/* Image prompt: VitNode AdminCP dashboard with a “Site notes” statistics widget in a draggable grid. Show the widget panel, the corner resize bracket on each card, and the settings gear. Dark theme, 1600x900. */}

Expand All @@ -33,34 +33,39 @@ export const StatsWidget = ({ settings }: AdminDashboardWidgetProps) => (
</Step>
<Step>

### Register it in the existing plugin factory
### Register it on the plugin's `adminNav`

Add it to the `adminNav` your plugin exports from its `admin/nav` module (the
same object that holds its [sidebar entries](/docs/dev/plugins/admin)). VitNode
collects every plugin's `adminNav` into the app's generated `admin-nav.gen.ts`,
and the AdminCP shell hands the widgets to the dashboard.

```tsx title="plugins/site-notes/src/admin/nav.tsx"
import type { AdminNavPluginSource } from '@vitnode/core/lib/plugin'

```tsx title="plugins/site-notes/src/config.tsx"
import { BarChart3Icon } from 'lucide-react'

import { StatsWidget } from './views/admin/widgets/stats-widget'

export const siteNotesPlugin = () =>
buildPlugin({
admin: {
dashboard: {
widgets: [
// [!code ++:9]
{
component: StatsWidget,
defaultEnabled: true,
defaultRows: 1,
defaultSpan: 1,
icon: <BarChart3Icon />,
id: 'stats',
},
],
},
import { manifest } from '@/manifest.gen'
import { StatsWidget } from '@/views/admin/widgets/stats-widget'

export const adminNav = {
pluginId: manifest.id,
admin: {
dashboard: {
widgets: [
// [!code ++:8]
{
component: StatsWidget,
defaultEnabled: true,
defaultRows: 1,
defaultSpan: 1,
icon: <BarChart3Icon />,
id: 'stats',
},
],
},
messages,
pluginId: '@acme/site-notes',
routes,
})
},
} satisfies AdminNavPluginSource
```

The full widget id is namespaced by the plugin, so `stats` will not collide with
Expand All @@ -70,13 +75,35 @@ enough already.
</Step>
<Step>

### Name it

The widget's title comes from your plugin's frontend messages, under
`admin.dashboard.widgets.{id}`. A `desc` is optional and shows in the widget panel.

```json title="plugins/site-notes/src/locales/en.json"
{
"@acme/site-notes": {
"admin": {
"dashboard": {
"widgets": {
"stats": { "title": "Note stats", "desc": "How many notes are live." }
}
}
}
}
}
```

</Step>
<Step>

### Add a permission or settings UI when needed

Add `permission` to hide a widget from staff who should not see it. Add
`settingsComponent` when administrators need to save preferences; both stay in
the plugin alongside the widget.

```tsx title="plugins/site-notes/src/config.tsx"
```tsx title="plugins/site-notes/src/admin/nav.tsx"
{
component: StatsWidget,
// [!code ++:4]
Expand Down
62 changes: 36 additions & 26 deletions apps/web/content/docs/dev/search.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,45 +52,55 @@ await c.get('search').delete('article', article.id)

### Register a rebuild indexer

To allow admins to re-index all historical content from the AdminCP, register a search indexer in your plugin's `config.api.ts`:
To allow admins to re-index all historical content from the AdminCP, give your plugin a search indexer. It rebuilds one item type page by page: `load` returns the documents for up to `limit` source rows starting at `offset`, plus how many rows it read (`0` ends the rebuild). `count` is optional and powers the coverage report.

```ts title="plugins/blog/src/api/indexers/post.indexer.ts"
import { buildSearchIndexer } from '@vitnode/core/api/lib/search'
import { blog_posts } from '@/database/posts'
<Callout type="info" title="Content types index themselves">
A Content Engine type with `search` enabled gets its indexer automatically.
Write one only for data of your own.
</Callout>

export const postSearchIndexer = buildSearchIndexer({
itemType: 'article',
totalCount: async (c) => {
return await c.get('db').$count(blog_posts)
},
batch: async (c, { limit, offset }) => {
const posts = await c
```ts title="plugins/recipes/src/api/indexers/recipe.indexer.ts"
import type { SearchIndexer } from '@vitnode/core/api/models/search'

import { asc } from 'drizzle-orm'

import { recipes } from '@/database/recipes'

export const recipeSearchIndexer: SearchIndexer = {
itemType: 'recipe',
count: async (c) => await c.get('db').$count(recipes),
load: async (c, offset, limit) => {
const rows = await c
.get('db')
.select()
.from(blog_posts)
.from(recipes)
.orderBy(asc(recipes.id))
.limit(limit)
.offset(offset)

return posts.map((post) => ({
itemType: 'article',
itemId: post.id,
title: post.title,
content: post.content,
url: `/articles/${post.slug}`,
authorIds: [post.authorId],
createdAt: post.createdAt,
}))
return {
documents: rows.map((recipe) => ({
itemType: 'recipe',
itemId: recipe.id,
title: recipe.title,
content: recipe.description,
url: `/recipes/${recipe.slug}`,
authorIds: [recipe.authorId],
createdAt: recipe.createdAt,
})),
itemsRead: rows.length,
}
},
})
}
```

Register it in `config.api.ts`:
Register it in `config.api.ts` (a module's `searchIndexers` works too):

```ts title="plugins/blog/src/config.api.ts"
export const blogApiPlugin = () =>
```ts title="plugins/recipes/src/config.api.ts"
export const recipesApiPlugin = () =>
buildApiPlugin({
manifest,
searchIndexers: [postSearchIndexer], // [!code ++]
searchIndexers: [recipeSearchIndexer], // [!code ++]
})
```

Expand Down
4 changes: 2 additions & 2 deletions apps/web/content/docs/dev/widgets/defining-a-block.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,15 @@ The icon only shows up in the editor. Your widget's component on the public page
```ts
interface WidgetComponentProps<TData> {
/** The instance's own stable id - not the widget's. */
widgetId: string
blockId: string
data: TData
index: number
type: string
variant?: string
}
```

`data` has already been through the widget's schema when the component runs, so a component never checks for missing fields. `variant` is the look this instance picked, and is `undefined` for a widget that offers none.
`data` is checked against the widget's schema when the page is saved, and its shape is checked again before render, so a component never checks for missing fields. Defaults are filled in when it is saved, not at render. `variant` is the look this instance picked, and is `undefined` for a widget that offers none.

<Callout type="warn" title="A text field is not a safe link">
The schema checks that a `field.text()` is a string, not that it is a URL you want people clicking. If your widget turns one into an `href`, check it with `isValidNavigationHref` from `@vitnode/core/lib/navigation` first - it allows a site path (`/discover`) or an `https://` URL and refuses `javascript:` and friends. Core's Hero and Call to action widgets simply leave the link out when it fails.
Expand Down
8 changes: 7 additions & 1 deletion packages/vitnode/scripts/cli/builder/package-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,13 @@ export const packageTsdownConfig = ({
dts:
output === "javascript"
? false
: { eager: true, emitDtsOnly: output === "declarations" },
: {
// The shared tsconfig sets `removeComments`, which would strip the
// JSDoc an IDE shows on hover from every published declaration.
compilerOptions: { removeComments: false },
eager: true,
emitDtsOnly: output === "declarations",
},
entry: listPackageEntries(root),
fixedExtension: false,
format: "esm",
Expand Down
Loading
Loading