diff --git a/AGENTS.md b/AGENTS.md
index d762a0d79c..d22e3fa454 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
@@ -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:
diff --git a/apps/web/content/docs/dev/captcha/index.mdx b/apps/web/content/docs/dev/captcha/index.mdx
index 45b0c78187..f1863c9565 100644
--- a/apps/web/content/docs/dev/captcha/index.mdx
+++ b/apps/web/content/docs/dev/captcha/index.mdx
@@ -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" } },
@@ -64,36 +64,47 @@ export const createCommentRoute = buildRoute({
### 3. Render the Frontend Widget
-Include `` 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()
-
- 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 (
-
+ ,
+ },
+ ]}
+ 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
diff --git a/apps/web/content/docs/dev/email/index.mdx b/apps/web/content/docs/dev/email/index.mdx
index 0f7f11aa78..bec8356017 100644
--- a/apps/web/content/docs/dev/email/index.mdx
+++ b/apps/web/content/docs/dev/email/index.mdx
@@ -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
diff --git a/apps/web/content/docs/dev/events/built-in-events.mdx b/apps/web/content/docs/dev/events/built-in-events.mdx
index 098bef4cb3..6fb07331e5 100644
--- a/apps/web/content/docs/dev/events/built-in-events.mdx
+++ b/apps/web/content/docs/dev/events/built-in-events.mdx
@@ -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',
},
}}
diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx
index 794d153e93..41083a5018 100644
--- a/apps/web/content/docs/dev/fetcher.mdx
+++ b/apps/web/content/docs/dev/fetcher.mdx
@@ -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
diff --git a/apps/web/content/docs/dev/plugins/admin/dashboard-widgets.mdx b/apps/web/content/docs/dev/plugins/admin/dashboard-widgets.mdx
index ef204390fb..68d5946f94 100644
--- a/apps/web/content/docs/dev/plugins/admin/dashboard-widgets.mdx
+++ b/apps/web/content/docs/dev/plugins/admin/dashboard-widgets.mdx
@@ -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. */}
@@ -33,34 +33,39 @@ export const StatsWidget = ({ settings }: AdminDashboardWidgetProps) => (
-### 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: ,
- 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: ,
+ 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
@@ -70,13 +75,35 @@ enough already.
+### 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." }
+ }
+ }
+ }
+ }
+}
+```
+
+
+
+
### 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]
diff --git a/apps/web/content/docs/dev/search.mdx b/apps/web/content/docs/dev/search.mdx
index d1732a04e2..ba93fa35b4 100644
--- a/apps/web/content/docs/dev/search.mdx
+++ b/apps/web/content/docs/dev/search.mdx
@@ -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'
+
+ A Content Engine type with `search` enabled gets its indexer automatically.
+ Write one only for data of your own.
+
-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 ++]
})
```
diff --git a/apps/web/content/docs/dev/widgets/defining-a-block.mdx b/apps/web/content/docs/dev/widgets/defining-a-block.mdx
index 068f73f1de..fc4935275e 100644
--- a/apps/web/content/docs/dev/widgets/defining-a-block.mdx
+++ b/apps/web/content/docs/dev/widgets/defining-a-block.mdx
@@ -67,7 +67,7 @@ The icon only shows up in the editor. Your widget's component on the public page
```ts
interface WidgetComponentProps {
/** The instance's own stable id - not the widget's. */
- widgetId: string
+ blockId: string
data: TData
index: number
type: string
@@ -75,7 +75,7 @@ interface WidgetComponentProps {
}
```
-`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.
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.
diff --git a/packages/vitnode/scripts/cli/builder/package-build.ts b/packages/vitnode/scripts/cli/builder/package-build.ts
index 99711709f1..f29e063926 100644
--- a/packages/vitnode/scripts/cli/builder/package-build.ts
+++ b/packages/vitnode/scripts/cli/builder/package-build.ts
@@ -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",
diff --git a/packages/vitnode/src/api/adapters/sso/discord.ts b/packages/vitnode/src/api/adapters/sso/discord.ts
index a6b0d470a0..9ecfd08341 100644
--- a/packages/vitnode/src/api/adapters/sso/discord.ts
+++ b/packages/vitnode/src/api/adapters/sso/discord.ts
@@ -28,11 +28,38 @@ const discordAvatarUrl = ({
return `https://cdn.discordapp.com/avatars/${id}/${avatar}.png?size=512`;
};
+/**
+ * Sign in with Discord: an SSO adapter for the API config's
+ * `authorization.ssoAdapters`. Requests the `identify email` scopes and
+ * redirects back to `login/sso/discord` under `VITNODE_WEB_URL` - register that URL
+ * as the OAuth redirect URI. Refuses (`400`) a Discord account whose email is not verified. Supplies the avatar for profile sync.
+ *
+ * Server-only.
+ *
+ * @example
+ * ```ts
+ * import { DiscordSSOApiPlugin } from "@vitnode/core/api/adapters/sso/discord";
+ *
+ * buildApiConfig({
+ * authorization: {
+ * ssoAdapters: [
+ * DiscordSSOApiPlugin({
+ * clientId: process.env.DISCORD_CLIENT_ID,
+ * clientSecret: process.env.DISCORD_CLIENT_SECRET,
+ * }),
+ * ],
+ * },
+ * // ...
+ * });
+ * ```
+ */
export const DiscordSSOApiPlugin = ({
clientId = "",
clientSecret = "",
}: {
+ /** The OAuth client ID. `undefined` still registers the button, but signing in throws until it is set. */
clientId: string | undefined;
+ /** The OAuth client secret. Needed to exchange the code; signing in throws without it. */
clientSecret: string | undefined;
}): SSOApiPlugin => {
const id = "discord";
diff --git a/packages/vitnode/src/api/adapters/sso/facebook.ts b/packages/vitnode/src/api/adapters/sso/facebook.ts
index c1d12b3e6b..6a899e2f2a 100644
--- a/packages/vitnode/src/api/adapters/sso/facebook.ts
+++ b/packages/vitnode/src/api/adapters/sso/facebook.ts
@@ -17,11 +17,38 @@ export const FACEBOOK_GRAPH_VERSION = "v26.0";
const FACEBOOK_USER_FIELDS =
"id,name,email,first_name,last_name,picture.width(512).height(512){url,is_silhouette}";
+/**
+ * Sign in with Facebook: an SSO adapter for the API config's
+ * `authorization.ssoAdapters`. Requests the `public_profile,email` scopes and
+ * redirects back to `login/sso/facebook` under `VITNODE_WEB_URL` - register that URL
+ * as the OAuth redirect URI. Supplies the avatar, first and last name for profile sync.
+ *
+ * Server-only.
+ *
+ * @example
+ * ```ts
+ * import { FacebookSSOApiPlugin } from "@vitnode/core/api/adapters/sso/facebook";
+ *
+ * buildApiConfig({
+ * authorization: {
+ * ssoAdapters: [
+ * FacebookSSOApiPlugin({
+ * clientId: process.env.FACEBOOK_CLIENT_ID,
+ * clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
+ * }),
+ * ],
+ * },
+ * // ...
+ * });
+ * ```
+ */
export const FacebookSSOApiPlugin = ({
clientId,
clientSecret,
}: {
+ /** The OAuth client ID. `undefined` still registers the button, but signing in throws until it is set. */
clientId: string | undefined;
+ /** The OAuth client secret. Needed to exchange the code; signing in throws without it. */
clientSecret: string | undefined;
}): SSOApiPlugin => {
const id = "facebook";
diff --git a/packages/vitnode/src/api/adapters/sso/google.ts b/packages/vitnode/src/api/adapters/sso/google.ts
index e2ab02df39..89af62a7bf 100644
--- a/packages/vitnode/src/api/adapters/sso/google.ts
+++ b/packages/vitnode/src/api/adapters/sso/google.ts
@@ -12,11 +12,38 @@ import { GOOGLE_ICON } from "./icons";
// A provider that stops answering would otherwise hang the sign-in request.
const PROVIDER_TIMEOUT_MS = 10_000;
+/**
+ * Sign in with Google: an SSO adapter for the API config's
+ * `authorization.ssoAdapters`. Requests the `openid profile email` scopes and
+ * redirects back to `login/sso/google` under `VITNODE_WEB_URL` - register that URL
+ * as the OAuth redirect URI. Refuses (`400`) a Google account whose email is not verified. Supplies the avatar, first and last name for profile sync.
+ *
+ * Server-only.
+ *
+ * @example
+ * ```ts
+ * import { GoogleSSOApiPlugin } from "@vitnode/core/api/adapters/sso/google";
+ *
+ * buildApiConfig({
+ * authorization: {
+ * ssoAdapters: [
+ * GoogleSSOApiPlugin({
+ * clientId: process.env.GOOGLE_CLIENT_ID,
+ * clientSecret: process.env.GOOGLE_CLIENT_SECRET,
+ * }),
+ * ],
+ * },
+ * // ...
+ * });
+ * ```
+ */
export const GoogleSSOApiPlugin = ({
clientId,
clientSecret,
}: {
+ /** The OAuth client ID. `undefined` still registers the button, but signing in throws until it is set. */
clientId: string | undefined;
+ /** The OAuth client secret. Needed to exchange the code; signing in throws without it. */
clientSecret: string | undefined;
}): SSOApiPlugin => {
const id = "google";
diff --git a/packages/vitnode/src/api/adapters/storage/local.ts b/packages/vitnode/src/api/adapters/storage/local.ts
index 5c7b0d6add..9c17467d42 100644
--- a/packages/vitnode/src/api/adapters/storage/local.ts
+++ b/packages/vitnode/src/api/adapters/storage/local.ts
@@ -9,11 +9,29 @@ import type {
import { CONFIG } from "@/lib/config";
+/**
+ * Stores uploads on the API server's disk, under `public/uploads` in the
+ * process's working directory. For development and single-server installs; use
+ * a cloud adapter when the API runs on more than one machine or on an
+ * ephemeral filesystem.
+ *
+ * Files are served only once the app mounts `serveStatic` from the adapter's
+ * `static` descriptor in its server entry. Server-only.
+ *
+ * @example
+ * ```ts
+ * import { LocalStorageAdapter } from "@vitnode/core/api/adapters/storage/local";
+ *
+ * buildApiConfig({ storage: { adapter: LocalStorageAdapter() } });
+ * ```
+ */
export const LocalStorageAdapter = ({
baseUrl,
publicPath = "/api/uploads",
}: {
+ /** The origin public URLs start with. Defaults to the origin of `VITNODE_API_URL` (`http://localhost:3000` when unset). */
baseUrl?: string;
+ /** The URL path files are served under. Defaults to `/api/uploads`. */
publicPath?: string;
} = {}): StorageApiPlugin => {
// Static path segments (not a variable) so Next's file tracer can scope the
diff --git a/packages/vitnode/src/api/builders.ts b/packages/vitnode/src/api/builders.ts
index 253a8c9bd2..7d863600eb 100644
--- a/packages/vitnode/src/api/builders.ts
+++ b/packages/vitnode/src/api/builders.ts
@@ -2,4 +2,5 @@ import { manifest } from "@/manifest";
import { bindApiPlugin } from "./lib/bind-api-plugin";
+/** Core's own builders, bound to `@vitnode/core`. Plugins bind their own with `bindApiPlugin`. */
export const { buildModule, buildRoute } = bindApiPlugin(manifest);
diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts
index d7c41dd5f8..121bbb8307 100644
--- a/packages/vitnode/src/api/config.ts
+++ b/packages/vitnode/src/api/config.ts
@@ -64,6 +64,29 @@ interface CSRFOptions {
origin?: IsAllowedOriginHandler | string | string[];
}
+/**
+ * Mounts VitNode on a Hono app: security headers, CORS, CSRF, the body limit,
+ * rate limiting, the per-request context (`c.get("db")`, `c.get("cache")` and
+ * friends), core and every plugin at `/{pluginId}`, cron registration, and an
+ * error handler that hides details outside development. Call it once at
+ * startup; it returns the same `app`.
+ *
+ * Server-only. Give `app` its base path (`/api`) before calling it.
+ *
+ * @throws At startup when the configuration is invalid - plugin content
+ * modules, colliding registrations, misconfigured passkeys or locale domains.
+ *
+ * @example
+ * ```ts
+ * import { OpenAPIHono } from "@hono/zod-openapi";
+ * import { VitNodeAPI } from "@vitnode/core/api/config";
+ *
+ * import { vitNodeApiConfig } from "./vitnode.api.config";
+ *
+ * const app = new OpenAPIHono().basePath("/api");
+ * VitNodeAPI({ app, vitNodeApiConfig });
+ * ```
+ */
export function VitNodeAPI({
app,
cors: corsOptions,
@@ -71,7 +94,9 @@ export function VitNodeAPI({
vitNodeApiConfig,
}: {
app: OpenAPIHono;
+ /** Replaces the default CORS policy: credentials from `VITNODE_WEB_URL` only. */
cors?: CORSOptions;
+ /** Replaces the default CSRF check: requests from `VITNODE_WEB_URL` or the API's own origin. */
csrf?: CSRFOptions;
vitNodeApiConfig: VitNodeApiConfig;
}) {
diff --git a/packages/vitnode/src/api/lib/ai/action.ts b/packages/vitnode/src/api/lib/ai/action.ts
index 4700e66fa9..e9bfe6f0d9 100644
--- a/packages/vitnode/src/api/lib/ai/action.ts
+++ b/packages/vitnode/src/api/lib/ai/action.ts
@@ -19,6 +19,7 @@ export type AiPrompt =
| { prompt: string; system?: string };
export interface AiPromptContext {
+ /** Extra guidance an admin set for this action in the AdminCP, or `null`. Append it to the system prompt. */
instructions: null | string;
}
@@ -29,18 +30,27 @@ export interface AiAuthorizeContext {
userId: number;
}
+/** The AI permission admins grant per role. Actions sharing a key share one switch and one daily counter. */
export interface AiPermissionDescriptor {
+ /** Whether roles hold it until an admin changes it. */
defaultGranted: boolean;
key: string;
}
+/** Per-action limits; admins can change them in the AdminCP. Ranges are in {@link AI_ACTION_LIMITS}. */
export interface AiActionDefaults {
+ /** Runs per user per day for the action's permission; `null` (default) for no limit. */
dailyLimit: null | number;
+ /** Images per run. Defaults to 1 with the `image-input` capability, else 0. */
maxImages: number;
+ /** Input size, counted by `measureInput` or every string in the input. */
maxInputCharacters: number;
maxOutputTokens: number;
+ /** Provider retries. Defaults to 0. */
maxRetries: number;
+ /** Tool-loop steps. Defaults to 1. */
maxSteps: number;
+ /** Provider timeout in milliseconds. */
timeoutMs: number;
}
@@ -54,25 +64,46 @@ interface AiActionDefinitionBase<
InputSchema extends z.ZodType,
OutputSchema extends z.ZodType,
> {
+ /**
+ * Who may run it: `user` through `c.get("ai").run()`, `system` through
+ * `runAsSystem()`. Defaults to `["user"]`.
+ */
actors?: readonly AiActorType[];
+ /**
+ * Checks the caller may read the content the input refers to - the AI
+ * permission never replaces that. Required when `actors` includes `user`.
+ */
authorize?(
context: AiAuthorizeContext>,
): boolean | Promise;
+ /** Builds the prompt from the validated input. */
buildPrompt(input: z.output, context: AiPromptContext): AiPrompt;
defaults: Partial &
Pick<
AiActionDefaults,
"maxInputCharacters" | "maxOutputTokens" | "timeoutMs"
>;
+ /** Message key of one sentence on what the action does. */
description: string;
+ /** Lucide icon name in kebab case, e.g. `"languages"`. */
icon?: string;
+ /**
+ * Lowercase, dot-separated id local to the plugin, e.g. `"excerpt.generate"`.
+ * Core stores it as `:`.
+ */
id: Id;
inputSchema: InputSchema;
+ /** Characters counted against `maxInputCharacters`; by default every string in the input. */
measureInput?(input: z.output): number;
+ /** Validates the model's answer. */
outputSchema: OutputSchema;
+ /** A permission key (not granted by default) or a full descriptor. */
permission: AiPermissionDescriptor | string;
+ /** Whole number of 1 or more, stored with every run. Bump it when the prompt changes meaningfully. */
promptVersion: number;
+ /** Model capabilities the action needs: at least one (normally `"text"`), plus `"structured-output"` for `output: "object"`. */
requiredCapabilities: readonly AiModelCapability[];
+ /** Message key of a short name for the AdminCP and member settings. */
title: string;
}
@@ -82,6 +113,7 @@ export interface AiTextActionDefinition<
OutputSchema extends z.ZodType,
> extends AiActionDefinitionBase {
output: "text";
+ /** Turns the model's text into the value checked by `outputSchema`. */
parseText(text: string, input: z.output): z.input;
}
@@ -95,6 +127,7 @@ export interface AiObjectActionDefinition<
/* oxlint-enable typescript/method-signature-style */
+/** What a plugin passes to {@link defineAiAction}: a text action or a structured-object action. */
export type AiActionDefinition<
Id extends string = string,
InputSchema extends z.ZodType = z.ZodType,
@@ -103,6 +136,7 @@ export type AiActionDefinition<
| AiObjectActionDefinition
| AiTextActionDefinition;
+/** An action after {@link defineAiAction} filled in `actors`, `defaults` and `permission`. */
export type ResolvedAiActionDefinition<
Id extends string = string,
InputSchema extends z.ZodType = z.ZodType,
@@ -125,6 +159,7 @@ export type ResolvedAiActionDefinition<
export type AnyAiActionDefinition = ResolvedAiActionDefinition;
+/** Thrown by {@link defineAiAction} when the module loads with an invalid definition. */
export class AiActionDefinitionError extends Error {
constructor(message: string) {
super(message);
@@ -132,6 +167,7 @@ export class AiActionDefinitionError extends Error {
}
}
+/** Allowed range of each {@link AiActionDefaults} value. */
export const AI_ACTION_LIMITS = {
maxInputCharacters: { max: 1_000_000, min: 1 },
maxOutputTokens: { max: 200_000, min: 1 },
@@ -162,6 +198,49 @@ const assertInRange = (
}
};
+/**
+ * Declares a managed AI feature. Register it in `buildApiPlugin({ aiActions })`
+ * and run it with `c.get("ai").run()`; core picks the model and enforces
+ * permissions, limits and budgets, and records every run.
+ *
+ * @throws {AiActionDefinitionError} For an invalid id, icon, permission key or
+ * `promptVersion`, an empty title or description, missing or unsupported
+ * capabilities, a default outside {@link AI_ACTION_LIMITS}, no actors, or a
+ * `user` action without `authorize`.
+ *
+ * @example
+ * ```ts
+ * import { defineAiAction } from "@vitnode/core/api/lib/ai/action";
+ * import { checkStaffPermission } from "@vitnode/core/api/lib/check-staff-permission";
+ * import { z } from "zod";
+ *
+ * export const summarizeAiAction = defineAiAction({
+ * id: "topic.summarize",
+ * title: "ai_actions.@acme/forum.topic_summarize.title",
+ * description: "ai_actions.@acme/forum.topic_summarize.description",
+ * promptVersion: 1,
+ * permission: "summarize",
+ * requiredCapabilities: ["text"],
+ * defaults: { maxInputCharacters: 50_000, maxOutputTokens: 300, timeoutMs: 30_000 },
+ * inputSchema: z.object({ content: z.string().min(1) }),
+ * output: "text",
+ * outputSchema: z.string().min(1),
+ * parseText: text => text.trim(),
+ * buildPrompt: (input, { instructions }) => ({
+ * system: ["Summarize in two sentences.", instructions].filter(Boolean).join("\n"),
+ * prompt: input.content,
+ * }),
+ * // The AI permission never replaces access to the content itself.
+ * authorize: async ({ c }) =>
+ * await checkStaffPermission(c, {
+ * type: "admin",
+ * plugin: "@acme/forum",
+ * module: "topics",
+ * permission: "can_view",
+ * }),
+ * });
+ * ```
+ */
export function defineAiAction<
const Id extends string,
InputSchema extends z.ZodType,
diff --git a/packages/vitnode/src/api/lib/ai/decimal.ts b/packages/vitnode/src/api/lib/ai/decimal.ts
index e48523a031..4c8e5bcb28 100644
--- a/packages/vitnode/src/api/lib/ai/decimal.ts
+++ b/packages/vitnode/src/api/lib/ai/decimal.ts
@@ -30,6 +30,16 @@ export const parseDecimalOrNull = (
): Decimal | null =>
value === null || value === undefined ? null : parseDecimal(value);
+/**
+ * Converts a JavaScript number to a {@link Decimal} (a `bigint` with 12
+ * fractional digits), rounded half away from zero at the 12th digit (after
+ * first rounding to 14 digits). Use it for costs
+ * a provider reports as a number, e.g. in an `AiProviderAdapter`'s
+ * `reportedCost`.
+ *
+ * @throws {DecimalParseError} When `value` is `NaN`, infinite, or has a
+ * magnitude of 1e21 or more.
+ */
export const decimalFromNumber = (value: number): Decimal => {
if (!Number.isFinite(value)) {
throw new DecimalParseError(String(value));
diff --git a/packages/vitnode/src/api/lib/ai/errors.ts b/packages/vitnode/src/api/lib/ai/errors.ts
index d946c92636..699be73ef4 100644
--- a/packages/vitnode/src/api/lib/ai/errors.ts
+++ b/packages/vitnode/src/api/lib/ai/errors.ts
@@ -55,5 +55,22 @@ export class AiError extends HTTPException {
readonly code: AiErrorCode;
}
+/**
+ * Whether `error` is an {@link AiError} thrown by `c.get("ai")`, so its `code`
+ * (e.g. `"AI_BUDGET_EXHAUSTED"`) can be checked.
+ *
+ * @example
+ * ```ts
+ * import { isAiError } from "@vitnode/core/api/lib/ai/errors";
+ *
+ * // `TAGS_AI_ACTION`: a key from your `defineAiAction`; `input`: its input.
+ * try {
+ * await c.get("ai").runAsSystem({ action: TAGS_AI_ACTION, input });
+ * } catch (error) {
+ * if (isAiError(error) && error.code === "AI_BUDGET_EXHAUSTED") return;
+ * throw error;
+ * }
+ * ```
+ */
export const isAiError = (error: unknown): error is AiError =>
error instanceof AiError;
diff --git a/packages/vitnode/src/api/lib/ai/html-structure.ts b/packages/vitnode/src/api/lib/ai/html-structure.ts
index be7304893c..ce07ebeb4c 100644
--- a/packages/vitnode/src/api/lib/ai/html-structure.ts
+++ b/packages/vitnode/src/api/lib/ai/html-structure.ts
@@ -55,6 +55,16 @@ export class HtmlStructureError extends Error {
}
}
+/**
+ * Checks that a translated HTML string keeps the source's structure: the same
+ * tags in the same order, with the same attributes. Text and the `alt`,
+ * `aria-label`, `placeholder` and `title` attributes may differ; comments are
+ * ignored. Call it from an AI action's `parseText` so a translation that
+ * breaks markup or links fails the run.
+ *
+ * @throws {HtmlStructureError} With the first difference, when the structure
+ * changed.
+ */
export const assertSameHtmlStructure = (
source: string,
translated: string,
diff --git a/packages/vitnode/src/api/lib/ai/registry.ts b/packages/vitnode/src/api/lib/ai/registry.ts
index 89f17897f1..b57f35741b 100644
--- a/packages/vitnode/src/api/lib/ai/registry.ts
+++ b/packages/vitnode/src/api/lib/ai/registry.ts
@@ -34,6 +34,7 @@ export interface AiActionPublicMetadata {
title: string;
}
+/** The canonical `:` key core stores settings, limits and history under. */
export const aiActionKey = (pluginId: string, localId: string): AiActionKey =>
`${pluginId}:${localId}`;
@@ -41,6 +42,10 @@ export type TypedAiActionKey = AiActionKey & {
readonly __aiAction?: { input: Input; output: Output };
};
+/**
+ * The canonical `:` key of an action, typed with its input and
+ * output so `c.get("ai").run({ action })` checks `input` and infers `output`.
+ */
export const aiActionRef = (
pluginId: string,
definition: Definition,
diff --git a/packages/vitnode/src/api/lib/ai/usage-cost.ts b/packages/vitnode/src/api/lib/ai/usage-cost.ts
index 03f159bf27..095324f4d2 100644
--- a/packages/vitnode/src/api/lib/ai/usage-cost.ts
+++ b/packages/vitnode/src/api/lib/ai/usage-cost.ts
@@ -74,11 +74,26 @@ export const normalizeUsage = (
};
};
+/**
+ * Reads cost and request ids from one AI provider's response metadata, for the
+ * API config's `ai.providerAdapters`. Custom adapters are tried before the
+ * built-in AI Gateway and OpenRouter ones; the first whose `matches` returns
+ * `true` is used. A provider without one reports no cost, so `pricing`
+ * estimates it.
+ */
export interface AiProviderAdapter {
+ /** A unique adapter id, e.g. `"gateway"`. */
id: string;
+ /**
+ * Asks the provider for a call's billed amount later, during reconciliation
+ * of calls whose cost was estimated or unknown. `null` when not available.
+ */
lookupCost?: (requestId: string) => Promise;
+ /** Whether this adapter handles a model, given its provider id: `"gateway"` for AI Gateway string ids, otherwise the SDK model's `provider`. */
matches: (providerId: string) => boolean;
+ /** The cost the provider reported in the response metadata (see {@link decimalFromNumber}), or `null`. */
reportedCost: (metadata: ProviderMetadata | undefined) => Decimal | null;
+ /** The provider's id for the call, used by `lookupCost`; `null` when there is none. */
requestId: (args: {
metadata: ProviderMetadata | undefined;
responseId: string | undefined;
diff --git a/packages/vitnode/src/api/lib/bind-api-plugin.ts b/packages/vitnode/src/api/lib/bind-api-plugin.ts
index e294bec4c9..803526d61e 100644
--- a/packages/vitnode/src/api/lib/bind-api-plugin.ts
+++ b/packages/vitnode/src/api/lib/bind-api-plugin.ts
@@ -10,9 +10,28 @@ import type { BuildRouteConfig, BuildRouteOptions, Route } from "./route";
import { buildModule } from "./module";
import { buildRoute } from "./route";
+/**
+ * Binds the module and route builders to one plugin, so its routes carry the
+ * plugin id - for OpenAPI tags, `c.get("plugin")`, staff-permission checks and
+ * the fetcher's type inference - without repeating it.
+ *
+ * Call it once per plugin, in a builders module the plugin's API code imports.
+ * Server-only.
+ *
+ * @example
+ * ```ts
+ * // src/api/builders.ts
+ * import { bindApiPlugin } from "@vitnode/core/api/lib/bind-api-plugin";
+ *
+ * import { manifest } from "@/manifest.gen";
+ *
+ * export const { buildModule, buildRoute } = bindApiPlugin(manifest);
+ * ```
+ */
export const bindApiPlugin = (
manifest: Pick, "id">,
) => ({
+ /** {@link buildModule}, with this plugin's id filled in. */
buildModule: <
const M extends string,
const Routes extends Route[],
@@ -24,6 +43,7 @@ export const bindApiPlugin = (
...options,
pluginId: manifest.id,
}),
+ /** {@link buildRoute}, with this plugin's id filled in. */
buildRoute:
>(
options: BuildRouteOptions
,
): Route =>
diff --git a/packages/vitnode/src/api/lib/cache-client.ts b/packages/vitnode/src/api/lib/cache-client.ts
index 1bfa310f38..03dee02e9c 100644
--- a/packages/vitnode/src/api/lib/cache-client.ts
+++ b/packages/vitnode/src/api/lib/cache-client.ts
@@ -2,6 +2,12 @@ import { createClient } from "redis";
import type { CacheClient, CacheConfig } from "./cache";
+/**
+ * Creates the Redis client the cache, rate limiter and WebSocket pub/sub share,
+ * or `null` without a config. Connects in the background and keeps retrying;
+ * until it is ready, commands fail fast and callers treat Redis as absent.
+ * Called once by `VitNodeAPI` with the API config's `redis` option.
+ */
export const createCacheClient = (config?: CacheConfig): CacheClient | null => {
if (!config) return null;
diff --git a/packages/vitnode/src/api/lib/cache.ts b/packages/vitnode/src/api/lib/cache.ts
index 72359466a4..d49825ec8e 100644
--- a/packages/vitnode/src/api/lib/cache.ts
+++ b/packages/vitnode/src/api/lib/cache.ts
@@ -3,7 +3,10 @@ import type { RedisClientOptions, RedisClientType } from "redis";
import type { MemoryCacheStore } from "./cache-memory";
-/** Connection options accepted by `redis` - `url` plus any client option. */
+/**
+ * Connection options accepted by `redis` - `url` plus any client option of
+ * node-redis `createClient`. The API config's `redis`.
+ */
export type CacheConfig = RedisClientOptions;
/** The connected `node-redis` client shared by the cache, rate limiter and ws. */
@@ -13,10 +16,25 @@ const CACHE_PREFIX = "vitnode:cache:";
const SYSTEM_NAMESPACE = "__system__";
/**
- * Per-request cache facade. Backed by Redis when a client is configured; without
- * one, by the process-wide {@link MemoryCacheStore} the global middleware passes
- * in (short TTLs, single-instance only - see `MEMORY_CACHE_MAX_TTL_SECONDS`).
- * With neither, every method is a no-op.
+ * Server-side domain cache, read in a route as `c.get("cache")`. Values are
+ * stored as JSON, so dates come back as strings.
+ *
+ * Backed by Redis when the API config has `redis` (the starter apps pass
+ * `REDIS_URL`); without it, by the process-wide {@link MemoryCacheStore} the
+ * global middleware passes in (short TTLs, single-instance only - see
+ * `MEMORY_CACHE_MAX_TTL_SECONDS`). With neither, every method is a no-op.
+ * Redis errors are swallowed: caching never fails a request.
+ *
+ * Keys are prefixed with the current plugin (`vitnode:cache:{pluginId}:`), so
+ * plugins cannot read or clobber each other's keys. The `*System` methods use a
+ * shared framework namespace instead.
+ *
+ * @example
+ * ```ts
+ * const stats = await c.get("cache").remember("stats", 300, async () => countPosts(c));
+ * // after a write:
+ * await c.get("cache").delete("stats");
+ * ```
*/
export class CacheModel {
constructor(
@@ -206,6 +224,7 @@ export class CacheModel {
return this.readKey(this.key(key));
}
+ /** {@link CacheModel.get} in the framework system namespace, shared by every plugin. */
async getSystem(key: string): Promise {
return this.readKey(this.systemKey(key));
}
@@ -234,6 +253,12 @@ export class CacheModel {
await this.removeKeys([this.systemKey(`lock:${key}`)]);
}
+ /**
+ * Returns the cached value, or runs `loader`, caches its result for
+ * `ttlSeconds` and returns it. A `null` result is never served from the
+ * cache, so a loader answering `null` runs every time. Without Redis it
+ * always runs `loader`.
+ */
async remember(
key: string,
ttlSeconds: number,
@@ -248,12 +273,17 @@ export class CacheModel {
return value;
}
+ /**
+ * Store a JSON value. Omit `ttlSeconds` (or pass `0`) to keep it until
+ * deleted. No-op without Redis or on error.
+ */
async set(key: string, value: T, ttlSeconds?: number): Promise {
await this.writeKey(this.key(key), value, ttlSeconds);
}
/**
- * `strict` rethrows a failed write instead of swallowing it - for an
+ * {@link CacheModel.set} in the framework system namespace, shared by every
+ * plugin. `strict` rethrows a failed write instead of swallowing it - for an
* invalidation stamp, whose caller has to know it did not land so it can
* retry rather than leave readers on the old generation.
*/
@@ -272,6 +302,7 @@ export class CacheModel {
await this.writeKey(fullKey, value, ttlSeconds);
}
+ /** Whether Redis is configured, and whether it answers a `PING` right now. */
async status(): Promise<{ configured: boolean; connected: boolean }> {
if (!this.client) return { configured: false, connected: false };
diff --git a/packages/vitnode/src/api/lib/check-staff-permission.ts b/packages/vitnode/src/api/lib/check-staff-permission.ts
index f86b0e20a1..02f5736f21 100644
--- a/packages/vitnode/src/api/lib/check-staff-permission.ts
+++ b/packages/vitnode/src/api/lib/check-staff-permission.ts
@@ -39,6 +39,10 @@ interface StaffUser {
roleId: number;
}
+/**
+ * Every role id a user has - the primary `roleId` plus secondary roles - with
+ * duplicates removed. Reads the database on each call.
+ */
export const getUserRoleIds = async (
c: Context,
user: StaffUser,
@@ -146,6 +150,11 @@ const loadAndCacheStaffPermissions = async (
return resolved;
};
+/**
+ * The effective staff permission set of a user for one staff type, from the
+ * staff permission cache or, on a miss, from the database (then cached). Root
+ * roles and unrestricted entries resolve to `root: true`.
+ */
export const resolveStaffPermissions = async (
c: Context,
{ type, user }: { type: PermissionStaffType; user: StaffUser },
@@ -286,6 +295,7 @@ export const getStaffFlags = async (
};
};
+/** {@link checkStaffPermission} for an explicit user instead of the request's. */
export const checkStaffPermissionOfUser = async (
c: Context,
user: StaffUser,
@@ -296,6 +306,15 @@ export const checkStaffPermissionOfUser = async (
return hasStaffPermission(set, args);
};
+/**
+ * Whether the current request's staff member holds a permission. `type:
+ * "admin"` checks the AdminCP session (`c.get("admin")`), `"moderator"` the
+ * signed-in user. Returns `false` when there is no such user.
+ *
+ * Pass `resource` to check a scoped grant for one resource. Without it, a
+ * grant scoped to selected resources does not count; one scoped to all
+ * resources of its family does.
+ */
export const checkStaffPermission = async (
c: Context,
args: StaffPermissionCheck & { type: PermissionStaffType },
@@ -306,6 +325,21 @@ export const checkStaffPermission = async (
return await checkStaffPermissionOfUser(c, user, args);
};
+/**
+ * {@link checkStaffPermission}, throwing instead of returning `false`.
+ *
+ * @throws HTTPException 403 when the permission is missing.
+ *
+ * @example
+ * ```ts
+ * await assertStaffPermission(c, {
+ * type: "admin",
+ * plugin: "@vitnode/blog",
+ * module: "posts",
+ * permission: "can_delete",
+ * });
+ * ```
+ */
export const assertStaffPermission = async (
c: Context,
args: StaffPermissionCheck & { type: PermissionStaffType },
diff --git a/packages/vitnode/src/api/lib/content-modules.ts b/packages/vitnode/src/api/lib/content-modules.ts
index b185e08445..b5f4529a30 100644
--- a/packages/vitnode/src/api/lib/content-modules.ts
+++ b/packages/vitnode/src/api/lib/content-modules.ts
@@ -10,11 +10,23 @@ import {
ContentUrlError,
} from "@/content/public-urls";
+/**
+ * Where an API process finds each plugin's browser-safe `{pluginId}/content`
+ * module - the one exporting `contentTypes` - to check it against the content
+ * types the plugin's API publishes.
+ */
export interface ContentModuleLoader {
+ /** Where modules are looked up, for error messages. */
describe: string;
+ /** The plugin's content module, or `undefined` when there is none. */
load: (pluginId: string) => unknown;
}
+/**
+ * A {@link ContentModuleLoader} that wraps the `contentModules` record (the
+ * app's generated `src/content-modules.gen.ts`) passed to `buildApiConfig`, in
+ * an app whose API runs inside the web build. `VitNodeAPI` applies it.
+ */
export const generatedContentModules = (
modules: Readonly>,
): ContentModuleLoader => ({
@@ -36,6 +48,15 @@ const errorCode = (error: unknown): string | undefined =>
? error.code
: undefined;
+/**
+ * A {@link ContentModuleLoader} that resolves `{pluginId}/content` from the
+ * packages installed under `root` (the working directory by default) - the
+ * fallback for a standalone API without generated content modules. Server-only.
+ *
+ * Its `load` throws a `ContentUrlError` when the module resolves but this
+ * Node.js cannot `require()` it (before 22.12, or with top-level `await`), and
+ * rethrows any other error the module raises while loading.
+ */
export const packageContentModules = (
root: string = process.cwd(),
): ContentModuleLoader => {
@@ -68,6 +89,16 @@ export const packageContentModules = (
};
};
+/**
+ * Checks, at API startup, that every plugin publishing content types with
+ * public URLs also exports those content types, with the same public URLs,
+ * from its `./content` module - which is how the web build verifies a page
+ * serves each URL. Extra exported types are allowed.
+ *
+ * @throws A `ContentUrlError` when a plugin's content module is missing, has
+ * no valid `contentTypes` export, or omits a published type or exports it with
+ * different URLs. Loader errors propagate.
+ */
export const assertPluginContentModules = ({
loader,
plugins,
diff --git a/packages/vitnode/src/api/lib/cron.ts b/packages/vitnode/src/api/lib/cron.ts
index f908f83973..cb73dda87d 100644
--- a/packages/vitnode/src/api/lib/cron.ts
+++ b/packages/vitnode/src/api/lib/cron.ts
@@ -5,22 +5,74 @@ import { CONFIG } from "@/lib/config";
import type { EnvVitNode } from "../middlewares/global.middleware";
import type { BuildPluginApiReturn } from "./plugin";
+/**
+ * Drives the cron runner in a long-running API process - set it as `cron` in
+ * the API config. `schedule()` is called once at boot and should call
+ * {@link handleCronJobs} on a timer (`@vitnode/node-cron` does it every
+ * minute). Without an adapter, something external has to `POST` the
+ * `/api/@vitnode/core/cron` endpoint instead.
+ */
export interface CronAdapter {
+ /** Starts the timer. Called once by `VitNodeAPI` when the API app is created. */
schedule: () => void;
}
+/** A cron job, as returned by {@link buildCron}. */
export interface BuildCronReturn {
+ /** Shown to admins in the cron job list. */
description?: string;
+ /**
+ * The job's work, run server-side with a request context. A throw is logged,
+ * and the job's `lastRun` is left unchanged, so it is due again on the next
+ * tick.
+ */
handler: (c: Context) => Promise | void;
+ /**
+ * Unique within the module. Together with the plugin id and module name it
+ * identifies the job's database row, so renaming a job creates a new row.
+ */
name: string;
+ /**
+ * A cron expression with 5 fields (`minute hour day month weekday`) or 6
+ * (`second` first). An invalid expression is warned about and the job is
+ * skipped. The runner is only as precise as its tick - every minute with
+ * `@vitnode/node-cron`.
+ */
schedule: string;
}
+/** A cron job after registration, tagged with the module and plugin that declared it. */
export interface CronJobConfig extends BuildCronReturn {
module: string;
pluginId: string;
}
+/**
+ * Declares a recurring server-side job. Add it to a top-level module's
+ * `cronJobs` array (`buildModule({ cronJobs })`).
+ *
+ * On each tick the runner executes every job whose `schedule` has come due
+ * since its last successful run; due jobs run concurrently. For heavy or
+ * retryable work, have the job dispatch queue tasks instead of doing the work
+ * itself. A job runs inside core's cron request, so `c.get("plugin")` is
+ * `@vitnode/core`: pass your plugin's id to `dispatch` explicitly.
+ *
+ * @example
+ * ```ts
+ * import { buildCron } from "@vitnode/core/api/lib/cron";
+ *
+ * export const cleanDraftsCron = buildCron({
+ * name: "clean-drafts",
+ * description: "Delete abandoned drafts",
+ * schedule: "0 3 * * *",
+ * handler: async c => {
+ * await c
+ * .get("queue")
+ * .dispatch({ name: "clean-drafts", pluginId: "@acme/blog" });
+ * },
+ * });
+ * ```
+ */
export function buildCron({
name,
schedule,
@@ -30,6 +82,7 @@ export function buildCron({
return { name, schedule, handler, description };
}
+/** Flattens every plugin's cron jobs, tagging each with its plugin id. */
export function collectCronJobs(
plugins: BuildPluginApiReturn[],
): CronJobConfig[] {
@@ -58,6 +111,15 @@ const reportFailedCronTick = (reason: string) => {
// later tick.
const CRON_TICK_TIMEOUT_MS = 55_000;
+/**
+ * One cron tick: `POST`s the API's own `/api/@vitnode/core/cron` endpoint,
+ * authenticated with the `CRON_SECRET` environment variable (which falls back
+ * to a development-only default that production refuses). Call it from a
+ * {@link CronAdapter}'s timer.
+ *
+ * Never throws once the API URL is valid - a failed request or non-2xx
+ * response is written to `console.error`.
+ */
export const handleCronJobs = async () => {
const url = new URL("/api/@vitnode/core/cron", CONFIG.api.origin);
const headers: HeadersInit = {
diff --git a/packages/vitnode/src/api/lib/editable-pages.ts b/packages/vitnode/src/api/lib/editable-pages.ts
index 397daf83eb..36a21fb204 100644
--- a/packages/vitnode/src/api/lib/editable-pages.ts
+++ b/packages/vitnode/src/api/lib/editable-pages.ts
@@ -20,6 +20,12 @@ export interface RegisteredEditablePage {
pluginId: string;
}
+/**
+ * Thrown at API startup when an editable page is misconfigured: no
+ * `permission`, a duplicate page id, or a permission its owning plugin
+ * (`permission.plugin`, defaulting to the registering plugin) does not
+ * declare in `permissionStaff.moderator`.
+ */
export class EditablePageRegistryError extends Error {
constructor(message: string) {
super(message);
@@ -27,6 +33,7 @@ export class EditablePageRegistryError extends Error {
}
}
+/** Pairs a page with the plugin registering it and resolves its permission's `plugin`. */
export const registerEditablePage = (
page: AnyEditablePageDefinition,
pluginId: string,
@@ -76,6 +83,14 @@ export const validateEditablePages = (
return [...byId.values()];
};
+/**
+ * Checks, once every plugin is loaded, that each page's moderator permission is
+ * declared in its owning plugin's `permissionStaff.moderator` (`permission.plugin`,
+ * defaulting to the plugin that registers the page).
+ *
+ * @throws {EditablePageRegistryError} When the owning plugin is not installed
+ * or does not declare the permission.
+ */
export const assertEditablePagePermissionsGrantable = ({
pages,
permissionStaff,
diff --git a/packages/vitnode/src/api/lib/events.ts b/packages/vitnode/src/api/lib/events.ts
index 3192a95260..774902f30c 100644
--- a/packages/vitnode/src/api/lib/events.ts
+++ b/packages/vitnode/src/api/lib/events.ts
@@ -7,24 +7,65 @@ import type {
VitNodeEvents,
} from "../models/events";
+/**
+ * A listener for one VitNode event, as returned by {@link buildEventListener}.
+ *
+ * Register it in a top-level module's `events` array (`buildModule({ events })`);
+ * listeners in nested modules are not collected.
+ */
export interface BuildEventListenerReturn<
K extends VitNodeEventName = VitNodeEventName,
> {
+ /** Human-readable note for developers; not shown to end users. */
description?: string;
+ /** The event to react to - a key of `VitNodeEvents`. */
event: K;
+ /**
+ * Runs for every emit of `event`, with the emitter's request context.
+ *
+ * With the default local adapter, listeners run one after another and the
+ * emitter awaits them all. A thrown error is logged and reported in the
+ * emitter's `EventEmitResult.failures`; it never reaches the emitter and
+ * does not stop the remaining listeners.
+ */
handler: (
c: Context,
payload: VitNodeEvents[K],
envelope: EventEnvelope,
) => Promise | void;
+ /** Identifies the listener in logs and failure reports. */
name: string;
}
+/** A listener after registration, tagged with the module and plugin that declared it. */
export interface EventListenerConfig extends BuildEventListenerReturn {
module: string;
pluginId: string;
}
+/**
+ * Declares a server-side listener for a VitNode event, with the payload typed
+ * from `event`.
+ *
+ * Add your own events by augmenting `VitNodeEvents` in
+ * `@vitnode/core/api/models/events`, and emit them with
+ * `c.get("events").emit(name, payload)`.
+ *
+ * @example
+ * ```ts
+ * import { buildEventListener } from "@vitnode/core/api/lib/events";
+ *
+ * export const welcomeListener = buildEventListener({
+ * event: "user.created",
+ * name: "send-welcome",
+ * handler: async (c, payload) => {
+ * await c.get("log").debug(`Welcome, ${payload.name}`);
+ * },
+ * });
+ *
+ * // buildModule({ name: "users", routes: [], events: [welcomeListener] })
+ * ```
+ */
export function buildEventListener(
args: BuildEventListenerReturn,
): BuildEventListenerReturn {
diff --git a/packages/vitnode/src/api/lib/logger-middleware.ts b/packages/vitnode/src/api/lib/logger-middleware.ts
index 087d2d374d..c33a0eb6b2 100644
--- a/packages/vitnode/src/api/lib/logger-middleware.ts
+++ b/packages/vitnode/src/api/lib/logger-middleware.ts
@@ -5,6 +5,11 @@ import { core_logs } from "@/database/logs";
type CoreLogsType = "debug" | "error" | "warn";
+/**
+ * `c.get("log")`: each method writes an entry to the `core_logs` table (shown
+ * in AdminCP debug logs) with the request's plugin, path, IP and user, and
+ * echoes it to the console.
+ */
export interface LoggerMiddlewareType {
debug: (content: string) => Promise;
error: (content: string) => Promise;
diff --git a/packages/vitnode/src/api/lib/moderation/registry.ts b/packages/vitnode/src/api/lib/moderation/registry.ts
index 0cd952de54..389fdf3a1d 100644
--- a/packages/vitnode/src/api/lib/moderation/registry.ts
+++ b/packages/vitnode/src/api/lib/moderation/registry.ts
@@ -16,8 +16,11 @@ import type {
import { ModerationRegistryError } from "./scopes";
+/** The `moderation` option of `buildApiPlugin`. */
export interface ModerationPluginConfig {
+ /** Custom report targets; content types with `reports` add theirs automatically. */
reportTargets?: ModerationReportTargetDefinition[];
+ /** Scope families moderator permissions can be limited by. */
scopes?: ModerationScopeDefinition[];
}
diff --git a/packages/vitnode/src/api/lib/moderation/scopes.ts b/packages/vitnode/src/api/lib/moderation/scopes.ts
index 89f01d1b77..23788ef2f5 100644
--- a/packages/vitnode/src/api/lib/moderation/scopes.ts
+++ b/packages/vitnode/src/api/lib/moderation/scopes.ts
@@ -5,28 +5,53 @@ import {
MODERATION_RESOURCE_ID_PATTERN,
} from "@/lib/moderation/const";
+/** One resource a moderator permission can be limited to, e.g. a forum section. */
export interface ModerationScopeResource {
+ /** Where a nested resource sits, outermost first, e.g. `["Games"]`. Shown next to the label. */
context?: string[];
+ /** The resource id as a string: 1-64 characters of `A-Z`, `a-z`, `0-9`, `.`, `_`, `:`, `-`, starting with a letter or digit. */
id: string;
+ /** The resource's display name, shown as is (not translated). */
label: string;
}
+/** What the AdminCP picker asks {@link ModerationScopeDefinition.list} for. */
export interface ModerationScopeListArgs {
+ /** The `nextCursor` of the previous page, or `null` for the first. */
cursor: null | string;
+ /** Page size; the picker asks for 20 and ignores any extra items. */
limit: number;
+ /** Trimmed search text, or `undefined` for none. */
search?: string;
}
+/** One page of resources. */
export interface ModerationScopeListResult {
items: ModerationScopeResource[];
+ /** Passed back as `cursor` for the next page; `null` on the last page. */
nextCursor: null | string;
}
+/**
+ * A scope family: a set of resources (forum sections, categories...) that
+ * moderator permissions can be limited to. Register it in `buildApiPlugin`'s
+ * `moderation.scopes`, and name it as a permission's or report target's `scope`.
+ * Server-only - both callbacks read the database.
+ */
export interface ModerationScopeDefinition {
+ /** Describes the family in the AdminCP permission editor; an i18n key, as for `label`. Dropped when the key is not found. */
description?: string;
+ /**
+ * Returns the resources among `ids` that exist. Used to label a saved
+ * selection and to refuse grants for ids that are gone. Omit missing ids
+ * rather than throwing.
+ */
find: (c: Context, ids: string[]) => Promise;
+ /** Lowercase family id, `a-z0-9` parts joined by `.`, `_` or `-`, e.g. `"forum.section"`. */
id: string;
+ /** The family's name in the AdminCP permission editor, as an i18n key; when the key is not found, the family `id` is shown instead. */
label: string;
+ /** One page of resources for the AdminCP picker, filtered by `search`. */
list: (
c: Context,
args: ModerationScopeListArgs,
@@ -45,6 +70,25 @@ export class ModerationRegistryError extends Error {
}
}
+/**
+ * Declares a scope family for moderator permissions. Returns the definition
+ * unchanged after checking its id.
+ *
+ * @throws {ModerationRegistryError} When `id` is not one or more lowercase
+ * `a-z0-9` parts joined by `.`, `_` or `-` (e.g. `"forum.section"`).
+ *
+ * @example
+ * ```ts
+ * import { defineModerationScope } from "@vitnode/core/api/moderation";
+ *
+ * export const categoryScope = defineModerationScope({
+ * id: "example.category",
+ * label: "@vitnode/example.moderation.category.label",
+ * list: async (c, { cursor, limit, search }) => ({ items: [], nextCursor: null }),
+ * find: async (c, ids) => [],
+ * });
+ * ```
+ */
export const defineModerationScope = (
definition: ModerationScopeDefinition,
): ModerationScopeDefinition => {
diff --git a/packages/vitnode/src/api/lib/moderation/targets.ts b/packages/vitnode/src/api/lib/moderation/targets.ts
index 4b524de540..f9ce8dab38 100644
--- a/packages/vitnode/src/api/lib/moderation/targets.ts
+++ b/packages/vitnode/src/api/lib/moderation/targets.ts
@@ -8,6 +8,7 @@ import { MODERATION_IDENTIFIER_PATTERN } from "@/lib/moderation/const";
import { ModerationRegistryError } from "./scopes";
+/** A report target as it is now. */
export interface ModerationTargetState {
hidden: boolean;
url: null | string;
@@ -23,43 +24,74 @@ export type ModerationCaptureResult = Omit<
"capturedAt" | "target" | "truncated" | "version"
>;
+/** What a moderator action's `run` receives. */
export interface ModerationActionArgs {
actorUserId: number;
targetId: string;
+ /** The report's transaction; write through it so the action and the report commit together. */
tx: ContentDatabase;
}
export interface ModerationActionOutcome {
+ /** `false` when the target was already in the requested state. */
changed: boolean;
+ /** Side effects (cache, search, events) run after the transaction commits. */
effects: () => Promise;
}
+/** A content action, such as hide or unhide, that moderators run from a report. */
export interface ModerationTargetActionDefinition {
+ /** Whether the action applies to the target's current state. */
available: (state: ModerationTargetState) => boolean;
+ /** Moderator permission in the target's `permissionModule` that allows it. */
permission: string;
+ /**
+ * Runs inside the report's transaction. Throwing rolls it back and records
+ * nothing; `null` means the target no longer exists.
+ */
run: (
c: Context,
args: ModerationActionArgs,
) => Promise;
}
+/**
+ * Something members can report that is not a Content Engine type (those
+ * register their target automatically). Create it with
+ * {@link defineReportTarget} and register it in
+ * `buildApiPlugin({ moderation: { reportTargets } })`.
+ */
export interface ModerationReportTargetDefinition {
actions?: Partial<
Record
>;
+ /**
+ * The snapshot stored with a new report, or `null` when the reporter cannot
+ * read the target. Runs as the reporter.
+ */
capture: (
c: Context,
args: ModerationCaptureArgs,
) => Promise;
+ /** The target now, or `null` once it is deleted. */
current: (
c: Context,
args: { locale: null | string; targetId: string },
) => Promise;
+ /** i18n key naming the kind of thing, an ICU plural called with `count: 1`. */
label: string;
+ /** Validates and normalizes a client-sent id before anything reads it; `null` rejects it. */
parseId: (value: string) => null | string;
+ /** Moderator permission module the report permissions are added to. */
permissionModule: string;
+ /**
+ * The target's resource id in the `scope` family: `""` for none,
+ * `undefined` once the target is deleted. Required with `scope`.
+ */
resolveScope?: (c: Context, targetId: string) => Promise;
+ /** Scope family id that limits which moderators see these reports. */
scope?: string;
+ /** Stable id stored in reports, lowercase and dot-separated, e.g. `"forum.topic"`. */
type: string;
}
@@ -68,6 +100,12 @@ export interface RegisteredReportTarget {
pluginId: string;
}
+/**
+ * Validates a custom report target when the module loads.
+ *
+ * @throws {ModerationRegistryError} When `type` is not lowercase and
+ * dot-separated, or `scope` is set without `resolveScope`.
+ */
export const defineReportTarget = (
definition: ModerationReportTargetDefinition,
): ModerationReportTargetDefinition => {
diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts
index 97a0f65bd2..21524c3975 100644
--- a/packages/vitnode/src/api/lib/module.ts
+++ b/packages/vitnode/src/api/lib/module.ts
@@ -17,6 +17,7 @@ export interface BuildModuleType {
routes: T;
}
+/** A built module, as a parent module or `buildApiPlugin` receives it. */
export interface BaseBuildModuleReturn<
P extends string = string,
M extends string = string,
@@ -49,27 +50,67 @@ export interface BuildModuleReturn<
modules?: Modules;
}
+/** What `buildModule` accepts. */
export interface BuildModuleOptions<
P extends string,
M extends string,
Routes extends Route
[],
Modules extends BaseBuildModuleReturn
[],
> {
+ /** Content containers this module registers. Collected from any depth. */
contentContainers?: AnyContentContainerDefinition[];
+ /** Content models this module registers. Collected from any depth. */
contentModels?: AnyContentModel[];
+ /** Content types this module registers. Collected from any depth. */
contentTypes?: AnyContentTypeDefinition[];
+ /** Scheduled jobs. Only read from a plugin's top-level modules. */
cronJobs?: BuildCronReturn[];
+ /** Event listeners. Only read from a plugin's top-level modules. */
events?: BuildEventListenerReturn[];
+ /** Child modules, each mounted at `/{child.name}` below this one. */
modules?: Modules;
+ /**
+ * The path segment the module is mounted at, and its part of the fetcher's
+ * `module` path (`admin/ai` for an `ai` module inside `admin`).
+ */
name: M;
+ /** Notification types this module registers. Collected from any depth. */
notificationTypes?: AnyNotificationTypeDefinition[];
+ /**
+ * Content types handed to this module's public API; the plugin keeps those
+ * that declare public URLs. Collected from any depth.
+ */
publicContentTypes?: AnyContentTypeDefinition[];
+ /** Background queue tasks. Only read from a plugin's top-level modules. */
queueTasks?: BuildQueueTaskReturn[];
+ /** Routes built with the plugin's bound `buildRoute`. Pass `[]` for a module with only children. */
routes: Routes;
+ /** Search indexers this module registers. Collected from any depth. */
searchIndexers?: SearchIndexer[];
+ /** WebSocket endpoints. Only read from a plugin's top-level modules. */
webSockets?: BuildWebSocketReturn[];
}
+/**
+ * Groups routes, child modules and what they register into one Hono
+ * sub-application. Plugins call the bound version from `bindApiPlugin`, which
+ * supplies `pluginId`.
+ *
+ * Mind the depth: content types, models, containers, notification types and
+ * search indexers are collected from the whole module tree, but cron jobs,
+ * events, queue tasks and WebSockets only from the modules passed to
+ * `buildApiPlugin` directly - declared on a nested module, they never run.
+ *
+ * @example
+ * ```ts
+ * import { buildModule } from "@/api/builders";
+ *
+ * export const postsModule = buildModule({
+ * name: "posts",
+ * routes: [listPostsRoute, showPostRoute],
+ * });
+ * ```
+ */
export function buildModule<
const P extends string,
const M extends string,
diff --git a/packages/vitnode/src/api/lib/notifications/registry.ts b/packages/vitnode/src/api/lib/notifications/registry.ts
index 4fc621c96e..1140bfac98 100644
--- a/packages/vitnode/src/api/lib/notifications/registry.ts
+++ b/packages/vitnode/src/api/lib/notifications/registry.ts
@@ -10,12 +10,21 @@ import type {
import { NOTIFICATION_EMAIL_MODES } from "@/lib/notifications/types";
+/**
+ * One inbox item as plain text. Core removes control characters, collapses
+ * whitespace and cuts each string at 500 characters; it does not strip HTML.
+ */
export interface NotificationPresentation {
body?: string;
+ /**
+ * Same-site path the item links to, e.g. `/forum/topics/42`. Absolute URLs
+ * and unsafe values are dropped, leaving the item without a link.
+ */
target?: null | string;
title: string;
}
+/** The wording of a notification email, when it differs from `present`. */
export interface NotificationEmailPresentation {
actionLabel?: string;
body?: string;
@@ -28,13 +37,19 @@ export interface NotificationActorPreview {
name: string;
}
+/** What `present` and `email` receive, once per recipient language. */
export interface NotificationPresentArgs {
+ /** Distinct people across every notification grouped into the item. */
actorCount: number;
+ /** Up to 3 people who caused the item, newest first; in emails, only that event's actor. */
actors: NotificationActorPreview[];
+ /** The stored data, migrated and parsed with the current `schema`. */
data: TData;
+ /** Notifications grouped into the item; always 1 without `grouping` and in emails. */
eventCount: number;
locale: string;
subject: NotificationSubject | null;
+ /** Translator for the recipient's language, reading API messages. */
t: Translator;
}
@@ -45,32 +60,78 @@ export interface NotificationAccessArgs {
userIds: number[];
}
+/**
+ * Merges notifications of one type for the same member into one inbox item
+ * while they share a group key and a fixed UTC time window.
+ */
export interface NotificationGroupingConfig {
+ /**
+ * The group key; defaults to the subject (no subject means no grouping). A
+ * `null` or empty key is never grouped. Cut at 200 characters.
+ */
key?: (args: {
data: TData;
subject: NotificationSubject | null;
}) => null | string;
+ /** Window length in minutes, 1 to 10080 (one week). */
windowMinutes: number;
}
+/** One kind of persistent notification. Build it with {@link buildNotificationType}. */
export interface NotificationTypeDefinition> {
+ /**
+ * Returns which of `userIds` may see the notification; without it every
+ * recipient may. Called in batches on delivery, before each email and when
+ * an inbox loads, so answer the whole batch with one query. Return fewer ids
+ * for "no access" - a throw fails the delivery batch for a retry.
+ */
access?: (
args: NotificationAccessArgs,
) => number[] | Promise;
+ /**
+ * Groups types on the preferences page and in inbox filters: 1-50 of
+ * `a-z`, `0-9`, `_`, `-`.
+ */
category: string;
+ /**
+ * What a member gets before choosing; administrators can override it in the
+ * AdminCP. `email` other than `"none"` requires `email`.
+ */
defaults: { email: NotificationEmailMode; inApp: boolean };
+ /** API message key for a one-line explanation under the label. */
description?: string;
+ /**
+ * Enables email. `true` reuses `present`; a function words the email
+ * separately.
+ */
email?:
| ((args: NotificationPresentArgs) => NotificationEmailPresentation)
| boolean;
+ /** Merges repeat notifications into one inbox item within a time window. Off by default: each publish is its own item. */
grouping?: NotificationGroupingConfig;
+ /**
+ * Dot-separated, plugin-scoped id such as `"forum.topic_reply"`: at least
+ * two segments of `a-z`, `0-9`, `_`, `-`, at most 100 characters. Stored
+ * notifications refer to it, so never rename it.
+ */
id: string;
+ /** API message key for the type name on the preferences page. */
label: string;
+ /** Always delivered to the inbox; members cannot turn it off. */
mandatory?: boolean;
+ /** Upgrades data stored by an older `version` before it is parsed. */
migrate?: (data: unknown, fromVersion: number) => unknown;
+ /**
+ * Renders one inbox item in the recipient's language. Runs on the API each
+ * time the item is listed or emailed; a throw or empty title shows a "no
+ * longer available" placeholder.
+ */
present: (args: NotificationPresentArgs) => NotificationPresentation;
+ /** Validates `data` on publish and whenever stored data is read. */
schema: z.ZodType;
+ /** The only subject type `publish()` accepts for this type, e.g. `"forum.topic"`. */
subjectType?: string;
+ /** Integer of 1 or more. Increase it when `schema` changes shape, and add `migrate`. */
version: number;
}
@@ -89,6 +150,7 @@ const CATEGORY_PATTERN = /^[a-z0-9_-]{1,50}$/;
export const SUBJECT_TYPE_PATTERN = /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/;
const MAX_GROUPING_WINDOW_MINUTES = 60 * 24 * 7;
+/** Thrown when a notification type is invalid or two plugins register the same id. */
export class NotificationRegistryError extends Error {
constructor(message: string) {
super(`[Notifications] ${message}`);
@@ -96,6 +158,37 @@ export class NotificationRegistryError extends Error {
}
}
+/**
+ * Declares a persistent notification type and checks it when the module loads.
+ * Register the returned object in `buildApiPlugin({ notificationTypes })` or
+ * `buildModule({ notificationTypes })`, and pass the same object to
+ * `c.get("notifications").publish({ type })` so `data` is type-checked.
+ *
+ * @throws {NotificationRegistryError} For an invalid `id`, `category` or
+ * `subjectType`, a `version` below 1, a grouping window outside 1-10080
+ * minutes, or `defaults.email` other than `"none"` without `email`.
+ *
+ * @example
+ * ```ts
+ * import { buildNotificationType } from "@vitnode/core/api/lib/notifications/registry";
+ * import { z } from "zod";
+ *
+ * export const topicReplyNotification = buildNotificationType({
+ * id: "forum.topic_reply",
+ * version: 1,
+ * schema: z.object({ topicId: z.number(), topicTitle: z.string() }),
+ * category: "content",
+ * label: "@acme/forum.notifications.topic_reply.label",
+ * defaults: { inApp: true, email: "none" },
+ * present: ({ data, t }) => ({
+ * title: t("@acme/forum.notifications.topic_reply.title", {
+ * title: data.topicTitle,
+ * }),
+ * target: `/forum/topics/${data.topicId}`,
+ * }),
+ * });
+ * ```
+ */
export function buildNotificationType(
definition: NotificationTypeDefinition,
): NotificationTypeDefinition {
diff --git a/packages/vitnode/src/api/lib/passkey-config.ts b/packages/vitnode/src/api/lib/passkey-config.ts
index 6db2c5196c..3f8d014055 100644
--- a/packages/vitnode/src/api/lib/passkey-config.ts
+++ b/packages/vitnode/src/api/lib/passkey-config.ts
@@ -1,6 +1,21 @@
+/**
+ * Passkey (WebAuthn) settings, the API config's `authorization.passkeys` when
+ * it is an object (`true` uses every default). Checked at startup: a bad
+ * origin or RP ID throws.
+ */
export interface PasskeysConfig {
+ /**
+ * Bare origins (`https://example.com`, no path) passkeys are accepted from.
+ * HTTPS only, except `localhost`. Defaults to the web origin
+ * (`VITNODE_WEB_URL`).
+ */
origins?: string[];
+ /**
+ * The relying party ID: a lowercase domain (not an IP) that every origin's
+ * host equals or is a subdomain of. Defaults to the first origin's host.
+ */
rpId?: string;
+ /** The name shown in the browser's passkey prompt. Defaults to `metadata.shortTitle ?? metadata.title`. */
rpName?: string;
}
@@ -77,6 +92,12 @@ export const passkeyConfigProblems = ({
return problems;
};
+/**
+ * The passkey settings with defaults filled in, or `enabled: false` when
+ * passkeys are off.
+ *
+ * @throws When the resolved origins or RP ID are invalid.
+ */
export const resolvePasskeysConfig = ({
config,
rpNameFallback,
diff --git a/packages/vitnode/src/api/lib/permission-staff.ts b/packages/vitnode/src/api/lib/permission-staff.ts
index 11b638a1d5..8c0252ae38 100644
--- a/packages/vitnode/src/api/lib/permission-staff.ts
+++ b/packages/vitnode/src/api/lib/permission-staff.ts
@@ -1,8 +1,22 @@
+/**
+ * One permission a plugin declares: its key (e.g. `"can_delete"`), or an object
+ * when it has dependencies or a scope. Labels come from the plugin's locale
+ * file as `"{pluginId}:{module}:{permission}"`.
+ */
export type PermissionStaffEntryInput =
| string
| {
+ /**
+ * Permissions of the same module this one requires. The AdminCP staff
+ * editor keeps them granted together, and a scoped moderator grant is
+ * narrowed to the scope of its dependencies.
+ */
dependsOn?: string[];
permission: string;
+ /**
+ * A moderation scope family id (e.g. `"forum.section"`), so the grant can
+ * be limited to selected resources. Moderator permissions only.
+ */
scope?: string;
};
@@ -22,8 +36,29 @@ export interface PermissionStaffEntry {
export type PermissionStaffModules = Record;
+/**
+ * The staff permissions a plugin declares in `buildApiPlugin({ permissionStaff })`,
+ * grouped by staff type and then by module.
+ *
+ * Core adds the generated content permissions (at least
+ * `can_view`/`can_create`/`can_edit`/`can_delete`) for each content type and
+ * container collection, plus report-target permissions, unless the plugin
+ * declares that module itself. Enforce an admin permission with `adminStaffPermission` in
+ * `buildRoute`, or any permission with `assertStaffPermission`.
+ *
+ * @example
+ * ```ts
+ * permissionStaff: {
+ * admin: {
+ * posts: ["can_view", { permission: "can_delete", dependsOn: ["can_view"] }],
+ * },
+ * }
+ * ```
+ */
export interface PermissionStaffConfig {
+ /** Permissions grantable to AdminCP administrators. */
admin?: PermissionStaffModulesInput;
+ /** Permissions grantable to moderators. */
moderator?: PermissionStaffModulesInput;
}
@@ -47,10 +82,12 @@ export const normalizePermissionStaffModules = (
]),
);
+/** Limits a grant to every resource of a scope family, or to the selected ids. */
export type PermissionStaffScope =
| { family: string; ids: string[]; mode: "selected" }
| { family: string; mode: "all" };
+/** One permission tuple: which plugin, module and permission key. */
export interface PermissionsStaffArgs {
module: string;
permission: string;
@@ -58,6 +95,7 @@ export interface PermissionsStaffArgs {
scope?: PermissionStaffScope;
}
+/** The resource a scoped permission is checked against, e.g. a forum section. */
export interface PermissionStaffResource {
family: string;
id: string;
diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts
index df7c250b42..baab563b26 100644
--- a/packages/vitnode/src/api/lib/plugin.ts
+++ b/packages/vitnode/src/api/lib/plugin.ts
@@ -49,6 +49,16 @@ import { applyModuleTags } from "./openapi-tags";
export type { ApiPluginContract };
+/**
+ * A plugin's API half, as {@link buildApiPlugin} returns it. An app lists it in
+ * `buildApiConfig({ plugins })` and `VitNodeAPI` mounts it at `/{pluginId}`
+ * under the API's base path (`/api` in the starter apps).
+ *
+ * Everything the modules declared is already collected and validated here:
+ * content types, models and containers from the whole module tree; cron jobs,
+ * event listeners, queue tasks and WebSockets from top-level modules only, each
+ * stamped with the module that declared it.
+ */
export interface BuildPluginApiReturn<
P extends string = string,
Modules extends readonly BaseBuildModuleReturn
[] =
@@ -62,15 +72,24 @@ export interface BuildPluginApiReturn<
cronJobs?: Omit[];
editablePages?: AnyEditablePageDefinition[];
events?: Omit[];
+ /** Every module's routes, each module mounted at `/{module.name}`. */
hono: OpenAPIHono;
manifest?: PluginManifest
;
messages?: LocaleMessagesMap;
+ /**
+ * Declared targets and scopes, plus one report target per content model with
+ * reports enabled and one scope per content container with policies enabled.
+ */
moderation?: Required;
+ /** The module tuple as passed in; the fetcher's types resolve calls through it. */
modules: Modules;
notificationTypes?: AnyNotificationTypeDefinition[];
openApiTags?: string[];
+ /** The declared permissions, plus the ones content types, containers and report targets add. */
permissionStaff?: PermissionStaffConfig;
+ /** The plugin's npm package name, taken from `manifest.id`. */
pluginId: P;
+ /** Content types the plugin serves public URLs for. */
publicContentTypes?: AnyContentTypeDefinition[];
queueTasks?: Omit[];
searchIndexers?: SearchIndexer[];
@@ -82,6 +101,44 @@ export type AnyBuildPluginApiReturn = BuildPluginApiReturn<
readonly BaseBuildModuleReturn[]
>;
+/**
+ * Declares a plugin's API half: its Hono modules plus everything they bring -
+ * content types, cron jobs, events, queues, notifications, WebSockets, staff
+ * permissions. Its frontend half is `buildPlugin` from
+ * `@vitnode/core/lib/plugin`; both take the plugin's generated manifest, whose
+ * `id` (the package name) keys every registration.
+ *
+ * Server-only. Call it from the plugin's `src/config.api.ts`, which an app
+ * imports in its `vitnode.api.config.ts` and nowhere a browser bundle reaches.
+ *
+ * Staff permissions: every content type adds `can_view`, `can_create`,
+ * `can_edit` and `can_delete` (plus `can_publish`, `can_hide` and
+ * `can_restore` when publication, visibility or editorial is on) under its
+ * permission module, and every content container and report target adds its
+ * own entries, unless `permissionStaff` already declares that module.
+ *
+ * @throws When `manifest` is malformed; when `blocks` belongs to another
+ * plugin; when content types, containers, editable pages, AI actions, search
+ * indexers or notification types are invalid or collide. In development, also
+ * when no installed package's `package.json` has the plugin id as its name.
+ *
+ * @example
+ * ```ts
+ * // src/config.api.ts
+ * import type { ApiPluginContract } from "@vitnode/core/api/lib/plugin";
+ *
+ * import { buildApiPlugin } from "@vitnode/core/api/lib/plugin";
+ *
+ * import { postsModule } from "@/api/modules/posts/posts.module";
+ * import { manifest } from "@/manifest.gen";
+ *
+ * export const blogApiPlugin = () =>
+ * buildApiPlugin({ manifest, modules: [postsModule] });
+ *
+ * // What the fetcher's registry reads to type calls to this plugin.
+ * export type VitNodeApiPlugin = ApiPluginContract>;
+ * ```
+ */
export function buildApiPlugin<
const P extends string,
const Modules extends readonly BuildModuleReturn
[] = readonly [],
@@ -97,15 +154,31 @@ export function buildApiPlugin<
permissionStaff,
searchIndexers,
}: {
+ /**
+ * Managed AI actions the plugin runs with `c.get("ai").run()` or
+ * `runAsSystem()`; admins configure them and grant their permission per role.
+ */
aiActions?: AnyAiActionDefinition[];
+ /** The object the plugin's own widgets (`blocks`) module exports; its `pluginId` must match. */
blocks?: BlockPluginSource;
+ /** Pages built from widgets that admins edit in place. Page ids must be unique within the plugin. */
editablePages?: AnyEditablePageDefinition[];
+ /** The plugin's generated manifest (`src/manifest.gen.ts`). */
manifest: PluginManifest
;
+ /**
+ * Lazy loaders for the plugin's server-side messages (emails, notifications),
+ * keyed by locale code. Separate from the frontend tree `buildPlugin` takes.
+ */
messages?: LocaleMessagesMap;
+ /** Report targets and moderation scopes beyond those content models add. */
moderation?: ModerationPluginConfig;
+ /** Top-level modules, each mounted at `/{pluginId}/{name}` under the API's base path. */
modules?: Modules;
+ /** Notification types in addition to those modules declare. */
notificationTypes?: AnyNotificationTypeDefinition[];
+ /** Staff permissions the plugin's routes check, grouped by permission module. */
permissionStaff?: PermissionStaffConfig;
+ /** Search indexers in addition to those modules declare. */
searchIndexers?: SearchIndexer[];
}): BuildPluginApiReturn
{
const pluginId = manifest.id;
diff --git a/packages/vitnode/src/api/lib/queue.ts b/packages/vitnode/src/api/lib/queue.ts
index fd76dad12f..d3c347d907 100644
--- a/packages/vitnode/src/api/lib/queue.ts
+++ b/packages/vitnode/src/api/lib/queue.ts
@@ -2,22 +2,72 @@ import type { Context } from "hono";
import type { EnvVitNode } from "../middlewares/global.middleware";
+/** A background task handler, as returned by {@link buildQueueTask}. */
export interface BuildQueueTaskReturn {
+ /** A note on what the task does. Kept on the definition only; core does not display it. */
description?: string;
+ /**
+ * Runs once per claimed task with the payload given to `dispatch()`. The
+ * payload is stored as JSON, so validate it before use.
+ *
+ * Returning completes the task. Throwing retries it with exponential backoff
+ * (10 s, doubling, capped at 1 hour) until `maxAttempts` is reached, then
+ * marks it failed. Throw {@link QueueDeferError} to postpone without using
+ * up an attempt.
+ */
handler: (
c: Context,
payload: Record,
) => Promise | void;
+ /**
+ * Seconds a worker may hold a claimed task before it is treated as
+ * abandoned and returned to the queue (or failed, once out of attempts).
+ * Defaults to 600. Raise it for tasks that legitimately run longer.
+ */
leaseSeconds?: number;
+ /**
+ * Default attempt limit for tasks dispatched without their own
+ * `maxAttempts`. Defaults to 3.
+ */
maxAttempts?: number;
+ /**
+ * Unique within the plugin - tasks are matched to handlers by plugin id and
+ * `name`, not by module.
+ */
name: string;
}
+/** A queue task after registration, tagged with the module and plugin that declared it. */
export interface QueueTaskConfig extends BuildQueueTaskReturn {
module: string;
pluginId: string;
}
+/**
+ * Declares a handler for durable background work stored in the `core_queue`
+ * table. Add it to a top-level module's `queueTasks` array, then enqueue work
+ * with `c.get("queue").dispatch({ name, payload })`.
+ *
+ * Tasks are processed by the core `process-queue` cron job (every minute, up
+ * to 25 per tick, highest `priority` first), so a cron adapter or an external
+ * cron trigger must be running.
+ *
+ * @example
+ * ```ts
+ * import { buildQueueTask } from "@vitnode/core/api/lib/queue";
+ *
+ * export const reindexPostTask = buildQueueTask({
+ * name: "reindex-post",
+ * maxAttempts: 5,
+ * handler: async (c, payload) => {
+ * const postId = Number(payload.postId);
+ * // ...
+ * },
+ * });
+ *
+ * // In a route: await c.get("queue").dispatch({ name: "reindex-post", payload: { postId } });
+ * ```
+ */
export function buildQueueTask({
name,
handler,
@@ -28,6 +78,11 @@ export function buildQueueTask({
return { name, handler, description, leaseSeconds, maxAttempts };
}
+/**
+ * Throw from a queue task handler to put the task back to `pending` until
+ * `until`, without counting the current attempt - for example while waiting
+ * on a rate limit or an external resource.
+ */
export class QueueDeferError extends Error {
constructor(until: Date, reason: string) {
super(reason);
diff --git a/packages/vitnode/src/api/lib/route.ts b/packages/vitnode/src/api/lib/route.ts
index 784f46fef1..e01491062e 100644
--- a/packages/vitnode/src/api/lib/route.ts
+++ b/packages/vitnode/src/api/lib/route.ts
@@ -11,14 +11,23 @@ import { pluginMiddleware } from "../middlewares/plugin.middleware";
import { assertStaffPermission } from "./check-staff-permission";
import { pluginTag } from "./openapi-tags";
+/**
+ * Runs after request validation, as `@hono/zod-openapi`'s hook. Return a
+ * `Response` to answer a failed validation yourself; return nothing for the
+ * default handling.
+ */
export type RouteValidationHook = (
result: { error: ZodError; success: false } | { success: true },
c: Context,
) => Promise | Response | undefined;
+/** The admin staff permission a route requires. */
export interface AdminStaffPermission {
+ /** The permission module, e.g. `posts`. */
module: string;
+ /** The permission within it, e.g. `can_edit`. */
permission: string;
+ /** The plugin that declares the permission. Defaults to the route's own plugin. */
plugin?: string;
}
@@ -35,6 +44,11 @@ const unwrapHandler = (handler: unknown): unknown =>
? unwrapHandler(handler.__COMPOSED_HANDLER)
: handler;
+/**
+ * Every route under an `admin` path segment without an `adminStaffPermission`
+ * guard, as sorted `METHOD /path` labels. Use it in a test (core and the
+ * bundled plugins do) to keep an admin API from shipping an unguarded route.
+ */
export const findAdminRoutesWithoutStaffPermission = (hono: {
routes: readonly MountedRoute[];
}): string[] => {
@@ -57,21 +71,66 @@ export const findAdminRoutesWithoutStaffPermission = (hono: {
.sort();
};
+/** A `@hono/zod-openapi` route definition, plus VitNode's own options. */
export type BuildRouteConfig
= Omit & {
+ /** Relative to the module, with OpenAPI-style params, e.g. `/{id}`. Also the fetcher's `path`. */
path: P;
+ /**
+ * Requires a valid captcha token in the `x-vitnode-captcha-token` header
+ * when the app has a captcha configured; answers `400` otherwise. Callers
+ * pass it as the fetcher's `captchaToken`. No effect without a captcha config.
+ */
withCaptcha?: boolean;
};
+/** What `buildRoute` accepts. */
export interface BuildRouteOptions<
P extends string,
R extends BuildRouteConfig
,
> {
+ /**
+ * Requires an admin with this staff permission; anyone else gets `403`.
+ * Paths containing `/admin/` already require an admin session, but this is
+ * the only per-permission check, so every admin route should set it.
+ */
adminStaffPermission?: AdminStaffPermission;
+ /** The Hono handler, typed from `route`: `c.req.valid(...)` and `c.json(...)` follow its schemas. */
handler: RouteHandler;
hook?: RouteValidationHook;
route: R;
}
+/**
+ * Defines one typed API route. Plugins call the bound version from
+ * `bindApiPlugin`, which supplies `pluginId`.
+ *
+ * The route is tagged with its plugin for OpenAPI and runs behind middleware
+ * that sets `c.get("plugin")`, then the staff-permission guard and the captcha
+ * check when requested, then the route's own `middleware`.
+ *
+ * @example
+ * ```ts
+ * import { z } from "@hono/zod-openapi";
+ *
+ * import { buildRoute } from "@/api/builders";
+ *
+ * export const showPostRoute = buildRoute({
+ * adminStaffPermission: { module: "posts", permission: "can_view" },
+ * route: {
+ * method: "get",
+ * path: "/{id}",
+ * request: { params: z.object({ id: z.string() }) },
+ * responses: {
+ * 200: {
+ * content: { "application/json": { schema: z.object({ title: z.string() }) } },
+ * description: "The post",
+ * },
+ * },
+ * },
+ * handler: c => c.json({ title: c.req.valid("param").id }, 200),
+ * });
+ * ```
+ */
export const buildRoute = <
Plugin extends string,
P extends string,
@@ -129,6 +188,7 @@ export const buildRoute = <
};
};
+/** A built route, ready for `buildModule`'s `routes`. */
export interface Route<
Plugin extends string = string,
R extends RouteConfig = RouteConfig,
diff --git a/packages/vitnode/src/api/lib/save-language-words.ts b/packages/vitnode/src/api/lib/save-language-words.ts
index 6abf4f6e5d..b5678258e3 100644
--- a/packages/vitnode/src/api/lib/save-language-words.ts
+++ b/packages/vitnode/src/api/lib/save-language-words.ts
@@ -6,14 +6,39 @@ import type { MultiLangValue } from "@/lib/helpers/multi-lang";
import { core_languages_words } from "@/database/languages";
+/** Which field of which record a multi-language value belongs to, plus the value. */
export interface SaveLanguageWordsArgs {
+ /** The record's id. */
itemId: number;
+ /** The owning plugin's code, e.g. `"blog"`. */
pluginCode: string;
+ /** The record's table, e.g. `"blog_categories"`. */
tableName: string;
+ /** The `multiLang` field's value: one `{ languageCode, value }` per language. Empty or missing clears it. */
values: MultiLangValue;
+ /** The field name, e.g. `"name"`. */
variable: string;
}
+/**
+ * Saves a multi-language field value (an `AutoForm` `multiLang` field) to
+ * `core_languages_words`, replacing every stored translation of that field in
+ * one transaction. Server-only; read the rows back by the same
+ * `(pluginCode, tableName, variable, itemId)` to prefill an edit form.
+ *
+ * @example
+ * ```ts
+ * import { saveLanguageWords } from "@vitnode/core/api/lib/save-language-words";
+ *
+ * await saveLanguageWords(c, {
+ * pluginCode: "blog",
+ * tableName: "blog_categories",
+ * variable: "name",
+ * itemId: category.id,
+ * values: name,
+ * });
+ * ```
+ */
// Write mirror of `resolveRoleNames`: persists a `multiLang` field value (the
// `{ languageCode, value }[]` produced by the form) into `core_languages_words`.
// The remaining columns - the `(pluginCode, tableName, variable, itemId)` tuple
diff --git a/packages/vitnode/src/api/lib/websocket.ts b/packages/vitnode/src/api/lib/websocket.ts
index 47d537dd9b..16f4236a13 100644
--- a/packages/vitnode/src/api/lib/websocket.ts
+++ b/packages/vitnode/src/api/lib/websocket.ts
@@ -7,6 +7,7 @@ export { getWebSocketId } from "@/ws/types";
export type VitNodeWSContext = WSContext;
+/** What a WebSocket handler's `onMessage` receives for each matching client message. */
export interface VitNodeWSMessageParams {
/** Hono request context - reach the db, the user, the logger, etc. */
c: Context;
@@ -18,6 +19,7 @@ export interface VitNodeWSMessageParams {
ws: VitNodeWSContext;
}
+/** A WebSocket handler, as returned by {@link buildWebSocket}. */
export interface BuildWebSocketReturn {
description?: string;
/**
@@ -25,14 +27,43 @@ export interface BuildWebSocketReturn {
* is `{pluginId}_{module}_{id}`.
*/
id: string;
+ /**
+ * Called for every client message whose `id` matches this socket. Errors,
+ * sync or async, are logged to the console and never close the connection.
+ */
onMessage: (params: VitNodeWSMessageParams) => Promise | void;
}
+/** A WebSocket handler after registration, tagged with the module and plugin that declared it. */
export interface WebSocketConfig extends BuildWebSocketReturn {
module: string;
pluginId: string;
}
+/**
+ * Declares a server handler for messages sent over the shared `/api/ws`
+ * connection. Add it to a top-level module's `webSockets` array.
+ *
+ * Clients address it as `{pluginId}_{module}_{id}`; share that id and the
+ * message types with the browser through `createWebSocketChannel` from
+ * `@vitnode/core/ws/types`, and talk to it with `useVitNodeWebSocket`. To push
+ * without a client message, use `c.get("realtime")`.
+ *
+ * Only active when the app mounts the WebSocket endpoint
+ * (`handleVitNodeWebSocket`).
+ *
+ * @example
+ * ```ts
+ * import { buildWebSocket } from "@vitnode/core/api/lib/websocket";
+ *
+ * export const echoSocket = buildWebSocket<{ text: string }, { text: string }>({
+ * id: "echo",
+ * onMessage: ({ data, send }) => {
+ * send({ text: data.text });
+ * },
+ * });
+ * ```
+ */
export function buildWebSocket(webSocket: {
description?: string;
id: string;
diff --git a/packages/vitnode/src/api/lib/with-pagination.ts b/packages/vitnode/src/api/lib/with-pagination.ts
index 695d004b41..a4f26aa175 100644
--- a/packages/vitnode/src/api/lib/with-pagination.ts
+++ b/packages/vitnode/src/api/lib/with-pagination.ts
@@ -228,6 +228,43 @@ export type PaginationCursorColumn = PgColumn<
PgColumnBaseConfig
>;
+/**
+ * Pages a Drizzle query by cursor (`first`/`last` + `cursor`) or by page
+ * number (`page`), and returns `{ edges, pageInfo }` ready for a list route.
+ * Server-only.
+ *
+ * Rows are ordered by `orderBy.column` with `primaryCursor` as the tiebreaker,
+ * so pages never skip or repeat rows. Page size defaults to 50 and is capped at
+ * 100. With `search`, `params.query.search` filters to rows where any of those
+ * columns contains the term (case-insensitive).
+ *
+ * Validate the query with {@link zodPaginationQuery} and describe the response
+ * with {@link zodPaginationPageInfo}.
+ *
+ * @throws `HTTPException(400)` for invalid or conflicting paging params, a
+ * tampered or stale cursor, a `primaryCursor` that is not an integer, bigint
+ * or UUID column, or an order column that cannot be paged.
+ *
+ * @example
+ * ```ts
+ * const { edges, pageInfo } = await withPagination({
+ * c,
+ * params: { query: c.req.valid("query") },
+ * table: core_logs,
+ * primaryCursor: core_logs.id,
+ * orderBy: { column: core_logs.createdAt, order: "desc" },
+ * query: async ({ cursorSelection, limit, offset, where, orderBy }) =>
+ * await c
+ * .get("db")
+ * .select({ ...cursorSelection, id: core_logs.id, content: core_logs.content })
+ * .from(core_logs)
+ * .where(where)
+ * .orderBy(orderBy)
+ * .limit(limit)
+ * .offset(offset),
+ * });
+ * ```
+ */
export async function withPagination<
QueryMin extends Record,
T extends TableConfig,
@@ -242,6 +279,7 @@ export async function withPagination<
c,
}: {
c: Context;
+ /** The sort column (a column of `table`) and direction. */
orderBy: {
column: PgColumn;
order: "asc" | "desc";
@@ -255,7 +293,12 @@ export async function withPagination<
search?: string;
};
};
+ /** The table's unique id column, the ordering's tiebreaker. */
primaryCursor: PaginationCursorColumn;
+ /**
+ * Runs the page query with the `where`, `orderBy`, `limit` and `offset` it is
+ * given - `limit` is one more than the page size, to detect a next page.
+ */
query: (args: {
/**
* Spread this into the projection: `.select({ ...fields, ...cursorSelection })`.
@@ -270,8 +313,11 @@ export async function withPagination<
orderBy: SQL;
where: SQL | undefined;
}) => Promise;
+ /** Text columns `params.query.search` is matched against. */
search?: T["columns"][keyof T["columns"]][];
+ /** The table being paged, for the total count and the cursor columns. */
table: Omit, "enableRLS">;
+ /** Your own filter, applied to the page and to `totalCount`. */
where?: SQL;
}): Promise<{
edges: Omit[];
@@ -532,6 +578,7 @@ const zodPageSize = z
.refine(value => Number(value) >= 1, "Must be greater than zero.")
.refine(value => Number.isSafeInteger(Number(value)), "Too large.");
+/** The `pageInfo` a {@link withPagination} list route returns, for its response schema. */
export const zodPaginationPageInfo = z.object({
totalCount: z.number(),
totalPages: z.number(),
@@ -548,6 +595,11 @@ export const zodPaginationPageInfo = z.object({
endCursor: z.string().nullable(),
});
+/**
+ * The paging query params {@link withPagination} reads - `cursor`, `first`,
+ * `last`, `page` - for a list route's request schema. Extend it with your own
+ * filters, sort options and `search`.
+ */
export const zodPaginationQuery = z
.object({
cursor: z
diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts
index 00a1326b3a..251af43773 100644
--- a/packages/vitnode/src/api/middlewares/global.middleware.ts
+++ b/packages/vitnode/src/api/middlewares/global.middleware.ts
@@ -95,11 +95,39 @@ declare module "hono" {
interface ContextVariableMap extends EnvVariablesVitNode {}
}
+/** The Hono environment of every VitNode API route: see {@link EnvVariablesVitNode}. */
export interface EnvVitNode extends Env {
Variables: EnvVariablesVitNode;
}
+/**
+ * What a route handler reads with `c.get(...)`. All are set before the route
+ * runs: the global middleware sets most of them, `buildRoute` sets `plugin`,
+ * and the admin middleware fills `admin` on `/admin/` paths. They are also
+ * typed on Hono's own `Context` through `ContextVariableMap`.
+ *
+ * The models (`ai`, `cache`, `email`, ...) are per-request objects bound to the
+ * context, so they act for the current user and plugin.
+ *
+ * @example
+ * ```ts
+ * handler: async c => {
+ * const user = c.get("user");
+ * if (!user) throw new HTTPException(401);
+ * const posts = await c.get("cache").remember("latest", 60, async () =>
+ * c.get("db").select().from(blog_posts).limit(10),
+ * );
+ *
+ * return c.json({ posts }, 200);
+ * },
+ * ```
+ */
export interface EnvVariablesVitNode {
+ /**
+ * The signed-in admin, on paths containing `/admin/` only - such a request
+ * without a valid admin session is answered `403` before the route runs.
+ * `null` everywhere else.
+ */
admin: null | {
user: {
avatarColor: string;
@@ -123,9 +151,17 @@ export interface EnvVariablesVitNode {
timeZone: null | string;
};
};
+ /** When the admin session expires; `null` outside `/admin/` paths. */
adminSessionExpiresAt: Date | null;
+ /** The AI model registry and action runner: see {@link AIModel}. */
ai: AIModel;
+ /** Plugin-scoped JSON cache, Redis-backed when the API config has `redis`, a no-op otherwise. */
cache: CacheModel;
+ /**
+ * The resolved installation: the API config with its defaults applied, plus
+ * everything every plugin registered. Read-only, and mostly for core's own
+ * routes - prefer the models above.
+ */
core: {
ai?: VitNodeApiConfig["ai"];
aiActions: AiActionRegistry;
@@ -193,20 +229,43 @@ export interface EnvVariablesVitNode {
};
webSockets: WebSocketConfig[];
};
+ /** The Drizzle database from the API config's `dbProvider`. */
db: Pick["dbProvider"];
+ /** Renders and sends emails through the configured email adapter: see {@link EmailModel}. */
email: EmailModel;
+ /** Emits events to registered listeners. */
events: EventsModel;
+ /** Server-side translations, for output with no UI such as emails: see {@link I18nModel}. */
i18n: I18nModel;
+ /**
+ * The client's IP: from a signed forwarded header (server-side fetcher
+ * calls), else the socket address (or the `x-forwarded-for` hop behind
+ * `VITNODE_TRUSTED_PROXY_HOPS` proxies), else `127.0.0.1`.
+ */
ipAddress: string;
+ /**
+ * Writes a `debug`, `warn` or `error` entry to the `core_logs` table, with
+ * the request's plugin, path, IP and user, and echoes it to the console.
+ */
log: LoggerMiddlewareType;
+ /** Creates and delivers user notifications. */
notifications: NotificationsModel;
+ /**
+ * The plugin whose route is handling the request, set by every route built
+ * with `buildRoute`. Scopes `cache` keys and stored files to that plugin.
+ */
plugin: {
id: string;
};
+ /** Dispatches background queue tasks. */
queue: QueueModel;
+ /** Pushes payloads to open WebSocket connections, across instances when Redis is configured. */
realtime: VitNodeRealtime;
+ /** Indexes and queries the search adapter: see {@link SearchModel}. */
search: SearchModel;
+ /** Uploads, reads and deletes files through the storage adapter: see {@link StorageModel}. */
storage: StorageModel;
+ /** The signed-in user from the session cookie, or `null` for a guest. */
user: null | {
avatarColor: string;
avatarUrl: null | string;
@@ -232,6 +291,14 @@ export interface EnvVariablesVitNode {
type CoreVariables = EnvVariablesVitNode["core"];
+/**
+ * Validates every registration once, then sets the {@link EnvVariablesVitNode}
+ * variables on each request. Mounted by `VitNodeAPI`; plugins never call it.
+ *
+ * @throws At startup, when the plugins' registrations are invalid or collide
+ * (content types, containers, editable pages, AI actions, search indexers,
+ * moderation targets and scopes).
+ */
export const globalMiddleware = ({
ai,
authorization,
@@ -618,6 +685,13 @@ export const globalMiddleware = ({
};
};
+/**
+ * Requires a valid admin session and sets `admin`. `VitNodeAPI` runs it for
+ * every path containing `/admin/`; a passive request (`?passive=true`) reads
+ * the session without extending it.
+ *
+ * @throws `HTTPException(403)` without a valid admin session.
+ */
export const globalAdminMiddleware = () => {
return async (c: Context, next: Next) => {
const session = await new SessionAdminModel(c).getSession({
diff --git a/packages/vitnode/src/api/middlewares/rate-limiter-config.ts b/packages/vitnode/src/api/middlewares/rate-limiter-config.ts
index a2a132a68d..71d376bc39 100644
--- a/packages/vitnode/src/api/middlewares/rate-limiter-config.ts
+++ b/packages/vitnode/src/api/middlewares/rate-limiter-config.ts
@@ -1,5 +1,11 @@
import type { IRateLimiterOptions } from "rate-limiter-flexible";
+/**
+ * One category's limit, passed to `rate-limiter-flexible`: `points` requests
+ * per `duration` seconds. A request over the limit gets `429` with
+ * `Retry-After` and `X-RateLimit-Category` headers (`OPTIONS` preflights are
+ * never refused). Off in development.
+ */
export type RateLimiterCategoryOptions = Partial<
Pick<
IRateLimiterOptions,
@@ -11,10 +17,23 @@ export type RateLimiterCategoryOptions = Partial<
>
>;
+/**
+ * Request limits per category, the API config's `rateLimiter`. Each request
+ * counts against `safety` plus the categories its method and path fall in.
+ * Unset categories keep their defaults.
+ */
export interface RateLimiterConfig {
+ /**
+ * POSTs to core's sign-in, sign-up, password, email verification and passkey
+ * sign-in routes. Always counted per IP. Defaults to 10 per 60 s.
+ */
auth?: RateLimiterCategoryOptions;
/** @deprecated Moved to `rateLimiter.safety.blockDuration`. */
blockDuration?: number;
+ /**
+ * POST and PATCH requests to non-admin Content Engine routes (paths with
+ * `/content/` and no `/admin/`). Defaults to 20 per 60 s.
+ */
contentWrite?: RateLimiterCategoryOptions;
/** @deprecated Moved to `rateLimiter.safety.duration`. */
duration?: number;
@@ -22,11 +41,22 @@ export interface RateLimiterConfig {
execEvenly?: boolean;
/** @deprecated Moved to `rateLimiter.safety.execEvenlyMinDelayMs`. */
execEvenlyMinDelayMs?: number;
+ /**
+ * Counts `read`, `write` and `contentWrite` per signed-in user instead of per
+ * IP for requests that carry a session cookie. `safety` and `auth` stay per
+ * IP. Defaults to `true`.
+ */
perUser?: boolean;
/** @deprecated Moved to `rateLimiter.safety.points`. */
points?: number;
+ /** `GET` and `HEAD` requests. Defaults to 300 per 60 s. */
read?: RateLimiterCategoryOptions;
+ /**
+ * The cap on every request, counted per IP. Defaults to 1000 per 60 s (80
+ * per 60 s while the deprecated top-level options are used).
+ */
safety?: RateLimiterCategoryOptions;
+ /** Every request other than `GET`, `HEAD` and `OPTIONS`. Defaults to 60 per 60 s. */
write?: RateLimiterCategoryOptions;
}
@@ -101,6 +131,11 @@ const resolveCategory = (
return resolved;
};
+/**
+ * Every category's limit, with defaults filled in.
+ *
+ * @throws When the deprecated top-level options and `safety` are both set.
+ */
export const resolveRateLimiterConfig = (
config: RateLimiterConfig | undefined,
): Record => {
diff --git a/packages/vitnode/src/api/models/ai.ts b/packages/vitnode/src/api/models/ai.ts
index c08c3306a2..46bc0e08e6 100644
--- a/packages/vitnode/src/api/models/ai.ts
+++ b/packages/vitnode/src/api/models/ai.ts
@@ -23,6 +23,7 @@ import {
openRouterProviderAdapter,
} from "../lib/ai/usage-cost";
+/** A run request whose `action` key carries its input and output types. */
export type TypedAiRunRequest = Omit<
AiRunRequest,
"action" | "input"
@@ -31,12 +32,19 @@ export type TypedAiRunRequest = Omit<
input: Input;
};
+/** A language model the installation offers, in the API config's `ai.models`. */
export interface AIModelDefinition {
+ /** What the model can do; AI actions needing a capability skip models without it. Defaults to `["text"]`. */
capabilities?: AiModelCapability[];
+ /** Stable id actions and settings refer to. The first model is the default. */
id: string;
+ /** Tokens one input image is estimated at when reserving budget. Defaults to `2000`. */
imageInputTokens?: number;
+ /** A Vercel AI SDK language model, e.g. `openai("gpt-5-mini")`. */
model: LanguageModel;
+ /** Display name in the AdminCP. */
name: string;
+ /** Per-token prices: used to reserve budget before a run, and to cost it when the provider reports no cost. */
pricing?: AiPricing;
}
@@ -54,13 +62,16 @@ export interface AIImageModelDefinition {
name: string;
}
+/** The API config's `ai` option: the models AI features may use. */
export interface AIConfig {
/** Text embedding models, resolved with `c.get("ai").embeddingModel(id?)`. */
embeddingModels?: AIEmbeddingModelDefinition[];
/** Image models, resolved with `c.get("ai").imageModel(id?)`. */
imageModels?: AIImageModelDefinition[];
+ /** Language models; the first is the default. */
models: AIModelDefinition[];
+ /** Read provider-reported costs for providers beyond the built-in AI Gateway and OpenRouter ones. */
providerAdapters?: AiProviderAdapter[];
}
@@ -77,6 +88,26 @@ export interface AIPublicModel {
const toModelId = (model: string | { modelId: string }): string =>
typeof model === "string" ? model : model.modelId;
+/**
+ * The installation's AI registry, read in a route as `c.get("ai")`.
+ *
+ * Two ways to use it: resolve a model with {@link AIModel.model} and call the Vercel
+ * AI SDK yourself, or run a registered AI action with {@link AIModel.run} or
+ * {@link AIModel.stream}, which also checks the action's permissions, enforces budgets
+ * and records usage.
+ *
+ * Model lookups throw `HTTPException(500)` when no model (or no model with
+ * that id) is configured. Action runs throw `AiError`, an `HTTPException`
+ * whose JSON body carries a `code` such as `AI_UNAUTHORIZED`, `AI_DISABLED` or
+ * `AI_NOT_CONFIGURED`.
+ *
+ * @example
+ * ```ts
+ * import { generateText } from "ai";
+ *
+ * const { text } = await generateText({ model: c.get("ai").model(), prompt });
+ * ```
+ */
export class AIModel {
constructor(c: Context, { ledger }: { ledger?: AiLedger } = {}) {
this.c = c;
@@ -115,16 +146,19 @@ export class AIModel {
return this.runnerInstance;
}
+ /** Every registered AI action. */
actions(): AiActionRegistry {
return this.c.get("core")?.aiActions ?? new AiActionRegistry([]);
}
+ /** A model's capabilities; omit `id` for the default model. */
capabilities(id?: string): AiModelCapability[] {
const entry = this.definition(id);
return [...(entry.capabilities ?? DEFAULT_AI_MODEL_CAPABILITIES)];
}
+ /** A model's configured definition; omit `id` for the default (first) model. */
definition(id?: string): AIModelDefinition {
const { models } = this.config();
const found = id ? models.find(entry => entry.id === id) : models[0];
@@ -159,6 +193,7 @@ export class AIModel {
return found.model;
}
+ /** The most a run could cost, in points and USD; `null` when the price is unknown. */
async estimate(
request: AiRunRequest,
): Promise<{ maxPoints: null | string; maxUsd: null | string }> {
@@ -187,6 +222,7 @@ export class AIModel {
return found.model;
}
+ /** Where runs and usage are recorded: Postgres unless the model was built with another ledger. */
ledger(): AiLedger {
return this.ledgerOverride ?? new PostgresAiLedger(this.c.get("db"));
}
@@ -199,6 +235,7 @@ export class AIModel {
return this.definition(id).model;
}
+ /** The configured models as serializable info, safe to send to the browser. Empty when AI is off. */
models(): AIPublicModel[] {
const ai = this.c.get("core").ai;
@@ -210,6 +247,15 @@ export class AIModel {
provider: providerIdOf(entry.model),
}));
}
+ /**
+ * Runs a registered AI action for the signed-in user (the admin on admin
+ * paths), validating its input and output, checking its permissions and
+ * budgets, and recording usage.
+ *
+ * @throws `AiError` with `AI_UNAUTHORIZED` for a guest or a user the action
+ * does not allow, or another code when the action is unknown, disabled,
+ * unconfigured, over budget or fails.
+ */
async run(
request: TypedAiRunRequest,
): Promise>;
@@ -217,6 +263,10 @@ export class AIModel {
async run(request: AiRunRequest): Promise> {
return await this.runner().run(request);
}
+ /**
+ * {@link AIModel.run} without a user, for jobs and other background work. Only for
+ * actions whose `actors` include `system`; usage is recorded against no user.
+ */
async runAsSystem(
request: TypedAiRunRequest,
): Promise>;
@@ -225,6 +275,10 @@ export class AIModel {
return await this.runner().runAsSystem(request);
}
+ /**
+ * {@link AIModel.run}, streaming the text as it arrives. Only for actions with text
+ * output; `result` settles once the stream ends.
+ */
async stream(
request: TypedAiRunRequest,
): Promise>;
diff --git a/packages/vitnode/src/api/models/email.ts b/packages/vitnode/src/api/models/email.ts
index b48f0da783..e20f4a2ce5 100644
--- a/packages/vitnode/src/api/models/email.ts
+++ b/packages/vitnode/src/api/models/email.ts
@@ -31,7 +31,16 @@ interface EmailModelSendArgsWithEmail {
user?: never;
}
+/**
+ * What an email adapter implements (e.g. `@vitnode/nodemailer`,
+ * `@vitnode/resend`); set it as the API config's `email.adapter`.
+ */
export interface EmailApiPlugin {
+ /**
+ * Sends one rendered email. Throw to fail the send: an email sent through
+ * `EmailModel.send` is retried by its queue task when that task runs. `idempotencyKey`, when a
+ * caller of `deliver` passes one, should reach providers that support it.
+ */
sendEmail: (args: {
html: string;
idempotencyKey?: string;
@@ -48,7 +57,16 @@ export interface EmailSendResult {
id?: string;
}
+/**
+ * One email to send: to a user (in their own language) or to an address with
+ * an explicit `locale`.
+ */
export type EmailModelSendArgs = {
+ /**
+ * Returns the email's React tree, called with the recipient's locale and
+ * messages plus the site's template props. Wrap it in `DefaultTemplateEmail`
+ * yourself to get the standard layout. Ignored when `html` is given.
+ */
content: (
props: Omit &
Pick,
@@ -56,6 +74,7 @@ export type EmailModelSendArgs = {
html?: string;
locale?: string;
replyTo?: string;
+ /** A string, or a function translating it with the recipient's locale and messages. */
subject:
| ((props: Pick) => string)
| string;
@@ -69,6 +88,14 @@ export interface BuiltEmail {
to: string;
}
+/**
+ * Renders React Email templates and sends them through the configured email
+ * adapter, read in a route as `c.get("email")`.
+ *
+ * Emails are written in the recipient's language - the user's own, or the
+ * `locale` given - falling back to the default locale when it is not offered,
+ * never in the language of whoever triggered the send.
+ */
export class EmailModel {
constructor(c: Context) {
this.c = c;
@@ -88,6 +115,11 @@ export class EmailModel {
return provider;
}
+ /**
+ * Renders an email to HTML and plain text without sending it.
+ *
+ * @throws `HTTPException(400)` when there is no recipient address.
+ */
async build({
html,
replyTo,
@@ -148,6 +180,11 @@ export class EmailModel {
};
}
+ /**
+ * Sends an already built email right away, through the adapter.
+ *
+ * @throws `HTTPException(500)` when no email adapter is configured.
+ */
async deliver(
email: BuiltEmail,
{ idempotencyKey }: { idempotencyKey?: string } = {},
@@ -190,6 +227,15 @@ export class EmailModel {
);
}
+ /**
+ * Renders the email now and queues core's `send-email` task (always under
+ * `@vitnode/core`, so plugin routes work too) - the request does not wait
+ * for the provider. Delivery is attempted right after the response, and the
+ * queue retries it if that fails.
+ *
+ * @throws `HTTPException(500)` when no email adapter is configured, and
+ * `HTTPException(400)` when there is no recipient address.
+ */
async send(args: EmailModelSendArgs): Promise {
this.requireProvider();
const email = await this.build(args);
diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts
index 1c323a2ee2..8ed841ce79 100644
--- a/packages/vitnode/src/api/models/events.ts
+++ b/packages/vitnode/src/api/models/events.ts
@@ -23,6 +23,21 @@ export interface ContentApprovalEventPayload {
submissionId: number;
}
+/**
+ * Every server-side event, mapped to its payload. Emitting and listening are
+ * type-checked against this map.
+ *
+ * A plugin adds its own events by augmenting the interface:
+ *
+ * @example
+ * ```ts
+ * declare module "@vitnode/core/api/models/events" {
+ * interface VitNodeEvents {
+ * "blog.post.created": { categoryId: number; postId: number };
+ * }
+ * }
+ * ```
+ */
export interface VitNodeEvents {
"ai.run.completed": {
actionKey: string;
@@ -209,22 +224,35 @@ export interface VitNodeEvents {
export type VitNodeEventName = keyof VitNodeEvents;
+/**
+ * Who caused an event, resolved from the emitting request: the admin on an
+ * AdminCP route, else the signed-in user, else `system` (cron, queue tasks,
+ * guests).
+ */
export interface EventActor {
id?: number;
type: "admin" | "system" | "user";
}
+/** An emitted event with its metadata, passed to listeners and adapters. */
export interface EventEnvelope {
+ /** Who caused the event. */
actor: EventActor;
+ /** When `emit()` was called. */
emittedAt: Date;
+ /** A random UUID, unique per emit; also returned in the result. */
eventId: string;
+ /** The event name, e.g. `"role.created"`. */
name: K;
+ /** The event's data, typed by its {@link VitNodeEvents} entry. */
payload: VitNodeEvents[K];
/** Plugin that emitted the event. */
pluginId: string;
}
+/** One listener (or the adapter itself) that failed to handle an event. */
export interface EventEmitFailure {
+ /** The thrown error's message. */
error: string;
/** Listener `name` as declared in `buildEventListener`. */
listener: string;
@@ -233,24 +261,44 @@ export interface EventEmitFailure {
pluginId: string;
}
+/** What `emit()` reports back about delivery. */
export interface EventEmitResult {
/** Listeners that ran successfully before `emit()` resolved. */
delivered: number;
+ /** The emitted envelope's id. */
eventId: string;
+ /** Listeners that threw. When the adapter itself throws, one entry with `module: "adapter"`. */
failures: EventEmitFailure[];
+ /** `delivered` when listeners ran before `emit()` resolved (the local adapter); `queued` when an adapter hands the event off. */
status: "delivered" | "queued";
}
+/**
+ * Transport for events, set as `events.adapter` in the API config. Defaults to
+ * the in-process local adapter, which runs matching listeners sequentially
+ * inside the emitting request.
+ *
+ * A custom adapter may deliver elsewhere and report `status: "queued"`.
+ * Throwing from `publish` is caught by `emit()`, logged, and reported as a
+ * failure.
+ */
export interface EventsApiPlugin {
+ /** Shown in logs and returned by `c.get("events").name()`, e.g. `"local"`. */
name: string;
+ /** Delivers one event to its listeners (read them from `c.get("core").events.listeners`) or hands it off. */
publish: (c: Context, envelope: EventEnvelope) => Promise;
}
export interface EventEmitOptions {
+ /**
+ * Plugin recorded as the emitter. Defaults to the plugin of the current
+ * route, then `@vitnode/core`.
+ */
pluginId?: string;
}
+/** Server-side events API, available as `c.get("events")` in API handlers. */
export class EventsModel {
constructor(c: Context) {
this.c = c;
@@ -262,6 +310,19 @@ export class EventsModel {
return this.c.get("core").events.adapter;
}
+ /**
+ * Publishes an event to its listeners through the configured adapter.
+ *
+ * Never throws: listener and adapter errors are logged and returned in
+ * `failures`. With the local adapter the promise resolves after every
+ * listener has finished, so await it when listeners must run before the
+ * response, and keep listeners fast.
+ *
+ * @example
+ * ```ts
+ * await c.get("events").emit("role.created", { roleId: role.id });
+ * ```
+ */
async emit(
name: K,
payload: VitNodeEvents[K],
@@ -310,6 +371,7 @@ export class EventsModel {
}
}
+ /** Name of the configured adapter, e.g. `"local"`. */
name(): string {
return this.adapter().name;
}
diff --git a/packages/vitnode/src/api/models/i18n.ts b/packages/vitnode/src/api/models/i18n.ts
index 3234ce34fe..872ce97e61 100644
--- a/packages/vitnode/src/api/models/i18n.ts
+++ b/packages/vitnode/src/api/models/i18n.ts
@@ -10,6 +10,11 @@ import { negotiateLocale } from "@/lib/i18n/negotiate-locale";
export type Translator = ReturnType;
+/**
+ * Server-side translations, read in a route as `c.get("i18n")`. For output with
+ * no UI only, such as emails - API responses return i18n keys and let the web
+ * translate them.
+ */
export class I18nModel {
constructor(c: Context) {
this.c = c;
@@ -17,6 +22,7 @@ export class I18nModel {
protected readonly c: Context;
+ /** The merged messages of core, every plugin and the app for a locale (see {@link I18nModel.resolveLocale}). */
async getMessages(locale?: string): Promise {
const core = this.c.get("core");
@@ -27,6 +33,16 @@ export class I18nModel {
});
}
+ /**
+ * A `use-intl` translator for a locale (see {@link I18nModel.resolveLocale}).
+ *
+ * @example
+ * ```ts
+ * const i18n = c.get("i18n");
+ * // The recipient's language, not the request's.
+ * const t = await i18n.getTranslator(i18n.resolveSupportedLocale(recipient.language));
+ * ```
+ */
async getTranslator(locale?: string): Promise {
const resolved = this.resolveLocale(locale);
@@ -36,6 +52,10 @@ export class I18nModel {
});
}
+ /**
+ * The locale for this request: `explicit` when offered, else the signed-in
+ * user's language, else the best `Accept-Language` match, else the default.
+ */
resolveLocale(explicit?: string): string {
const supported = this.locales.map(locale => locale.code);
@@ -50,6 +70,10 @@ export class I18nModel {
);
}
+ /**
+ * `preferred` when offered, else the default locale - never the request's
+ * language. For output addressed to someone else, such as an email.
+ */
resolveSupportedLocale(preferred?: string): string {
const supported = this.locales.map(locale => locale.code);
diff --git a/packages/vitnode/src/api/models/notifications/index.ts b/packages/vitnode/src/api/models/notifications/index.ts
index 3e72b6dea0..e3fc5d7258 100644
--- a/packages/vitnode/src/api/models/notifications/index.ts
+++ b/packages/vitnode/src/api/models/notifications/index.ts
@@ -17,6 +17,7 @@ import { publishNotification } from "./publish";
export type { PublishNotificationArgs, PublishNotificationResult };
+/** Server-side notifications API, available as `c.get("notifications")` in API handlers. */
export class NotificationsModel {
constructor(c: NotificationsContext) {
this.c = c;
@@ -41,6 +42,29 @@ export class NotificationsModel {
);
}
+ /**
+ * Stores a notification event and queues delivery to its recipients. With
+ * recipients, delivery also starts once the handler returns, without
+ * delaying the response; the queue worker covers anything that does not
+ * finish then. Invalid recipient ids (and the actor, unless `allowSelf`) are
+ * dropped silently.
+ *
+ * @throws NotificationPublishError When the type is not registered or passed
+ * as a definition other than the registered one, `data` fails the schema,
+ * the idempotency key or subject is invalid, there are more than 100000
+ * recipients, or the same event is being published concurrently.
+ *
+ * @example
+ * ```ts
+ * await c.get("notifications").publish({
+ * type: topicReplyNotification,
+ * data: { topicId: topic.id, topicTitle: topic.title },
+ * recipients: watcherIds,
+ * subject: { type: "forum.topic", id: topic.id },
+ * idempotencyKey: `reply:${reply.id}`,
+ * });
+ * ```
+ */
async publish(
args: PublishNotificationArgs,
): Promise {
@@ -49,6 +73,12 @@ export class NotificationsModel {
);
}
+ /**
+ * Deletes inbox items about `subject` (optionally only one `type` or some
+ * `userIds`) and lowers unread counts in the same transaction.
+ *
+ * @returns How many items were deleted.
+ */
async remove({
subject,
type,
diff --git a/packages/vitnode/src/api/models/notifications/publish.ts b/packages/vitnode/src/api/models/notifications/publish.ts
index 0f2f03aa9d..1fdc214b90 100644
--- a/packages/vitnode/src/api/models/notifications/publish.ts
+++ b/packages/vitnode/src/api/models/notifications/publish.ts
@@ -19,14 +19,32 @@ import {
const MAX_EXPLICIT_RECIPIENTS = 100_000;
+/** What `c.get("notifications").publish()` takes. */
export interface PublishNotificationArgs {
+ /**
+ * Who caused it. Defaults to the request's admin or signed-in user; `null`
+ * for none. The actor is dropped from `recipients` unless `allowSelf`.
+ */
actorId?: null | number;
allowSelf?: boolean;
+ /** Validated against the type's `schema`. */
data: NoInfer;
+ /**
+ * One key per real-world event, 1-255 characters. Publishing the same type
+ * with the same key again returns `duplicate: true` instead of notifying
+ * twice.
+ */
idempotencyKey: string;
+ /**
+ * Candidate user ids, at most 100000. Core still drops deleted users,
+ * members who turned the type off and those `access` rejects.
+ */
recipients?: readonly number[];
+ /** What the notification is about; used by `remove()`, grouping and `subjectType`. */
subject?: NotificationSubject;
+ /** A transaction to publish in, so the event only exists if it commits. */
tx?: NotificationsDb;
+ /** The registered definition (preferred, for typed `data`) or its id. */
type: NotificationTypeDefinition | string;
}
@@ -35,6 +53,7 @@ export interface PublishNotificationResult {
eventId: number;
}
+/** Thrown by `publish()` for invalid input; see {@link PublishNotificationArgs}. */
export class NotificationPublishError extends Error {
constructor(message: string) {
super(`[Notifications] ${message}`);
diff --git a/packages/vitnode/src/api/models/queue.ts b/packages/vitnode/src/api/models/queue.ts
index 8e03b12778..2be85946cc 100644
--- a/packages/vitnode/src/api/models/queue.ts
+++ b/packages/vitnode/src/api/models/queue.ts
@@ -2,20 +2,43 @@ import type { Context } from "hono";
import { core_queue } from "@/database/queue";
+/** What to enqueue with `c.get("queue").dispatch()`. */
export interface QueueDispatchArgs {
+ /** Earliest time the task may run. Defaults to now. */
availableAt?: Date;
+ /**
+ * While a task with the same plugin id and `dedupeKey` is pending or
+ * processing, a new dispatch is dropped and reported as `deduplicated`.
+ */
dedupeKey?: string;
+ /** Overrides the handler's `maxAttempts`; falls back to 3. */
maxAttempts?: number;
+ /** The `name` of a task registered with `buildQueueTask`. */
name: string;
+ /** JSON-serializable data passed to the handler. Defaults to `{}`. */
payload?: Record;
+ /**
+ * Plugin that owns the handler. Defaults to the plugin of the current
+ * request's route, then `@vitnode/core`.
+ */
pluginId?: string;
+ /** Higher runs first. Defaults to 0. */
priority?: number;
+ /**
+ * Queue name. Defaults to `"default"`. The default worker drains every queue
+ * except `ai`, which core's AI cron drains on its own.
+ */
queue?: string;
+ /**
+ * A transaction to insert with, so the task is only enqueued if the
+ * surrounding write commits.
+ */
tx?: Omit;
}
+/** Server-side queue API, available as `c.get("queue")` in API handlers. */
export class QueueModel {
constructor(c: Context) {
this.c = c;
@@ -23,6 +46,13 @@ export class QueueModel {
protected readonly c: Context;
+ /**
+ * Enqueues a task. Does not run it - the queue worker picks it up on a
+ * later tick.
+ *
+ * @returns The new row's id, or `{ deduplicated: true, id: null }` when an
+ * active task with the same `dedupeKey` already exists.
+ */
async dispatch({
dedupeKey,
name,
diff --git a/packages/vitnode/src/api/models/search.ts b/packages/vitnode/src/api/models/search.ts
index 47a09b3394..f1a9f86c37 100644
--- a/packages/vitnode/src/api/models/search.ts
+++ b/packages/vitnode/src/api/models/search.ts
@@ -44,20 +44,37 @@ const SEARCH_TOP_AUTHORS_TTL_SECONDS = 5 * 60;
// bind-parameter cap while still turning a rebuild page into one round trip.
const BULK_UPSERT_CHUNK = 500;
+/**
+ * One searchable item (or one language of it), as an indexer or a write hands
+ * it to {@link SearchModel}.
+ */
export interface SearchDocument {
+ /** Users credited for the item, for author filters and author hits. */
authorIds?: readonly number[];
+ /** The forum, category or other container holding the item. */
containerId?: number;
containerType?: string;
+ /** Searchable body text. HTML is stripped before it is indexed. */
content: string;
createdAt: Date;
+ /** A `core_files` id shown as the hit's image. */
imageId?: null | number;
+ /**
+ * With `isPublic: false`, still lets its authors and viewers with moderator
+ * access to its item type find it. Hides nothing on its own. Defaults to
+ * `false`.
+ */
isHidden?: boolean;
+ /** Found by everyone. Defaults to `true`; `false` hides it from ordinary searches. */
isPublic?: boolean;
+ /** The item's id; with `itemType` (and `languageCode`) it identifies the document. */
itemId: ContentId;
+ /** The collection, e.g. a content type id. One indexer owns each item type. */
itemType: string;
// Locale of this projection. Multi-language content emits one document per
// language; single-language content may leave it empty.
languageCode?: string;
+ /** Extra data returned with the hit. */
metadata?: Record;
// The plugin that owns this item. Omit it and {@link SearchModel} falls back
// to the request's plugin - which is only right while the request *is* the
@@ -66,9 +83,11 @@ export interface SearchDocument {
pluginId?: string;
title: string;
updatedAt?: Date;
+ /** Where the hit links to. */
url?: string;
}
+/** A search query: filters, sorting and cursor pagination. */
export interface SearchQueryParams {
authorId?: number;
authorIds?: readonly number[];
@@ -77,7 +96,9 @@ export interface SearchQueryParams {
dateFrom?: Date;
dateTo?: Date;
first?: number;
+ /** Lets this viewer also find hidden documents they wrote or moderate. */
hiddenAccess?: SearchHiddenAccess;
+ /** Skips every visibility check - for admin and diagnostic reads only. */
includePrivate?: boolean;
itemTypes?: string[];
// Restrict results to one locale (the viewer's). Rows with an empty
@@ -209,12 +230,27 @@ export type SearchIndexerLoadResult =
LegacySearchIndexerPage | SearchIndexerPage;
+/**
+ * Rebuilds one item type's search documents from its source, page by page,
+ * when an admin reindexes it. Register it on a module's (or the plugin's)
+ * `searchIndexers`; live writes keep the index current in between with
+ * `c.get("search").index()` and `.delete()`.
+ *
+ * Content types with `search` enabled get one from the Content Engine; write
+ * one only for data of your own.
+ */
export interface SearchIndexer {
// Total number of source items available to index for this type. Powers the
// admin coverage report (indexed vs. total). Omit when the source count is
// unknown or expensive; coverage then falls back to the indexed count (100%).
count?: (c: Context) => Promise;
+ /** The collection this indexer owns. Two indexers for one item type fail at startup. */
itemType: string;
+ /**
+ * Reads up to `limit` source rows starting at `offset` and returns their
+ * documents, plus how many source rows it read; `itemsRead: 0` ends the
+ * rebuild. The rebuild clears the item type first.
+ */
load: (
c: Context,
offset: number,
@@ -375,6 +411,13 @@ const toRow = (doc: SearchDocument) => ({
indexedAt: new Date(),
});
+/**
+ * The search index, read in a route as `c.get("search")`.
+ *
+ * Every write goes to the canonical `core_search_index` table and is mirrored
+ * to the configured search adapter (Postgres by default), which answers
+ * queries.
+ */
export class SearchModel {
constructor(c: Context) {
this.c = c;
@@ -695,6 +738,7 @@ export class SearchModel {
});
}
+ /** {@link SearchModel.index} for many documents at once. */
async bulkIndex(docs: SearchDocument[]): Promise {
const clean = docs.map(doc =>
this.resolveOwner({ ...doc, content: stripHtml(doc.content) }),
@@ -704,6 +748,7 @@ export class SearchModel {
await this.provider().bulkIndex(this.c, clean);
}
+ /** Removes every document of one item type, or of all of them when omitted. */
async clear(itemType?: string): Promise {
await this.c
.get("db")
@@ -832,7 +877,10 @@ export class SearchModel {
);
}
- /** Canonical projection lives in `core_search_index`; the provider mirrors it. */
+ /**
+ * Adds or replaces one document. Canonical projection lives in
+ * `core_search_index`; the provider mirrors it.
+ */
async index(doc: SearchDocument): Promise {
const clean = this.resolveOwner({
...doc,
@@ -862,6 +910,7 @@ export class SearchModel {
return this.provider().capabilities?.canonicalStorage === true;
}
+ /** Whether the item has a public document in the index. */
async isListed(itemType: string, itemId: string): Promise {
const [row] = await this.c
.get("db")
@@ -916,6 +965,7 @@ export class SearchModel {
await recordSearchPopularity(this.c, { itemId, itemType, signal });
}
+ /** Runs a query on the search adapter and attaches each hit's authors and image. */
async search(params: SearchQueryParams): Promise {
const result = await this.provider().search(
this.c,
diff --git a/packages/vitnode/src/api/models/sso.ts b/packages/vitnode/src/api/models/sso.ts
index e8fbe090e6..f70156ac62 100644
--- a/packages/vitnode/src/api/models/sso.ts
+++ b/packages/vitnode/src/api/models/sso.ts
@@ -32,28 +32,52 @@ import {
} from "./sso-link-token";
import { UserModel } from "./user";
+/** The account an SSO provider returned, as an adapter's `fetchUser` maps it. */
export interface SSOProviderUser {
+ /** Profile picture URL, for profile sync. */
avatarUrl?: null | string;
+ /** The account's email. Matched against existing VitNode accounts on first sign-in. */
email: string;
firstName?: null | string;
+ /** The provider's stable account id, stored to recognise the account next time. */
id: string;
lastName?: null | string;
+ /** Becomes the new user's name (special characters removed) on sign-up. */
username: string;
}
+/**
+ * An SSO (OAuth) provider adapter, listed in the API config's
+ * `authorization.ssoAdapters`. Core's built-in adapters live in
+ * `@vitnode/core/api/adapters/sso/*`; a custom provider implements this shape.
+ *
+ * Sign-in flow: `getUrl` sends the browser to the provider, which redirects to
+ * {@link getRedirectUri} with a `code`; core verifies the `state` cookie, then
+ * calls `fetchToken` and `fetchUser`. An unknown account signs up, a known one
+ * signs in, and an email that matches an existing account asks for that
+ * account's password before linking. Server-only.
+ */
export interface SSOApiPlugin {
+ /** The sign-in button's background color, e.g. `"#5865f2"`; the text color is picked for contrast. */
brandColor?: string;
+ /** Exchanges the authorization code for an access token. Throw an `HTTPException` to fail the sign-in. */
fetchToken: (
code: string,
) => Promise<{ access_token: string; token_type: string }>;
+ /** Reads the provider account with the token. Throw an `HTTPException` (e.g. `400` for an unverified email) to refuse it. */
fetchUser: (args: {
access_token: string;
token_type: string;
}) => Promise;
+ /** The provider's authorization URL. Must pass `state` through unchanged and use {@link getRedirectUri} as the redirect URI. */
getUrl: (props: { state: string }) => string;
+ /** The button's icon, as SVG markup. */
icon?: string;
+ /** Unique provider id, used in URLs (`login/sso/{id}`) and to store linked accounts. */
id: string;
+ /** The provider's display name, e.g. `"Google"`. */
name: string;
+ /** Which profile fields `fetchUser` supplies, so users can sync them from this provider. Defaults to none. */
profileFields?: readonly SsoProfileField[];
}
@@ -66,6 +90,11 @@ export type SSOCallbackOutcome =
}
| { kind: "signed_in"; userId: number };
+/**
+ * The OAuth redirect URI for provider `code`: `login/sso/{code}` under
+ * `VITNODE_WEB_URL`. Register it with the provider, and send the same value
+ * from `getUrl` and `fetchToken`.
+ */
export const getRedirectUri = (code: string) =>
new URL(`${CONFIG.web.href}login/sso/${code}`).toString();
diff --git a/packages/vitnode/src/api/models/storage.ts b/packages/vitnode/src/api/models/storage.ts
index 4368720ac1..d964bdfa5a 100644
--- a/packages/vitnode/src/api/models/storage.ts
+++ b/packages/vitnode/src/api/models/storage.ts
@@ -41,23 +41,34 @@ const PROCESSABLE_IMAGE_MIME_TYPES = new Set([
"image/webp",
]);
+/** One object for a storage adapter to write. */
export interface StorageUploadArgs {
+ /** The file's bytes, after any image processing. */
body: Buffer;
+ /** The MIME type to store with the object, when known. */
contentType?: string;
+ /** The full object key, e.g. `2026/07/avatars/{uuid}.webp`, already unique. */
key: string;
}
+/** What a storage adapter's `upload` returns. */
export interface StorageUploadResult {
+ /** The key the object was stored under, normally the one it was given. */
key: string;
+ /** The object's public URL, usually `getUrl(key)`. */
url: string;
}
+/** An uploaded file, as {@link StorageModel.upload} recorded it. */
export interface StorageFileUploadResult extends StorageUploadResult {
+ /** Pixel size when `storage.image` processing read the image; `null` otherwise. */
dimensions: null | { height: number; width: number };
+ /** The `core_files` row id - what content file fields reference. */
id: number;
mimeType: null | string;
/** The display name as stored, which a format conversion may have changed. */
name: string;
+ /** Bytes stored, after any image processing. */
size: number;
}
@@ -65,6 +76,7 @@ export type { StorageFileInUseBody } from "@/lib/files/in-use";
export { STORAGE_FILE_IN_USE } from "@/lib/files/in-use";
export interface StorageDeleteFileOptions {
+ /** Also drop pins held by retained content revisions. A live reference still refuses. */
force?: boolean;
/** Scopes the delete to one user's own uploads. */
ownerId?: number;
@@ -79,29 +91,46 @@ const storageFileInUse = (body: Omit) =>
});
/**
- * Present only on disk-backed adapters (e.g. Local). The Node entry point reads
- * it to mount `serveStatic` for the stored files. Cloud adapters omit it.
+ * Present only on disk-backed adapters (e.g. Local). The app's server entry
+ * reads it to mount `serveStatic` for the stored files; core mounts nothing by
+ * itself. Cloud adapters omit it.
*/
export interface StorageStaticConfig {
+ /** The route pattern to mount, relative to the API app, e.g. `/uploads/*`. */
mountPath: string;
+ /** The folder the files are served from, e.g. `./public/uploads`. */
root: string;
+ /** Removed from the request path before looking the file up in `root`. */
stripPrefix: string;
}
+/**
+ * What a storage adapter implements (e.g. `@vitnode/s3`,
+ * `@vitnode/supabase-storage`); set it as the API config's `storage.adapter`.
+ */
export interface StorageApiPlugin {
+ /** Removes a stored object. Core also calls it to clean up an upload whose `core_files` row could not be written. */
delete: (key: string) => Promise;
+ /** The public URL of a stored object. */
getUrl: (key: string) => string;
+ /**
+ * Reads an object's bytes, or `null` when missing. Optional: without it,
+ * reads fetch `getUrl`.
+ */
read?: (
key: string,
options: { maxBytes: number },
) => Promise;
+ /** How to serve stored files from disk; only for disk-backed adapters. */
static?: StorageStaticConfig;
+ /** Writes one object under `args.key` and returns its key and public URL. */
upload: (args: StorageUploadArgs) => Promise;
}
export interface StorageUploadOptions {
/** Allowed MIME types (e.g. `["image/png", "image/jpeg"]`). Omit to allow any. */
allowedMimeTypes?: string[];
+ /** The uploaded file, e.g. from `c.req.valid("form")`. */
file: File;
/** Sub-folder under the dated prefix, e.g. `avatars` -> `2026/07/avatars/…`. */
folder: string;
@@ -110,6 +139,7 @@ export interface StorageUploadOptions {
/** Extra data stored in the `core_files.metadata` JSON column. */
metadata?: Record;
+ /** The owner recorded on the file. Defaults to the signed-in admin or user; `null` for none. */
userId?: null | number;
}
@@ -243,6 +273,13 @@ const imageEncodeFailure = ({
});
};
+/**
+ * Stores files through the configured storage adapter and records each one in
+ * `core_files`, read in a route as `c.get("storage")`.
+ *
+ * Every method except `deleteFile` throws `HTTPException(500)` when no
+ * storage adapter is configured.
+ */
export class StorageModel {
constructor(c: Context) {
this.c = c;
@@ -374,6 +411,10 @@ export class StorageModel {
return provider;
}
+ /**
+ * Deletes the stored object only, leaving any `core_files` row. To remove an
+ * uploaded file, use {@link StorageModel.deleteFile}.
+ */
async delete(key: string): Promise {
await this.requireProvider().delete(key);
}
@@ -478,10 +519,12 @@ export class StorageModel {
await releaseUserImageHolders(this.c, deleted.holders);
}
+ /** The public URL of a stored object. */
getUrl(key: string): string {
return this.requireProvider().getUrl(key);
}
+ /** An object's bytes, or `null` when it is missing, unreadable or larger than `maxBytes`. */
async readBytes(key: string, maxBytes: number): Promise {
const provider = this.requireProvider();
if (provider.read) {
@@ -517,6 +560,20 @@ export class StorageModel {
return Buffer.concat(chunks);
}
+ /**
+ * Validates, stores and records one uploaded file, owned by the current
+ * plugin and user.
+ *
+ * When the API config sets `storage.image`, JPEG, PNG, WebP, AVIF and TIFF
+ * images are re-encoded at its quality and, by default, converted to WebP;
+ * the key and name follow any new extension. SVG, GIF and other files are
+ * stored as they are. An AI alt-text job is queued for images when that
+ * feature is on.
+ *
+ * @throws `HTTPException(400)` when the file is over `maxBytes`, not an
+ * allowed type, or an image that does not match its declared type, cannot be
+ * decoded or exceeds `storage.image.maxPixels`.
+ */
async upload({
allowedMimeTypes,
file,
diff --git a/packages/vitnode/src/blocks/area.ts b/packages/vitnode/src/blocks/area.ts
index 4b6fda2e9b..437f8d9015 100644
--- a/packages/vitnode/src/blocks/area.ts
+++ b/packages/vitnode/src/blocks/area.ts
@@ -95,6 +95,7 @@ export const isAreaLike = (value: unknown): boolean =>
!Array.isArray(value) &&
(value as Record).kind === CONTENT_AREA_KIND;
+/** Whether a stored zone node is a layout Area (`kind: "area"` with an id, a valid layout and a children array) rather than a widget. */
export const isBlockAreaInstance = (
value: unknown,
): value is BlockAreaInstance => {
@@ -237,6 +238,10 @@ export const contentNodeKey = (node: unknown, index: number): string => {
export const areaChildren = (node: ContentNode): readonly AnyBlockInstance[] =>
isBlockAreaInstance(node) ? node.children : [];
+/**
+ * Every widget in a zone's nodes, with each Area replaced by its children -
+ * for counting, indexing or checking widgets regardless of layout.
+ */
export const contentNodeBlocks = (
nodes: readonly ContentNode[],
): readonly AnyBlockInstance[] =>
diff --git a/packages/vitnode/src/blocks/built-in/index.tsx b/packages/vitnode/src/blocks/built-in/index.tsx
index af43812831..4d5fa51ac0 100644
--- a/packages/vitnode/src/blocks/built-in/index.tsx
+++ b/packages/vitnode/src/blocks/built-in/index.tsx
@@ -10,10 +10,15 @@ export { ctaBlock } from "./cta";
export { heroBlock } from "./hero";
export { textBlock } from "./text";
+/**
+ * Core's built-in widgets - Call to action, Hero and Text - under the `core`
+ * namespace (`core:hero`, ...). Core registers them itself.
+ */
export const blocks = {
pluginId: manifest.id,
blocks: [ctaBlock, heroBlock, textBlock],
namespace: "core",
} satisfies BlockPluginSource;
+/** Alias of {@link blocks}. */
export const widgets = blocks;
diff --git a/packages/vitnode/src/blocks/define.ts b/packages/vitnode/src/blocks/define.ts
index 5151631840..6463065208 100644
--- a/packages/vitnode/src/blocks/define.ts
+++ b/packages/vitnode/src/blocks/define.ts
@@ -13,18 +13,38 @@ import { BLOCK_VARIANT_ID_MAX_LENGTH, BLOCK_VARIANT_PATTERN } from "./const";
import { BlockError } from "./errors";
import { assertBlockName } from "./namespace";
+/** What {@link defineBlock} (exported as `defineWidget`) takes. */
export interface DefineBlockArgs<
TId extends string,
TFields extends BlockFieldMap,
TVariant extends string = string,
> {
+ /** Renders one instance; `data` is as stored - validated on write and shape-checked before render. */
component: BlockComponent, TVariant>;
+ /** Variant used when an instance stores none. Must be one of `variants`. */
defaultVariant?: NoInfer;
+ /** Shown in the Visual Editor's catalogue. */
description?: string;
+ /**
+ * Content Engine fields (`field.text()`, `field.enum()`, ...) the data is
+ * validated and edited with. Only boolean, dateTime, enum, group, number,
+ * text and textarea fields are accepted, at least one, and fields are never
+ * localized.
+ */
fields: TFields;
+ /** Icon component (not an element) for the editor; defaults to a generic widget icon. */
icon?: BlockIcon;
+ /**
+ * Name without a namespace, e.g. `"callout"`. The registry prefixes the
+ * plugin's namespace, so it is stored as `example:callout`.
+ */
id: TId;
+ /** Display name in the editor. */
name?: string;
+ /**
+ * Alternative looks for the same data. The chosen id is stored beside the
+ * instance's data, and the editor shows a picker for it.
+ */
variants?: readonly BlockVariantDefinition[];
}
@@ -83,6 +103,39 @@ const assertBlockVariants = (
}
};
+/**
+ * Declares a widget: an id, Content Engine fields and one React component. The
+ * data type is inferred from `fields` (`WidgetData`).
+ *
+ * Universal and validation-free at render time - the data schema is derived
+ * on demand by `parseWidgetData` / `widgetDataSchema`. List the definition in
+ * the plugin's widgets source and pass that to `buildApiPlugin({ blocks })`.
+ *
+ * @throws {BlockError} For an invalid or namespaced `id`, an unsupported
+ * field kind, invalid or duplicate variant ids, an empty `variants`, or a
+ * `defaultVariant` that is not declared.
+ *
+ * @example
+ * ```tsx
+ * import { field } from "@vitnode/core/content";
+ * import { defineWidget, type WidgetComponentProps, type WidgetData } from "@vitnode/core/widgets";
+ *
+ * const calloutFields = {
+ * title: field.text({ required: true, maxLength: 120 }),
+ * };
+ *
+ * const Callout = ({ data }: WidgetComponentProps>) => (
+ *
+ * );
+ *
+ * export const calloutWidget = defineWidget({
+ * id: "callout",
+ * name: "Callout",
+ * fields: calloutFields,
+ * component: Callout,
+ * });
+ * ```
+ */
export const defineBlock = <
const TFields extends BlockFieldMap,
TId extends string = string,
diff --git a/packages/vitnode/src/blocks/edit.tsx b/packages/vitnode/src/blocks/edit.tsx
index a53ebe0069..9ca2b77a52 100644
--- a/packages/vitnode/src/blocks/edit.tsx
+++ b/packages/vitnode/src/blocks/edit.tsx
@@ -24,9 +24,16 @@ import { BlockError } from "./errors";
const EditorRoot = lazy(async () => await import("../editor/root"));
export interface ContentEditorRuntimeProps {
+ /** Where **Save** sends the zones. Without one, Save shows an error toast and saves nothing. */
adapter?: VisualEditorAdapter;
+ /** The page; every `ContentZone` inside it becomes editable while `enabled`. */
children: ReactNode;
+ /**
+ * `false` renders only `children` (after a short closing transition); `true`
+ * lazy-loads and mounts the editor.
+ */
enabled: boolean;
+ /** Called when the editor is closed (**Finish editing**), after its unsaved-changes prompt. */
onExit?: () => void;
}
@@ -37,6 +44,14 @@ const duplicateContentZone = (id: string): BlockError =>
`Two \`\` outlets are on the page at the same time. A zone id names one place on one page, and the editor stores, moves and saves blocks by it - with two of them it cannot tell which one a block belongs to, and saving would write one over the other. Give each zone its own id, or render only one of them.`,
);
+/**
+ * The low-level Visual Editor host: wraps a page and, while `enabled`, turns
+ * its `ContentZone`s into editable outlets. For a host that owns edit mode and
+ * persistence itself; a declared editable page uses `EditablePage` instead.
+ *
+ * @throws {BlockError} When two `ContentZone`s with the same id are mounted
+ * at once while editing.
+ */
export const ContentEditorRuntime = ({
adapter,
children,
diff --git a/packages/vitnode/src/blocks/instance.ts b/packages/vitnode/src/blocks/instance.ts
index 99617aa5af..6ac70d9d52 100644
--- a/packages/vitnode/src/blocks/instance.ts
+++ b/packages/vitnode/src/blocks/instance.ts
@@ -37,6 +37,7 @@ const encodeTime = (time: number): string => {
return out;
};
+/** A new time-sortable, ULID-style instance id. */
export const createBlockInstanceId = (now = Date.now()): string => {
const bytes = randomBytes(RANDOM_LENGTH);
let random = "";
@@ -49,6 +50,7 @@ export const createBlockInstanceId = (now = Date.now()): string => {
export const isBlockInstanceId = (value: unknown): value is string =>
typeof value === "string" && BLOCK_INSTANCE_ID_PATTERN.test(value);
+/** A new instance with a fresh id. Pass the qualified `type`, e.g. `"example:callout"`. */
export const createBlockInstance = (
type: TType,
data: TData,
diff --git a/packages/vitnode/src/blocks/page.tsx b/packages/vitnode/src/blocks/page.tsx
index fe5ac4f8df..2581f334a8 100644
--- a/packages/vitnode/src/blocks/page.tsx
+++ b/packages/vitnode/src/blocks/page.tsx
@@ -25,15 +25,31 @@ import { EditablePageContext } from "./page-context";
import { adoptedZones, layoutKey, layoutZones } from "./page-layout";
export interface EditablePageProps {
+ /** Where **Save** goes, e.g. one from `createContentEditorAdapter`. */
adapter?: VisualEditorAdapter;
+ /**
+ * Mounts the editor runtime and offers **Edit widgets** in the user menu.
+ * UI only - the save route checks the page's permission again.
+ */
canEdit?: boolean;
children: ReactNode;
+ /** The effective layout from the page-layout API; zones it lacks fall back to the declared defaults. */
layout?: EditablePageLayoutPayload | null;
+ /** Called by **Finish editing**, after the unsaved-changes prompt. */
onExit?: () => void;
+ /** Open in edit mode on arrival (`?edit=true`). Ignored unless `canEdit`. */
openEditing?: boolean;
+ /** The page from `defineEditablePage`. */
page: AnyEditablePageDefinition;
}
+/**
+ * Wraps the `ContentZone`s of a page declared with `defineEditablePage`, so
+ * they render the stored layout and can be rearranged in the Visual Editor.
+ * After a save it adopts the canonical zones the server returned.
+ *
+ * @throws {ContentEngineError} When `layout` belongs to a different page id.
+ */
export const EditablePage = ({
adapter,
canEdit,
diff --git a/packages/vitnode/src/blocks/registry.ts b/packages/vitnode/src/blocks/registry.ts
index b060a313c2..bd53200d80 100644
--- a/packages/vitnode/src/blocks/registry.ts
+++ b/packages/vitnode/src/blocks/registry.ts
@@ -18,6 +18,14 @@ import {
const describe = (entry: RegisteredBlock): string =>
`${entry.pluginId} -> ${entry.type}`;
+/**
+ * Builds a registry from every plugin's widgets, prefixing each id with its
+ * namespace. The app's generated `src/blocks.gen.ts` calls this and installs
+ * the result as the default.
+ *
+ * @throws {BlockError} When two plugins claim one namespace, one plugin uses
+ * two, or a qualified id is registered twice.
+ */
export const createBlockRegistry = (
sources: readonly BlockPluginSource[],
): BlockRegistry => {
@@ -85,6 +93,7 @@ export const createBlockRegistry = (
};
};
+/** Whether a qualified widget id matches an allowlist. */
export const isBlockAllowed = (
allowed: BlockAllowedSpec,
type: string,
@@ -116,6 +125,11 @@ export const allowedBlocks = (
let processDefault: BlockRegistry | undefined;
+/**
+ * Installs the process-wide registry used when none is passed explicitly.
+ *
+ * @returns A function restoring the previous default.
+ */
export const setDefaultBlockRegistry = (
registry: BlockRegistry | undefined,
): (() => void) => {
@@ -130,6 +144,11 @@ export const setDefaultBlockRegistry = (
export const getDefaultBlockRegistry = (): BlockRegistry | undefined =>
processDefault;
+/**
+ * The given registry, else the process default.
+ *
+ * @throws {BlockRegistryMissingError} When neither exists.
+ */
export const resolveBlockRegistry = (
registry?: BlockRegistry,
): BlockRegistry => {
diff --git a/packages/vitnode/src/blocks/renderer.tsx b/packages/vitnode/src/blocks/renderer.tsx
index 0214578821..69a41aff63 100644
--- a/packages/vitnode/src/blocks/renderer.tsx
+++ b/packages/vitnode/src/blocks/renderer.tsx
@@ -243,13 +243,34 @@ const renderNode = ({
};
export interface ContentRendererProps {
+ /** The zone's allowlist. A stored widget it does not allow is skipped, but only when `validate` diagnoses. */
allowed?: BlockAllowedSpec;
+ /** The zone's stored nodes (widgets and Areas). Nothing renders when empty or missing. */
blocks?: null | readonly unknown[] | undefined;
+ /**
+ * Renders a skipped widget (unknown type or variant, mismatched data, not
+ * allowed). Defaults to a notice in development and nothing in production.
+ */
fallback?: BlockRenderFallback;
+ /** The widget registry to look types up in. Defaults to the process-wide registry. */
registry?: BlockRegistry;
+ /**
+ * Whether the zone allowlist is enforced and data mismatches are logged
+ * (`"always"`, `"development"`, `"never"`). Defaults to `"development"`.
+ * Other warnings log in development regardless, and nothing logs in
+ * production.
+ */
validate?: BlockValidationMode;
}
+/**
+ * Renders a zone's stored widgets and layout Areas read-only, looking each
+ * widget up in the registry. Universal; data is not re-validated here - only a
+ * structural check against the widget's current fields runs before render.
+ *
+ * @throws {BlockRegistryMissingError} When no `registry` is passed and no
+ * process-wide registry is installed.
+ */
export const ContentRenderer = ({
allowed,
blocks,
diff --git a/packages/vitnode/src/blocks/schema.ts b/packages/vitnode/src/blocks/schema.ts
index 7a25b58e70..d0cddb169d 100644
--- a/packages/vitnode/src/blocks/schema.ts
+++ b/packages/vitnode/src/blocks/schema.ts
@@ -12,6 +12,7 @@ export const buildBlockDataSchema = (
fields: ContentFieldMap,
): z.ZodObject => contentFieldValuesObject(fields);
+/** The Zod object for a widget's data, built from its fields once and cached per definition. */
export const blockDataSchema = (
definition: AnyBlockDefinition,
): z.ZodObject => {
@@ -39,6 +40,12 @@ export const safeParseBlockData = (
): z.ZodSafeParseResult =>
blockDataSchema(definition).safeParse(value);
+/**
+ * Validates widget data and applies defaults - for seeds, migrations and
+ * importers; the Content Engine already does this on every write.
+ *
+ * @throws {BlockError} Naming the failing fields.
+ */
export const parseBlockData = (
definition: BlockDefinition,
value: unknown,
diff --git a/packages/vitnode/src/blocks/types.ts b/packages/vitnode/src/blocks/types.ts
index 72e9daaf71..ef9162e4ee 100644
--- a/packages/vitnode/src/blocks/types.ts
+++ b/packages/vitnode/src/blocks/types.ts
@@ -11,26 +11,34 @@ import type {
export type BlockFieldMap = Record;
+/** A widget's data type, inferred from its field map. */
export type BlockData = ContentValuesOf;
export type BlockUnknownData = Record;
export interface BlockVariantDefinition {
description?: string;
+ /** Lowercase letters, digits and single hyphens. Stored in every instance that picks it, so keep it stable. */
id: TId;
label?: string;
}
export type AnyBlockVariantDefinition = BlockVariantDefinition;
+/** Props of a widget's component. */
export interface BlockComponentProps<
TData = BlockUnknownData,
TVariant extends string = string,
> {
+ /** The instance's own stable id, not the widget's type. */
blockId: string;
+ /** The stored data: validated when saved, shape-checked before render. Defaults are applied at write time only. */
data: TData;
+ /** Position among its siblings. */
index: number;
+ /** The qualified widget id, e.g. `"example:callout"`. */
type: string;
+ /** The look this instance picked, resolved against the declared variants; `undefined` for a widget with none. */
variant?: TVariant;
}
@@ -46,18 +54,27 @@ export interface BlockDefinition<
TFields extends BlockFieldMap = ContentFieldMap,
TVariant extends string = string,
> {
+ /** Renders one instance. */
component: BlockComponent;
+ /** Variant used when an instance stores none. */
defaultVariant?: TVariant;
+ /** Shown in the Visual Editor's catalogue. */
description?: string;
+ /** Content Engine fields the data is validated and edited with. */
fields: TFields;
+ /** Icon component for the editor; a generic widget icon when missing. */
icon?: BlockIcon;
+ /** Name without a namespace; the registry stores it as `{namespace}:{id}`. */
id: TId;
+ /** Display name in the editor. */
name?: string;
+ /** Alternative looks for the same data. */
variants?: readonly BlockVariantDefinition[];
}
export type AnyBlockDefinition = BlockDefinition;
+/** One stored widget: `{ id, type, data, variant? }`. Create one with `createWidgetInstance`. */
export interface BlockInstance<
TType extends string = string,
TData = BlockUnknownData,
@@ -102,6 +119,7 @@ export interface BlockAreaStoredLayout extends Omit<
marginY?: AreaStoredSpacing;
}
+/** A layout area grouping widgets in a grid, stored next to widget instances. */
export interface BlockAreaInstance {
children: readonly AnyBlockInstance[];
id: string;
@@ -113,11 +131,26 @@ export type ContentNode = AnyBlockInstance | BlockAreaInstance;
export type BlockAllowedEntry = string;
+/**
+ * Which widgets a zone accepts: `"*"` for all, or a list of qualified ids
+ * (`"blog:latest-posts"`) and namespace wildcards (`"blog:*"`).
+ */
export type BlockAllowedSpec = "*" | readonly BlockAllowedEntry[];
+/**
+ * A plugin's widgets, passed to `buildApiPlugin({ blocks })` and collected
+ * into the app's generated registry. Export it from a module of its own so
+ * the API and the browser register the same object.
+ */
export interface BlockPluginSource {
+ /** The plugin's widget definitions, from `defineWidget`. */
blocks?: readonly AnyBlockDefinition[];
+ /**
+ * Overrides the id prefix, which defaults to the last segment of `pluginId`
+ * (`@vitnode/blog` -> `blog`). Only needed when two plugins' names end the same.
+ */
namespace?: string;
+ /** The plugin's id (`manifest.id`); must match the plugin passing it to `buildApiPlugin`. */
pluginId: string;
}
@@ -128,6 +161,7 @@ export interface RegisteredBlock {
type: string;
}
+/** Every installed widget by qualified id. Built by `createWidgetRegistry`. */
export interface BlockRegistry {
all: () => readonly RegisteredBlock[];
byNamespace: (namespace: string) => readonly RegisteredBlock[];
@@ -147,6 +181,11 @@ export interface BlockRenderFallbackProps {
reason: BlockRenderFallbackReason;
}
+/**
+ * Whether rendering enforces a zone's allowlist and logs warnings. A data
+ * shape check and skipping unknown types or variants always apply; full
+ * validation happens on write.
+ */
export type BlockValidationMode = "always" | "development" | "never";
export type BlockRenderFallback = (
diff --git a/packages/vitnode/src/blocks/variant.ts b/packages/vitnode/src/blocks/variant.ts
index 2849da96b2..a74a06f698 100644
--- a/packages/vitnode/src/blocks/variant.ts
+++ b/packages/vitnode/src/blocks/variant.ts
@@ -23,6 +23,11 @@ export const findBlockVariant = (
): AnyBlockVariantDefinition | undefined =>
blockVariants(definition).find(entry => entry.id === variant);
+/**
+ * Resolves an instance's stored variant against a widget definition: no
+ * variant gives the definition's `defaultVariant` (possibly `undefined`), a
+ * declared one is kept, and an undeclared one comes back as `kind: "unknown"`.
+ */
export const resolveBlockVariant = (
definition: AnyBlockDefinition,
variant: string | undefined,
diff --git a/packages/vitnode/src/blocks/zone-meta.ts b/packages/vitnode/src/blocks/zone-meta.ts
index 5774a74901..0fe013d977 100644
--- a/packages/vitnode/src/blocks/zone-meta.ts
+++ b/packages/vitnode/src/blocks/zone-meta.ts
@@ -18,11 +18,21 @@ import {
import { BlockError } from "./errors";
import { parseBlockId } from "./namespace";
+/**
+ * Whether a value is a usable zone id: `:`-separated segments of lowercase
+ * letters, digits and single hyphens, at most 64 characters (`"main"`,
+ * `"settings:before-profile"`).
+ */
export const isContentZoneId = (value: unknown): value is string =>
typeof value === "string" &&
value.length <= CONTENT_ZONE_ID_MAX_LENGTH &&
CONTENT_ZONE_ID_PATTERN.test(value);
+/**
+ * Returns `id` when it is a usable zone id.
+ *
+ * @throws {BlockError} When it is not (see {@link isContentZoneId}).
+ */
export const assertContentZoneId = (id: string): string => {
if (isContentZoneId(id)) return id;
@@ -31,6 +41,11 @@ export const assertContentZoneId = (id: string): string => {
);
};
+/**
+ * Splits a zone id at its last `:` into `scope` and `name` (`"settings:before-profile"`
+ * gives scope `settings`, name `before-profile`; an id without `:` has no
+ * scope). `null` for an invalid id.
+ */
export const parseContentZoneId = (id: string): null | ParsedContentZoneId => {
if (!isContentZoneId(id)) return null;
@@ -43,6 +58,13 @@ export const parseContentZoneId = (id: string): null | ParsedContentZoneId => {
export const formatBlockAllowed = (allowed: BlockAllowedSpec): string =>
allowed === BLOCK_WILDCARD ? BLOCK_WILDCARD : allowed.join(",");
+/**
+ * The DOM attributes that mark an element as a zone: `data-vitnode-zone`
+ * with the id, plus `data-vitnode-zone-allowed` (comma-separated, or `*`)
+ * when `allowedBlocks` is given.
+ *
+ * @throws {BlockError} When `id` is not a usable zone id.
+ */
export const contentZoneAttributes = ({
allowedBlocks,
id,
diff --git a/packages/vitnode/src/blocks/zone.tsx b/packages/vitnode/src/blocks/zone.tsx
index 254883645f..b0db5c5c44 100644
--- a/packages/vitnode/src/blocks/zone.tsx
+++ b/packages/vitnode/src/blocks/zone.tsx
@@ -21,18 +21,50 @@ import {
import { ContentZoneOutlet } from "./zone-outlet";
export interface ContentZoneProps {
+ /** What may be placed here. Under an editable page it may only narrow what the page allows. */
allowedBlocks?: BlockAllowedSpec;
+ /** HTML element to wrap the widgets in; without `as` or `className` nothing is added. */
as?: keyof JSX.IntrinsicElements;
+ /**
+ * The instances to render; `null` and `[]` render nothing. Left out under an
+ * `EditablePage`, the page's stored layout for this zone is used.
+ */
blocks?: null | readonly unknown[];
+ /** Class for the wrapper; implies `as="div"`. */
className?: string;
+ /** What to render for a widget that is skipped; defaults to a development-only notice. */
fallback?: BlockRenderFallback;
+ /**
+ * Stable name of this place on the page: `:`-separated segments of
+ * lowercase letters, digits and single hyphens, up to 64 characters, e.g.
+ * `"settings:before-profile"`. Saved layouts address zones by it.
+ */
id: string;
+ /** Editor bounds on the number of widgets; may only tighten an editable page's own. */
max?: number;
min?: number;
+ /** Defaults to the process default registry installed by `src/blocks.gen.ts`. */
registry?: BlockRegistry;
+ /** Defaults to `"development"`. */
validate?: BlockValidationMode;
}
+/**
+ * A named place on a page where widgets render. Renders the instances it is
+ * given through `ContentRenderer`, makes no request, and adds no element
+ * unless `as` or `className` is set. In edit mode it registers itself with the
+ * Visual Editor instead.
+ *
+ * @throws {BlockError} On an invalid `id`, or when two zones with one id are
+ * mounted at the same time in edit mode.
+ *
+ * @example
+ * ```tsx
+ * import { ContentZone } from "@vitnode/core/widgets/zone";
+ *
+ * ;
+ * ```
+ */
export const ContentZone = ({
allowedBlocks,
as,
diff --git a/packages/vitnode/src/components/ai/use-available-ai-actions.ts b/packages/vitnode/src/components/ai/use-available-ai-actions.ts
index db2b3832a1..9f615816ab 100644
--- a/packages/vitnode/src/components/ai/use-available-ai-actions.ts
+++ b/packages/vitnode/src/components/ai/use-available-ai-actions.ts
@@ -5,6 +5,12 @@ import type { AiAssistScope } from "@/lib/ai/stream-client";
import { manifest } from "@/manifest";
import { fetcher } from "@/tanstack/fetcher";
+/**
+ * The AI action keys the current user may run, as a TanStack Query result
+ * (`data` is a string array; `[]` on an error response). Use it to hide AI
+ * buttons nobody can use. `scope` picks the endpoint: `admin` (the default)
+ * asks the AdminCP route for staff, `user` the public one. Cached for 5 minutes.
+ */
export const useAvailableAiActions = (scope: AiAssistScope = "admin") =>
useQuery({
queryFn: async () => {
diff --git a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
index d9a94a48c3..c40b94ee18 100644
--- a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
+++ b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.tsx
@@ -13,6 +13,27 @@ import {
} from "../ui/alert-dialog";
import { ContentConfirmAction } from "./content";
+/**
+ * Asks for confirmation before a destructive action, such as a delete. The
+ * `children` element opens it; omit it to control the dialog with `open` and
+ * `onOpenChange`.
+ *
+ * `onSubmit` runs as a form action, with the confirm button spinning until it
+ * settles; call its `onClose` to close the dialog after success. `title` and
+ * `description` default to a generic "Are you sure?" text.
+ *
+ * @example
+ * ```tsx
+ * {
+ * await deletePost(id);
+ * onClose();
+ * }}
+ * >
+ *
+ *
+ * ```
+ */
export const ConfirmActionAlertDialog = ({
children,
title,
diff --git a/packages/vitnode/src/components/confirm-action/content.tsx b/packages/vitnode/src/components/confirm-action/content.tsx
index 48b60d1014..2eca01aed4 100644
--- a/packages/vitnode/src/components/confirm-action/content.tsx
+++ b/packages/vitnode/src/components/confirm-action/content.tsx
@@ -13,8 +13,11 @@ export const ContentConfirmAction = ({
submitVariant = "destructive",
textSubmit,
}: {
+ /** Runs on confirm; call `onClose` to close the dialog. */
onSubmit: (props: { onClose: () => void }) => Promise | void;
+ /** Defaults to `destructive`. */
submitVariant?: React.ComponentProps["variant"];
+ /** The confirm button's label. Defaults to a translated "Yes, confirm". */
textSubmit?: string;
}) => {
const t = useTranslations("core.global.confirm_action");
diff --git a/packages/vitnode/src/components/date-format.tsx b/packages/vitnode/src/components/date-format.tsx
index 497707f4f7..c7ff684132 100644
--- a/packages/vitnode/src/components/date-format.tsx
+++ b/packages/vitnode/src/components/date-format.tsx
@@ -2,13 +2,24 @@ import { useFormatter, useNow } from "use-intl";
import { TooltipWithContent } from "./ui/tooltip";
+/**
+ * A date in the visitor's language: relative ("5 minutes ago", with the full
+ * date in a tooltip) when it is less than a week old, the full date and time
+ * otherwise. The year is left out for dates in the current year.
+ */
export const DateFormat = ({
date,
updateInterval,
showFullDate,
}: {
+ /** A `Date` or anything `new Date()` parses, such as an ISO string from the API. */
date: Date | string;
+ /** Always shows the full date and time. */
showFullDate?: boolean;
+ /**
+ * How often a relative date re-renders, in ms. Defaults to every minute for
+ * dates less than a day old and never otherwise.
+ */
updateInterval?: number;
}) => {
const dateToFormat = typeof date === "string" ? new Date(date) : date;
diff --git a/packages/vitnode/src/components/editor-provider.tsx b/packages/vitnode/src/components/editor-provider.tsx
index 6596ea023f..8595b4f358 100644
--- a/packages/vitnode/src/components/editor-provider.tsx
+++ b/packages/vitnode/src/components/editor-provider.tsx
@@ -15,12 +15,15 @@ export interface EditorEmojiSection {
label: string;
}
+/** Editor settings, from the `editor` option of the app's `buildConfig`. */
export interface VitNodeEditorConfig {
+ /** Custom emoji sections in the picker. Empty sections are dropped. */
emojis?: EditorEmojiSection[];
}
const EditorConfigContext = React.createContext({});
+/** Provides the app's {@link VitNodeEditorConfig} to every editor below it. */
export const EditorConfigProvider = ({
children,
config = {},
@@ -36,5 +39,6 @@ export const EditorConfigProvider = ({
return {children};
};
+/** The editor config from the nearest {@link EditorConfigProvider}; `{}` without one. */
export const useEditorConfig = (): VitNodeEditorConfig =>
React.use(EditorConfigContext);
diff --git a/packages/vitnode/src/components/form/auto-form-sheet-footer.tsx b/packages/vitnode/src/components/form/auto-form-sheet-footer.tsx
index 00936547fd..e2cd27e683 100644
--- a/packages/vitnode/src/components/form/auto-form-sheet-footer.tsx
+++ b/packages/vitnode/src/components/form/auto-form-sheet-footer.tsx
@@ -5,10 +5,38 @@ import { Button } from "../ui/button";
import { useFormApi } from "../ui/form";
import { SheetClose } from "../ui/sheet";
+/**
+ * The footer of an `AutoForm` in a `Sheet`: an "unsaved changes" note, Cancel
+ * (closes the sheet) and Submit. Render it from the form's `layout`, below a
+ * scrollable body.
+ *
+ * Submit stays disabled until a value differs from its default, and shows a
+ * spinner while submitting. Closing the sheet after a successful save is the
+ * caller's job.
+ *
+ * @example
+ * ```tsx
+ * (
+ * <>
+ *
+ * {rendered.title}
+ *
+ *
+ * >
+ * )}
+ * />
+ * ```
+ */
export const AutoFormSheetFooter = ({
disabled = false,
submitLabel,
}: {
+ /** Disables Submit even with unsaved changes. Defaults to `false`. */
disabled?: boolean;
submitLabel: string;
}) => {
diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx
index 08440bafc8..d80958b60a 100644
--- a/packages/vitnode/src/components/form/auto-form.tsx
+++ b/packages/vitnode/src/components/form/auto-form.tsx
@@ -42,17 +42,36 @@ export type { AnyFormFieldApi, FormFieldApi } from "../ui/form";
export { setFormFieldError } from "../ui/form";
interface ItemAutoFormSharedProps> {
+ /**
+ * Nested fields, rendered and passed to this field's component as
+ * `children` - e.g. extra inputs inside a group.
+ */
children?: ItemAutoFormProps[];
+ /** Hides the field while it returns `true`; re-evaluated on every change. */
hidden?: (values: z.input) => boolean;
+ /** The `value` of the {@link AutoFormTab} the field shows in. Defaults to the first tab. */
tab?: string;
}
+/**
+ * One entry of `AutoForm`'s `fields`. Either a field rendered by `component`,
+ * or - without a component - a heading with a `label` and `description`.
+ *
+ * Either way, `id` must name a key of `formSchema` (a dotted path for a nested
+ * object, e.g. `seo.title`); an id the schema does not have renders nothing.
+ */
type ItemAutoFormProps<
T extends z.ZodObject = z.ZodObject,
> = ItemAutoFormSharedProps &
(
| {
+ /**
+ * Renders the field, usually one of the `AutoForm*` field components
+ * from `@vitnode/core/components/form/fields/*` with the props spread
+ * in and a `label` added.
+ */
component: (props: ItemAutoFormComponentProps) => React.ReactNode;
+ /** The schema key the field edits. */
id: string;
}
| {
@@ -63,19 +82,35 @@ type ItemAutoFormProps<
}
);
+/** One tab of a tabbed `AutoForm`. Fields pick a tab with their `tab`. */
export interface AutoFormTab {
label: React.ReactNode;
value: string;
}
+/**
+ * What `AutoForm` passes to a field's `component`. Spread it into an
+ * `AutoForm*` field component, or build a custom field on `field`.
+ */
export interface ItemAutoFormComponentProps {
+ /** The rendered nested fields declared in the item's `children`. */
children?: React.ReactNode;
+ /**
+ * Help text under the field. `AutoForm` fills it from the schema's
+ * description (`.describe()`); a component may override it.
+ */
description?: React.ReactNode;
+ /** The field's value and change/blur handlers, bound to the form. */
field: AnyFormFieldApi;
+ /** Constraints of an array field's items, read from the schema. */
itemParams?: InputParams;
+ /** Not filled by `AutoForm`; pass it to the field component yourself. */
label?: React.ReactNode;
+ /** Content shown at the right end of the label row. */
labelRight?: React.ReactNode;
+ /** Edit one value per enabled language. Supported by `AutoFormInput`, `AutoFormTextarea` and `AutoFormEditor`. */
multiLang?: boolean;
+ /** Constraints read from the field's Zod schema, plus the field's `aria-invalid` state. */
otherProps: {
["aria-invalid"]?: boolean;
clearsToEmpty?: boolean;
@@ -95,6 +130,10 @@ type AutoFormValidator> = StandardSchemaV1<
z.output
>;
+/**
+ * The TanStack Form instance behind an `AutoForm`, as its `onSubmit` receives
+ * it - for `form.reset()`, or {@link setFormFieldError} after an API error.
+ */
export type AutoFormApi> = FormApi<
z.input,
AutoFormValidator,
@@ -131,6 +170,7 @@ const hiddenIdsOf = >(
})
.join("\n");
+/** Renders one field's `component` with its props. Used by `AutoForm` and `AutoFormArray`. */
export const AutoFormFieldSlot = ({
component,
...props
@@ -175,6 +215,13 @@ type SubmitButtonProps =
: never
: never;
+/**
+ * A submit button for an `AutoForm` with a custom `layout`, which renders no
+ * button of its own. Shows a spinner while the form submits.
+ *
+ * Several buttons with different `intent` values can share one form: the
+ * clicked button's intent reaches `onSubmit` as `options.intent`.
+ */
export const AutoFormSubmitButton = ({
children,
className,
@@ -203,6 +250,14 @@ export const AutoFormSubmitButton = ({
);
};
+/**
+ * `AutoForm`'s submit handler. Runs only with values that passed the schema,
+ * already parsed (Zod output, so transforms and defaults are applied).
+ *
+ * `options.captchaToken` is a fresh token when the form has `captcha`, `""`
+ * otherwise; pass it to the fetcher's `captchaToken`. `options.intent` is the
+ * `value` of the submit button that was clicked, if any.
+ */
export type AutoFormOnSubmit> = (
values: z.infer,
form: AutoFormApi,
@@ -303,6 +358,49 @@ const fieldOtherProps = ({
const emptySubmitMeta: FormSubmitMeta = {};
+/**
+ * A form generated from a Zod object schema: validation, default values,
+ * required markers, length limits and error display all come from the schema,
+ * and `fields` only says which component renders each key. The form to use for
+ * any form in VitNode and its plugins.
+ *
+ * - Defaults come from the schema's `.default()` values.
+ * - The schema validates on mount, on change and on submit; `mode` only decides
+ * when errors become visible. A failed submit reveals every error, switches
+ * to the first tab with one, focuses it and shakes the invalid fields.
+ * - Inside a `Dialog` it renders a footer with Cancel and Submit, and the dialog
+ * asks before closing with unsaved changes. Closing or reloading the tab with
+ * unsaved changes asks too (browser `beforeunload`); in-app navigation is not
+ * blocked.
+ * - With `layout`, no submit button is rendered: add `AutoFormSubmitButton`, or
+ * `AutoFormSheetFooter` in a sheet.
+ *
+ * Browser-only interaction, but safe to render during SSR.
+ *
+ * @example
+ * ```tsx
+ * import { AutoForm } from "@vitnode/core/components/form/auto-form";
+ * import { AutoFormInput } from "@vitnode/core/components/form/fields/input";
+ * import { z } from "zod";
+ *
+ * const formSchema = z.object({ title: z.string().min(3).max(100) });
+ *
+ * export const TitleForm = () => (
+ * ,
+ * },
+ * ]}
+ * formSchema={formSchema}
+ * onSubmit={async values => {
+ * // values.title is a string of 3-100 characters
+ * }}
+ * />
+ * );
+ * ```
+ */
export function AutoForm>({
formSchema,
mode,
@@ -316,14 +414,39 @@ export function AutoForm>({
children,
...props
}: Omit, "onSubmit"> & {
+ /**
+ * Passed to TanStack Form. With `true` (the default) every submit re-runs
+ * the schema before deciding; with `false` a submit of a form already known
+ * to be invalid skips straight to revealing the errors. Invalid fields are
+ * revealed and focused either way, and `onSubmit` never runs with invalid
+ * values.
+ */
canSubmitWhenInvalid?: boolean;
+ /**
+ * The app's captcha config, from the middleware API. When set, the built-in
+ * submit button stays disabled until the captcha loads and `onSubmit` gets a
+ * token.
+ */
captcha?: z.infer["captcha"];
+ /** Which component renders each schema key, in display order. */
fields: ItemAutoFormProps[];
+ /** Validates the values, and supplies defaults and field constraints. */
formSchema: T;
+ /**
+ * Places the rendered fields yourself; receives them keyed by `id`. Replaces
+ * the tabs and the built-in submit button.
+ */
layout?: (renderedFields: Record) => React.ReactNode;
+ /**
+ * When a field's errors become visible: after blur (`onTouched`, `onBlur`),
+ * after the first change or blur (`onChange`, `all`) or only after a submit
+ * (`onSubmit`). Every error shows after a submit. Defaults to `onTouched`.
+ */
mode?: FormMode;
onSubmit?: AutoFormOnSubmit;
+ /** Props of the built-in submit button; `children` replaces its label. */
submitButtonProps?: SubmitButtonProps;
+ /** Splits the fields into tabs; a tab with an error is marked. */
tabs?: AutoFormTab[];
}) {
const {
diff --git a/packages/vitnode/src/components/form/common/desc.tsx b/packages/vitnode/src/components/form/common/desc.tsx
index bb1a003a20..941ff213b2 100644
--- a/packages/vitnode/src/components/form/common/desc.tsx
+++ b/packages/vitnode/src/components/form/common/desc.tsx
@@ -2,6 +2,11 @@ import { cn } from "cn";
import { useFormDescriptionId } from "../../ui/form";
+/**
+ * A form field's help text, wired to the field through
+ * `aria-describedby`. Render it inside a form field, below the control - for
+ * a custom field component the same way built-in fields do.
+ */
export const AutoFormDesc = ({
children,
className,
diff --git a/packages/vitnode/src/components/form/common/label.tsx b/packages/vitnode/src/components/form/common/label.tsx
index 5cbe184467..83803e87b7 100644
--- a/packages/vitnode/src/components/form/common/label.tsx
+++ b/packages/vitnode/src/components/form/common/label.tsx
@@ -4,6 +4,10 @@ import { useTranslations } from "use-intl";
import { FieldLabel } from "@/components/ui/field";
import { useFormField } from "@/components/ui/form";
+/**
+ * A form field's label, linked to the field's control by id. Render it inside
+ * a form field, above the control, in a custom field component.
+ */
export const AutoFormLabel = ({
children,
labelRight,
@@ -11,7 +15,9 @@ export const AutoFormLabel = ({
isOptional,
...props
}: React.ComponentProps & {
+ /** Appends the translated "optional" hint after the label. */
isOptional?: boolean;
+ /** Content pushed to the end of the label row, e.g. a language switcher. */
labelRight?: React.ReactNode;
}) => {
const t = useTranslations("core.global");
diff --git a/packages/vitnode/src/components/form/fields/array.tsx b/packages/vitnode/src/components/form/fields/array.tsx
index 8c2e69551c..5e684d5597 100644
--- a/packages/vitnode/src/components/form/fields/array.tsx
+++ b/packages/vitnode/src/components/form/fields/array.tsx
@@ -42,22 +42,34 @@ const reconcileRowKeys = (previous: number[], length: number): number[] => {
];
};
+/** One input of every row in an {@link AutoFormArray}. */
export interface AutoFormArrayField {
className?: string;
component: (props: ItemAutoFormComponentProps) => React.ReactNode;
+ /** The key of the row object the input edits. */
id: string;
}
export interface AutoFormArrayProps extends ItemAutoFormComponentProps {
+ /** Defaults to "Add Item" (not translated - pass your own). */
addButtonLabel?: string;
className?: string;
+ /** The inputs of each row, one per key of the array's item object. */
fields: AutoFormArrayField[];
itemParams?: InputParams;
+ /** Overrides the schema's `.max()`; Add is disabled at the limit. */
maxItems?: number;
+ /** Overrides the schema's `.min()`; Remove buttons are hidden at the limit. */
minItems?: number;
+ /** Defaults to `true`. */
showRemoveButton?: boolean;
}
+/**
+ * A repeatable group of inputs for an `AutoForm` field holding an array of
+ * objects (`z.array(z.object(...))`): one row per item, with Add and Remove
+ * buttons. A new row starts with every key `undefined`.
+ */
export const AutoFormArray = ({
label,
field: parentField,
diff --git a/packages/vitnode/src/components/form/fields/cascader.tsx b/packages/vitnode/src/components/form/fields/cascader.tsx
index 9a7c941c82..7290d858a8 100644
--- a/packages/vitnode/src/components/form/fields/cascader.tsx
+++ b/packages/vitnode/src/components/form/fields/cascader.tsx
@@ -7,6 +7,13 @@ import { AutoFormLabel } from "../common/label";
type CascaderProps = React.ComponentProps;
+/**
+ * A nested picker for an `AutoForm` string field: the admin drills through
+ * `options` and only a leaf (an option without children) can be chosen. Stores
+ * the leaf's `value`, or `undefined` when cleared; `showClear` defaults to
+ * whether the field is optional. Spread the props `AutoForm` passes and add a
+ * `label` and `options`.
+ */
export const AutoFormCascader = ({
label,
labelRight,
diff --git a/packages/vitnode/src/components/form/fields/checkbox.tsx b/packages/vitnode/src/components/form/fields/checkbox.tsx
index 5dd6aab670..a48e1d99f8 100644
--- a/packages/vitnode/src/components/form/fields/checkbox.tsx
+++ b/packages/vitnode/src/components/form/fields/checkbox.tsx
@@ -7,6 +7,7 @@ import { FormControl, FormMessage } from "../../ui/form";
import { AutoFormDesc } from "../common/desc";
import { AutoFormLabel } from "../common/label";
+/** A checkbox for an `AutoForm` boolean field. Spread the props `AutoForm` passes and add a `label`. */
export const AutoFormCheckbox = ({
label,
labelRight,
diff --git a/packages/vitnode/src/components/form/fields/color.tsx b/packages/vitnode/src/components/form/fields/color.tsx
index 846d2b3ae4..22060cb1d9 100644
--- a/packages/vitnode/src/components/form/fields/color.tsx
+++ b/packages/vitnode/src/components/form/fields/color.tsx
@@ -8,6 +8,12 @@ import type { ItemAutoFormComponentProps } from "../auto-form";
import { AutoFormDesc } from "../common/desc";
import { AutoFormLabel } from "../common/label";
+/**
+ * A color picker for an `AutoForm` string field. Stores a CSS color string -
+ * the picker writes `hsl(...)`, presets write their own value, and the text
+ * box accepts anything typed. Spread the props `AutoForm` passes and add a
+ * `label`.
+ */
export const AutoFormColor = ({
label,
labelRight,
diff --git a/packages/vitnode/src/components/form/fields/combobox.tsx b/packages/vitnode/src/components/form/fields/combobox.tsx
index 12208df687..3ca4aa6606 100644
--- a/packages/vitnode/src/components/form/fields/combobox.tsx
+++ b/packages/vitnode/src/components/form/fields/combobox.tsx
@@ -31,6 +31,7 @@ import { AutoFormDesc } from "../common/desc";
import { AutoFormLabel } from "../common/label";
import { groupComboboxItems } from "./combobox-groups";
+/** One option of an async combobox; the field stores the whole object. */
export interface ComboboxAsyncItem {
label: string;
value: string;
@@ -44,7 +45,9 @@ export const COMBOBOX_INERT_QUERY_KEY = "combobox:no-fetcher";
type AutoFormComboboxProps = ItemAutoFormComponentProps &
Omit, "items" | "value"> & {
className?: string;
+ /** Display labels (and optional groups) for the schema's enum values. */
labels?: ComboboxOption[];
+ /** With `multiple`, how many selected chips show before the rest collapse. */
maxVisibleChips?: number;
placeholder?: string;
renderChip?: (item: ComboboxAsyncItem) => React.ReactNode;
@@ -52,8 +55,14 @@ type AutoFormComboboxProps = ItemAutoFormComponentProps &
showClear?: boolean;
} & (
| {
+ /**
+ * Loads the options for the typed search (debounced by 500 ms). The
+ * field then stores {@link ComboboxAsyncItem} objects instead of enum
+ * strings.
+ */
fetchData: ComboboxFetchData;
id: string;
+ /** The TanStack Query key prefix; the search is appended to it. */
queryKey: readonly unknown[];
searchPlaceholder?: string;
}
@@ -322,6 +331,13 @@ const toComboboxValue = (
return value;
};
+/**
+ * A searchable select for an `AutoForm` field, single or `multiple`.
+ *
+ * Without `fetchData` the options are the schema's enum values, filtered in
+ * the browser. With `fetchData` they are loaded per search through TanStack
+ * Query. Spread the props `AutoForm` passes and add a `label`.
+ */
export const AutoFormCombobox = ({
label,
field,
diff --git a/packages/vitnode/src/components/form/fields/date-picker.tsx b/packages/vitnode/src/components/form/fields/date-picker.tsx
index c4872f89b9..bec47c9c63 100644
--- a/packages/vitnode/src/components/form/fields/date-picker.tsx
+++ b/packages/vitnode/src/components/form/fields/date-picker.tsx
@@ -9,6 +9,11 @@ import type { ItemAutoFormComponentProps } from "../auto-form";
import { AutoFormDesc } from "../common/desc";
import { AutoFormLabel } from "../common/label";
+/**
+ * A calendar picker for an `AutoForm` date field. Stores a local
+ * `YYYY-MM-DD` string, or `undefined` when cleared. Spread the props
+ * `AutoForm` passes and add a `label`.
+ */
export const AutoFormDatePicker = ({
label,
labelRight,
@@ -26,6 +31,7 @@ export const AutoFormDatePicker = ({
...props
}: ItemAutoFormComponentProps &
Omit, "onChange" | "value"> & {
+ /** Called after the form value changes, with the new `YYYY-MM-DD` string, or `undefined` when cleared. */
onChange?: (value: string | undefined) => void;
}) => (
<>
diff --git a/packages/vitnode/src/components/form/fields/date-time.tsx b/packages/vitnode/src/components/form/fields/date-time.tsx
index a2f57a280d..7d3e47fca4 100644
--- a/packages/vitnode/src/components/form/fields/date-time.tsx
+++ b/packages/vitnode/src/components/form/fields/date-time.tsx
@@ -19,6 +19,14 @@ const toInputValue = (value: unknown): string => {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
+/**
+ * A native `datetime-local` input for an `AutoForm` date-time field. Shows
+ * the value in the browser's time zone and stores it as an ISO string in UTC;
+ * clearing it stores `null` (`""` when `otherProps.clearsToEmpty` is set, as the Content Engine does for
+ * optional, non-nullable fields). Spread the
+ * props `AutoForm` passes and add a `label`; your own `onChange` and `onBlur`
+ * run after the form value updates.
+ */
export const AutoFormDateTime = ({
label,
labelRight,
@@ -31,6 +39,8 @@ export const AutoFormDateTime = ({
// oxlint-disable-next-line no-unused-vars
multiLang,
otherProps: { clearsToEmpty, isOptional },
+ onBlur,
+ onChange,
...props
}: ItemAutoFormComponentProps &
Omit, "type" | "value">) => {
@@ -48,7 +58,7 @@ export const AutoFormDateTime = ({
{
field.onBlur();
- props.onBlur?.(event);
+ onBlur?.(event);
}}
onChange={event => {
const { value } = event.target;
@@ -58,7 +68,7 @@ export const AutoFormDateTime = ({
? date.toISOString()
: cleared,
);
- props.onChange?.(event);
+ onChange?.(event);
}}
type="datetime-local"
value={toInputValue(field.value)}
diff --git a/packages/vitnode/src/components/form/fields/editor.tsx b/packages/vitnode/src/components/form/fields/editor.tsx
index ff2253df6a..6eac437e4c 100644
--- a/packages/vitnode/src/components/form/fields/editor.tsx
+++ b/packages/vitnode/src/components/form/fields/editor.tsx
@@ -40,7 +40,13 @@ const useEditorLabelledBy = (label: React.ReactNode): string | undefined => {
type AutoFormEditorProps = ItemAutoFormComponentProps &
TipTapEditorBaseProps & {
+ /**
+ * What the field stores: an HTML string (`html`) or a Tiptap JSON document
+ * (`json`, required for rich text in the Content Engine and for
+ * collaborative editing). Defaults to `html`.
+ */
format?: "html" | "json";
+ /** Edits one document per enabled language. */
multiLang?: boolean;
};
@@ -196,6 +202,12 @@ const MultiLangJsonEditor = ({
);
};
+/**
+ * The rich-text editor (Tiptap) as an `AutoForm` field. Spread the props `AutoForm` passes and add a `label`.
+ *
+ * Inside a collaborative editing context, a `json` field edits the shared
+ * document instead of a local one.
+ */
export const AutoFormEditor = ({
label,
labelRight,
diff --git a/packages/vitnode/src/components/form/fields/emoji-icon.tsx b/packages/vitnode/src/components/form/fields/emoji-icon.tsx
index 032a5b2837..66dfb811df 100644
--- a/packages/vitnode/src/components/form/fields/emoji-icon.tsx
+++ b/packages/vitnode/src/components/form/fields/emoji-icon.tsx
@@ -9,6 +9,11 @@ import type { ItemAutoFormComponentProps } from "../auto-form";
import { AutoFormDesc } from "../common/desc";
import { AutoFormLabel } from "../common/label";
+/**
+ * An emoji or Lucide icon picker for an `AutoForm` string field. Stores
+ * `emoji:` or `icon:`, or `""` when cleared. Spread the
+ * props `AutoForm` passes and add a `label`.
+ */
export const AutoFormEmojiIcon = ({
label,
labelRight,
diff --git a/packages/vitnode/src/components/form/fields/file-shared.tsx b/packages/vitnode/src/components/form/fields/file-shared.tsx
index bc6317aebe..b4718a8c37 100644
--- a/packages/vitnode/src/components/form/fields/file-shared.tsx
+++ b/packages/vitnode/src/components/form/fields/file-shared.tsx
@@ -28,13 +28,24 @@ import { Spinner } from "@/components/ui/spinner";
import { fileFormatLabels } from "@/lib/file-constraints";
import { formatBytes } from "@/lib/format-bytes";
+/**
+ * A stored file as a file field shows it - what an upload resolves with and
+ * what `file`/`files` props pass in. The field's own value is only `id`.
+ */
export interface AutoFormFileValue {
+ /** Image height in pixels, for the preview's layout. */
height?: number;
+ /** The stored file's id; this becomes the field value. */
id: number;
+ /** Decides whether the file is previewed as an image (`image/*`). */
mimeType?: null | string;
+ /** The file name shown to the user. */
name: string;
+ /** Size in bytes. */
size: number;
+ /** Where the file can be loaded from; `""` shows no image preview. */
url: string;
+ /** Image width in pixels, for the preview's layout. */
width?: number;
}
diff --git a/packages/vitnode/src/components/form/fields/file-upload-queue.ts b/packages/vitnode/src/components/form/fields/file-upload-queue.ts
index b0d7f407a0..d553df5338 100644
--- a/packages/vitnode/src/components/form/fields/file-upload-queue.ts
+++ b/packages/vitnode/src/components/form/fields/file-upload-queue.ts
@@ -11,8 +11,11 @@ import { placeUploadedFile } from "./file-order";
export const FILE_UPLOAD_CONCURRENCY = 6;
+/** What a file field passes to your upload function for each file. */
export interface FileUploadOptions {
+ /** Report upload progress from `0` to `1`; values outside are clamped. */
onProgress: (fraction: number) => void;
+ /** Aborted when the user cancels the upload; stop the request then. */
signal: AbortSignal;
}
diff --git a/packages/vitnode/src/components/form/fields/file.tsx b/packages/vitnode/src/components/form/fields/file.tsx
index 02795794c5..6345149a38 100644
--- a/packages/vitnode/src/components/form/fields/file.tsx
+++ b/packages/vitnode/src/components/form/fields/file.tsx
@@ -37,11 +37,19 @@ export type { AutoFormFileValue } from "./file-shared";
export type { FileUploadOptions } from "./file-upload-queue";
export interface AutoFormFileProps extends ItemAutoFormComponentProps {
+ /** Files with other extensions are rejected before upload. */
allowedExtensions?: readonly string[];
+ /** Files with other MIME types are rejected before upload. */
allowedMimeTypes?: readonly string[];
+ /** The stored file the field's initial id points at, so it can be shown. */
file?: AutoFormFileValue | null;
label?: React.ReactNode;
+ /** The largest accepted file, in bytes. */
maxBytes: number;
+ /**
+ * Uploads a picked file and resolves with the stored file; its `id` becomes
+ * the field value. Report progress and honour `signal` to support cancel.
+ */
onUpload: (
file: File,
options: FileUploadOptions,
@@ -169,6 +177,11 @@ const useFileUpload = ({
};
};
+/**
+ * A single-file upload for an `AutoForm` field that stores a file id (or
+ * `null` after removal). The file is uploaded as soon as it is picked, through
+ * `onUpload`; the form only ever holds the id.
+ */
export const AutoFormFile = ({
allowedExtensions,
allowedMimeTypes,
diff --git a/packages/vitnode/src/components/form/fields/files.tsx b/packages/vitnode/src/components/form/fields/files.tsx
index 525343ff24..0d7112e594 100644
--- a/packages/vitnode/src/components/form/fields/files.tsx
+++ b/packages/vitnode/src/components/form/fields/files.tsx
@@ -35,18 +35,27 @@ import {
export interface AutoFormFilesProps extends ItemAutoFormComponentProps {
allowedExtensions?: readonly string[];
allowedMimeTypes?: readonly string[];
+ /** The stored files the field's initial ids point at, so they can be shown. */
files?: AutoFormFileValue[];
label?: React.ReactNode;
+ /** The largest accepted file, in bytes. */
maxBytes: number;
maxItems: number;
+ /** Defaults to `0`. */
minItems?: number;
+ /** Uploads one picked file and resolves with the stored file. Up to six run at once. */
onUpload: (
file: File,
options: FileUploadOptions,
) => Promise;
+ /** Lets the user reorder the files; the order is kept in the value. Defaults to `true`. */
ordered?: boolean;
}
+/**
+ * A multi-file upload for an `AutoForm` field that stores an array of file
+ * ids. Picked files upload right away through `onUpload`.
+ */
export const AutoFormFiles = ({
allowedExtensions,
allowedMimeTypes,
diff --git a/packages/vitnode/src/components/form/fields/input-otp.tsx b/packages/vitnode/src/components/form/fields/input-otp.tsx
index 41bf882d8f..8ebca9869a 100644
--- a/packages/vitnode/src/components/form/fields/input-otp.tsx
+++ b/packages/vitnode/src/components/form/fields/input-otp.tsx
@@ -22,11 +22,22 @@ type AutoFormInputOTPProps = ItemAutoFormComponentProps &
React.ComponentProps,
"children" | "maxLength" | "onChange" | "render" | "value"
> & {
+ /**
+ * Slot counts per group, separated visually, e.g. `[3, 3]`. Defaults to
+ * two equal halves for an even length of 6 or more, else one group.
+ */
groups?: number[];
+ /** Number of slots. Defaults to the schema's max length, else 6. */
maxLength?: number;
+ /** Called after the form value changes. */
onChange?: (value: string) => void;
};
+/**
+ * A one-time-code input for an `AutoForm` string field: one box per
+ * character. `pattern` defaults to the schema's regex. Spread the props
+ * `AutoForm` passes and add a `label`.
+ */
export const AutoFormInputOTP = ({
label,
labelRight,
diff --git a/packages/vitnode/src/components/form/fields/input-roles.tsx b/packages/vitnode/src/components/form/fields/input-roles.tsx
index 33e5080534..4307f96528 100644
--- a/packages/vitnode/src/components/form/fields/input-roles.tsx
+++ b/packages/vitnode/src/components/form/fields/input-roles.tsx
@@ -39,15 +39,24 @@ const RoleLabel = ({
/** What the role field takes. `search` is the whole of its host coupling. */
export type AutoFormRolesProps = ItemAutoFormComponentProps & {
disabled?: boolean;
+ /** Roles that cannot be picked, by id. */
excludeIds?: number[];
+ /** Stores an array of role ids instead of one id. Defaults to `false`. */
multiple?: boolean;
placeholder?: string;
+ /** Finds roles for the typed text. */
search: RoleSearch;
searchPlaceholder?: string;
+ /** The roles the field's initial ids belong to, so they show names before any search. */
selected?: RoleOption[];
};
+/**
+ * A role picker for an `AutoForm` field that stores a role id (or `null`), or
+ * an array of ids with `multiple`. Spread the props `AutoForm` passes and add a
+ * `label`.
+ */
export const AutoFormRoles = ({
description,
disabled,
diff --git a/packages/vitnode/src/components/form/fields/input-users.tsx b/packages/vitnode/src/components/form/fields/input-users.tsx
index ea2aa601ba..8d09b08495 100644
--- a/packages/vitnode/src/components/form/fields/input-users.tsx
+++ b/packages/vitnode/src/components/form/fields/input-users.tsx
@@ -92,6 +92,10 @@ export const UserOptionRow = ({
);
+/**
+ * A user picker for an `AutoForm` field that stores a user id (or `null`).
+ * Spread the props `AutoForm` passes and add a `label`.
+ */
export const AutoFormUser = ({
clearable = false,
description,
@@ -111,12 +115,15 @@ export const AutoFormUser = ({
selected,
}: ItemAutoFormComponentProps & {
className?: string;
+ /** Adds a button that sets the field back to `null`. */
clearable?: boolean;
disabled?: boolean;
placeholder?: string;
+ /** Finds users for the typed text. */
search: (value: string) => Promise;
searchPlaceholder?: string;
+ /** The user the field's initial id belongs to, so it shows a name before any search. */
selected?: null | PartialUserOption;
}) => {
const t = useTranslations("core.global");
diff --git a/packages/vitnode/src/components/form/fields/input.tsx b/packages/vitnode/src/components/form/fields/input.tsx
index 23a1ca5afb..05b30b43e0 100644
--- a/packages/vitnode/src/components/form/fields/input.tsx
+++ b/packages/vitnode/src/components/form/fields/input.tsx
@@ -83,6 +83,15 @@ const MultiLangInput = ({
);
};
+/**
+ * A single-line text input for an `AutoForm` string field. Length limits and
+ * `pattern` come from the schema; `type` comes from the schema (`email`
+ * format) or the key name (`password`). Other input props pass through.
+ * Spread the props `AutoForm` passes and add a `label`.
+ *
+ * With `multiLang`, edits one value per enabled language. Pass `children` to
+ * render them as addons inside an input group.
+ */
export const AutoFormInput = ({
label,
labelRight,
diff --git a/packages/vitnode/src/components/form/fields/multi-lang-default-language.ts b/packages/vitnode/src/components/form/fields/multi-lang-default-language.ts
index 4e9bb97658..e9810f0106 100644
--- a/packages/vitnode/src/components/form/fields/multi-lang-default-language.ts
+++ b/packages/vitnode/src/components/form/fields/multi-lang-default-language.ts
@@ -1,8 +1,14 @@
import React from "react";
+/**
+ * The language a multi-language field falls back to when picking which
+ * language to show first. The Content Engine form provides the content type's
+ * `defaultLocale`.
+ */
export const MultiLangDefaultLanguageContext = React.createContext<
null | string
>(null);
+/** The language from {@link MultiLangDefaultLanguageContext}, or `null`. */
export const useMultiLangDefaultLanguage = (): null | string =>
React.use(MultiLangDefaultLanguageContext);
diff --git a/packages/vitnode/src/components/form/fields/multi-lang-language.ts b/packages/vitnode/src/components/form/fields/multi-lang-language.ts
index da97870e66..e0ad9138de 100644
--- a/packages/vitnode/src/components/form/fields/multi-lang-language.ts
+++ b/packages/vitnode/src/components/form/fields/multi-lang-language.ts
@@ -1,15 +1,27 @@
import React from "react";
+/**
+ * Pins every multi-language field below it to one language and hides their
+ * language switchers - e.g. a side-by-side translation view rendering the
+ * source and target languages separately.
+ */
export const MultiLangLanguageContext = React.createContext(
null,
);
+/** The pinned language from {@link MultiLangLanguageContext}, or `null`. */
export const useMultiLangLanguage = (): null | string =>
React.use(MultiLangLanguageContext);
+/**
+ * The language a multi-language field is showing, provided to its label's
+ * `labelRight` content and to a multi-language editor's body (e.g. AI assist
+ * acting on that language).
+ */
export const MultiLangSelectedContext = React.createContext(
null,
);
+/** The language from {@link MultiLangSelectedContext}, or `null` outside a multi-language field. */
export const useMultiLangSelected = (): null | string =>
React.use(MultiLangSelectedContext);
diff --git a/packages/vitnode/src/components/form/fields/multi-lang-presence.ts b/packages/vitnode/src/components/form/fields/multi-lang-presence.ts
index 8efd6ed268..8b790b7e16 100644
--- a/packages/vitnode/src/components/form/fields/multi-lang-presence.ts
+++ b/packages/vitnode/src/components/form/fields/multi-lang-presence.ts
@@ -1,13 +1,18 @@
import React from "react";
+/** Who else is editing which language of a field, for its language switcher. */
export interface MultiLangPresence {
+ /** Whether someone else is editing this language; marks the switcher. */
busy: (code: string) => boolean;
+ /** Rendered next to each language in the switcher's list. */
marker: (code: string) => React.ReactNode;
}
+/** Provided by Content Engine live editing; `null` elsewhere. */
export const MultiLangPresenceContext =
React.createContext(null);
+/** Receives the language a multi-language field is showing, whenever it changes. */
export const MultiLangShownLanguageContext = React.createContext<
((code: string) => void) | null
>(null);
diff --git a/packages/vitnode/src/components/form/fields/multi-lang.tsx b/packages/vitnode/src/components/form/fields/multi-lang.tsx
index 6bb1c7a1c4..5ac08312bd 100644
--- a/packages/vitnode/src/components/form/fields/multi-lang.tsx
+++ b/packages/vitnode/src/components/form/fields/multi-lang.tsx
@@ -38,6 +38,7 @@ export type {
MultiLangValueItem,
} from "@/lib/helpers/multi-lang";
+/** A form field holding one value per language. */
export interface MultiLangFieldProps {
field: FormFieldApi;
}
@@ -72,6 +73,16 @@ const useMultiLangSelection = (
};
};
+/**
+ * Drives a custom multi-language text field: which language is shown, its
+ * text, and a setter that updates only that language's entry.
+ *
+ * It first shows the first language that already has text (`isFilled`,
+ * default: not blank), checking the UI locale, then the default language, then
+ * the rest; with none filled, the UI locale (or the first language). A pinned
+ * `MultiLangLanguageContext` language always wins. `canSelect` is `false` when
+ * pinned or when there is only one language.
+ */
export const useMultiLangField = (
field: MultiLangFieldProps["field"],
{ isFilled = hasText }: { isFilled?: (text: string) => boolean } = {},
@@ -95,6 +106,10 @@ export const useMultiLangField = (
};
};
+/**
+ * {@link useMultiLangField} for non-string values (e.g. a rich-text document):
+ * `isFilled` decides which languages count as having a value.
+ */
export const useMultiLangValueField = (
field: FormFieldApi | undefined>,
{ isFilled }: { isFilled: (value: TValue | undefined) => boolean },
@@ -118,6 +133,7 @@ export const useMultiLangValueField = (
};
};
+/** The compact language switcher shown in a multi-language field's label row. */
export const MultiLangSelect = ({
languages,
onSelect,
@@ -166,6 +182,10 @@ export const MultiLangSelect = ({
);
};
+/**
+ * A multi-language field's label row: the label, plus the language switcher
+ * when `canSelect`. Renders nothing without either.
+ */
export const MultiLangLabel = ({
canSelect,
isOptional,
diff --git a/packages/vitnode/src/components/form/fields/nullable-number.tsx b/packages/vitnode/src/components/form/fields/nullable-number.tsx
index e83829fbec..b7edfebb9f 100644
--- a/packages/vitnode/src/components/form/fields/nullable-number.tsx
+++ b/packages/vitnode/src/components/form/fields/nullable-number.tsx
@@ -21,14 +21,21 @@ type AutoFormNullableNumberProps = ItemAutoFormComponentProps &
React.ComponentProps,
"children" | "defaultValue" | "id" | "name" | "onValueChange" | "value"
> & {
+ /** Text between the input and the toggle, e.g. "or". */
orLabel?: React.ReactNode;
placeholder?: string;
/** The number restored when the toggle is unchecked before anything was typed. */
resumeValue?: number;
+ /** The checkbox that sets the value to `null`, e.g. "Unlimited". */
toggleLabel: React.ReactNode;
+ /** Shown after the input, e.g. `MB` or `days`. */
unitLabel?: React.ReactNode;
};
+/**
+ * A number input with a checkbox that stores `null` instead - for a limit that
+ * can be switched off. Use with a `z.number().nullable()` field. Spread the props `AutoForm` passes and add a `label`.
+ */
export const AutoFormNullableNumber = ({
label,
labelRight,
diff --git a/packages/vitnode/src/components/form/fields/number.tsx b/packages/vitnode/src/components/form/fields/number.tsx
index 26eefa2ecf..69e9274e72 100644
--- a/packages/vitnode/src/components/form/fields/number.tsx
+++ b/packages/vitnode/src/components/form/fields/number.tsx
@@ -21,9 +21,11 @@ type AutoFormNumberProps = ItemAutoFormComponentProps &
"children" | "defaultValue" | "id" | "name" | "onValueChange" | "value"
> & {
placeholder?: string;
+ /** Shown after the input, e.g. `MB` or `days`. */
unitLabel?: React.ReactNode;
};
+/** A number input with steppers for an `AutoForm` number field. Spread the props `AutoForm` passes and add a `label`. */
export const AutoFormNumber = ({
className,
description,
diff --git a/packages/vitnode/src/components/form/fields/radio-group.tsx b/packages/vitnode/src/components/form/fields/radio-group.tsx
index a8c1f8ae8e..5a7211ac48 100644
--- a/packages/vitnode/src/components/form/fields/radio-group.tsx
+++ b/packages/vitnode/src/components/form/fields/radio-group.tsx
@@ -22,6 +22,7 @@ interface ItemAutoFormRadioGroupLabelsProps {
value: string;
}
+/** Radio buttons for an `AutoForm` enum field, one per enum value. Spread the props `AutoForm` passes and add a `label`. */
export const AutoFormRadioGroup = ({
label,
labelRight,
@@ -39,7 +40,9 @@ export const AutoFormRadioGroup = ({
...props
}: ItemAutoFormComponentProps &
Omit, "value"> & {
+ /** Labels, descriptions and disabled flags for the enum values. */
labels?: ItemAutoFormRadioGroupLabelsProps[];
+ /** `blocks` renders each option as a selectable card. Defaults to `default`. */
variant?: "blocks" | "default";
}) => {
const values: ItemAutoFormRadioGroupLabelsProps[] = enumValues.map(value => {
diff --git a/packages/vitnode/src/components/form/fields/roles.ts b/packages/vitnode/src/components/form/fields/roles.ts
index 319b9ad1cb..16dcd0df23 100644
--- a/packages/vitnode/src/components/form/fields/roles.ts
+++ b/packages/vitnode/src/components/form/fields/roles.ts
@@ -1,11 +1,15 @@
+/** A role as the roles picker lists it. */
export interface RoleOption {
+ /** The role's color, or `null` for none. */
color: null | string;
id: number;
+ /** The role's name in each language it was given. */
name: { languageCode: string; name: string }[];
}
export type RoleSearch = (search: string) => Promise;
+/** A role's name in `locale`, else its first name, else its id as text. */
export const roleOptionName = (role: RoleOption, locale: string): string =>
role.name.find(item => item.languageCode === locale)?.name ??
role.name[0]?.name ??
diff --git a/packages/vitnode/src/components/form/fields/select.tsx b/packages/vitnode/src/components/form/fields/select.tsx
index dd69c7d3df..d9f85114fe 100644
--- a/packages/vitnode/src/components/form/fields/select.tsx
+++ b/packages/vitnode/src/components/form/fields/select.tsx
@@ -16,6 +16,10 @@ import type { ItemAutoFormComponentProps } from "../auto-form";
import { AutoFormDesc } from "../common/desc";
import { AutoFormLabel } from "../common/label";
+/**
+ * A dropdown for an `AutoForm` enum field: the options are the schema's
+ * `z.enum()` values. Spread the props `AutoForm` passes and add a `label`.
+ */
export const AutoFormSelect = ({
label,
field,
@@ -33,6 +37,7 @@ export const AutoFormSelect = ({
...props
}: ItemAutoFormComponentProps &
Omit, "value"> & {
+ /** Display labels for the enum values; a value without one shows as is. */
labels?: { label: string; value: string }[];
placeholder?: string;
}) => {
diff --git a/packages/vitnode/src/components/form/fields/switch.tsx b/packages/vitnode/src/components/form/fields/switch.tsx
index d19ab6829a..c69d1045ef 100644
--- a/packages/vitnode/src/components/form/fields/switch.tsx
+++ b/packages/vitnode/src/components/form/fields/switch.tsx
@@ -16,6 +16,7 @@ import { AutoFormLabel } from "../common/label";
const FIELDS_PANEL = "fields";
+/** A switch for an `AutoForm` boolean field. Spread the props `AutoForm` passes and add a `label`. */
export const AutoFormSwitch = ({
label,
field,
diff --git a/packages/vitnode/src/components/form/fields/textarea.tsx b/packages/vitnode/src/components/form/fields/textarea.tsx
index 377a62227c..79bd997740 100644
--- a/packages/vitnode/src/components/form/fields/textarea.tsx
+++ b/packages/vitnode/src/components/form/fields/textarea.tsx
@@ -82,6 +82,10 @@ const MultiLangTextarea = ({
);
};
+/**
+ * A multi-line text input for an `AutoForm` string field, with the schema's
+ * length limits. Supports `multiLang`. Spread the props `AutoForm` passes and add a `label`.
+ */
export const AutoFormTextarea = ({
label,
description,
diff --git a/packages/vitnode/src/components/logo-context.tsx b/packages/vitnode/src/components/logo-context.tsx
index 207df7daba..4b0f8d2fcd 100644
--- a/packages/vitnode/src/components/logo-context.tsx
+++ b/packages/vitnode/src/components/logo-context.tsx
@@ -2,6 +2,7 @@ import React from "react";
import { LogoVitNodeBrand } from "./logo-vitnode";
+/** A component that renders the site logo, the frontend config's `logo`. Takes no props. */
export type VitNodeLogo = React.ComponentType;
export const LogoContext = React.createContext(
diff --git a/packages/vitnode/src/components/logo-vitnode.tsx b/packages/vitnode/src/components/logo-vitnode.tsx
index c8c229cb36..c951a0f699 100644
--- a/packages/vitnode/src/components/logo-vitnode.tsx
+++ b/packages/vitnode/src/components/logo-vitnode.tsx
@@ -107,6 +107,7 @@ export const LogoVitNode = ({
);
};
+/** The VitNode logo at brand size - the default for `logo` in the frontend config. */
export const LogoVitNodeBrand = () => (
);
diff --git a/packages/vitnode/src/components/pet/pet.tsx b/packages/vitnode/src/components/pet/pet.tsx
index 5b441f98c5..577a658ff6 100644
--- a/packages/vitnode/src/components/pet/pet.tsx
+++ b/packages/vitnode/src/components/pet/pet.tsx
@@ -7,13 +7,19 @@ export type { PetState } from "./states";
export const PET_STATE_NAMES = Object.keys(PET_STATES) as PetState[];
+/**
+ * VitNode's animated mascot as an inline SVG. Its CSS is injected once per
+ * page. Decorative (hidden from screen readers) unless you pass `label`.
+ */
export const Pet = ({
className,
label,
state = "idle",
}: {
className?: string;
+ /** Accessible name; without it the SVG is `aria-hidden`. */
label?: string;
+ /** Which animation to play, one of {@link PET_STATE_NAMES}. Defaults to `idle`. */
state?: PetState;
}) => {
const State = PET_STATES[state];
diff --git a/packages/vitnode/src/components/rich-text/rich-text-content.tsx b/packages/vitnode/src/components/rich-text/rich-text-content.tsx
index 655043c83a..324d8ab8c7 100644
--- a/packages/vitnode/src/components/rich-text/rich-text-content.tsx
+++ b/packages/vitnode/src/components/rich-text/rich-text-content.tsx
@@ -36,8 +36,11 @@ export type RichTextMarkRenderer = (
export interface RichTextContentProps {
className?: string;
+ /** The document to render. Nothing renders for `null` or `undefined`. */
content: null | RichTextDocument | undefined;
+ /** Custom renderers by mark type (e.g. `link`), replacing the built-in element. */
marks?: Readonly>;
+ /** Custom renderers by node type (e.g. `image`), receiving the rendered children. */
nodes?: Readonly>;
}
@@ -138,6 +141,12 @@ const renderNode = (
);
};
+/**
+ * Renders a stored rich-text document as React elements, without an editor
+ * and without `dangerouslySetInnerHTML`. Built-in nodes and marks map to
+ * sanitised HTML elements; unknown ones render only their children unless
+ * `nodes` or `marks` supplies a renderer. Universal - works in SSR.
+ */
export const RichTextContent = ({
className,
content,
diff --git a/packages/vitnode/src/components/role-format-content.tsx b/packages/vitnode/src/components/role-format-content.tsx
index 6dca671ccc..970ef99183 100644
--- a/packages/vitnode/src/components/role-format-content.tsx
+++ b/packages/vitnode/src/components/role-format-content.tsx
@@ -8,6 +8,11 @@ import type { RoleNameEntry } from "./role-name";
import { resolveRoleName } from "./role-name";
import { EmojiIcon } from "./ui/emoji-icon";
+/**
+ * A role's name in the current language (else its first name, else its id),
+ * in the role's color, after its emoji or icon prefix. Extra props go to the
+ * outer ``.
+ */
export const RoleFormatContent = ({
className,
role,
diff --git a/packages/vitnode/src/components/staff-permission/provider.tsx b/packages/vitnode/src/components/staff-permission/provider.tsx
index b92cefe117..a26b61982e 100644
--- a/packages/vitnode/src/components/staff-permission/provider.tsx
+++ b/packages/vitnode/src/components/staff-permission/provider.tsx
@@ -31,6 +31,10 @@ const isPending = (
): value is Promise =>
typeof (value as Partial>).then === "function";
+/**
+ * The current admin's staff permission set from the AdminCP layout. Suspends
+ * while it loads. For hiding UI only - the API enforces permissions itself.
+ */
export const useAdminStaffPermissions = (): StaffPermissionSet => {
const value = React.use(AdminStaffPermissionContext);
@@ -61,6 +65,18 @@ const AdminStaffPermissionGateResolved = ({
return <>{allowed ? children : fallback}>;
};
+/**
+ * Renders `children` only when the current admin holds the permission,
+ * otherwise `fallback` (also shown while permissions load). UI only - guard
+ * the matching API route with `adminStaffPermission` as well.
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ *
+ * ```
+ */
export const AdminStaffPermissionGate = ({
children,
fallback = null,
diff --git a/packages/vitnode/src/components/table/content.tsx b/packages/vitnode/src/components/table/content.tsx
index ee3d27d95c..ba3ec28aea 100644
--- a/packages/vitnode/src/components/table/content.tsx
+++ b/packages/vitnode/src/components/table/content.tsx
@@ -131,6 +131,15 @@ const ReorderableBodyDataTable = React.lazy(async () =>
})),
);
+/**
+ * A server-paginated data table driven by the URL: sorting, cursor paging,
+ * search and filters are read from and written to the query string through
+ * the enclosing `DataTableNavigationProvider`, so the route's loader fetches
+ * each page. Optional row selection with bulk actions, expandable and
+ * reorderable rows, and grouping.
+ *
+ * Pass the API's `edges` and `pageInfo` for the current page.
+ */
export function ContentDataTable({
bulkActions,
columns = [],
diff --git a/packages/vitnode/src/components/table/data-table-content.tsx b/packages/vitnode/src/components/table/data-table-content.tsx
index d989fb0641..1e0fb6da9f 100644
--- a/packages/vitnode/src/components/table/data-table-content.tsx
+++ b/packages/vitnode/src/components/table/data-table-content.tsx
@@ -36,12 +36,14 @@ export type AlignDataTable = "center" | "left" | "right";
interface ColumnDefBase {
align?: AlignDataTable;
+ /** Renders the cell. Defaults to the `accessorKey` value. */
cell?: (data: { allData: T[]; row: T }) => React.ReactNode;
className?: string;
header: React.ReactNode;
}
interface AccessorColumnDef extends ColumnDefBase {
+ /** The row key the column shows, and sorts by when listed in `order.columns`. */
accessorKey: keyof T;
id?: string;
}
@@ -51,46 +53,63 @@ interface DisplayColumnDef extends ColumnDefBase {
id: string;
}
+/** One column of a `ContentDataTable`: bound to a row key, or a display column with its own `id`. */
export type ColumnDef =
| AccessorColumnDef
| DisplayColumnDef;
+/** What `ContentDataTable` accepts. */
export type DataTableProps = Omit<
React.ComponentProps,
"columns"
> &
React.ComponentProps &
React.ComponentProps & {
+ /** Adds a checkbox per row and shows these actions while rows are selected. */
bulkActions?: React.ReactNode;
columns?: ColumnDef[];
+ /** Replaces the default empty state. */
customNoResults?: {
description?: string;
footer?: React.ReactNode;
icon?: React.ReactNode;
title?: string;
};
+ /** The rows of the current page. */
edges: T[];
expandable?: ExpandableDataTable;
+ /** Faceted filters in the toolbar, kept in the URL. */
filters?: FilterDataTable[];
groupBy?: GroupByDataTable;
+ /** A band above the table, e.g. a count or an action button. */
header?: React.ReactNode;
id: string;
order: {
+ /** The columns a header click sorts by. */
columns?: (keyof T)[];
+ /** The sort used while the URL names none. */
defaultOrder: {
column: keyof T;
order: "asc" | "desc";
};
};
+ /** Renders each row as one full-width cell instead of `columns`. */
renderRow?: (data: { allData: T[]; row: T }) => React.ReactNode;
+ /** Lets rows be dragged into a new order. */
reorderable?: ReorderableDataTable;
+ /**
+ * Makes a whole row clickable (and Enter/Space-activatable); clicks on
+ * controls inside it are ignored. Ignored when `bulkActions` is set.
+ */
rowOpens?: (row: T) => void;
+ /** Shows a search box in the toolbar, kept in the URL. */
search?: boolean;
};
const SKELETON_HEAD_WIDTHS = ["w-24", "w-16", "w-20", "w-14"];
const SKELETON_CELL_WIDTHS = ["w-full", "w-3/4", "w-1/2", "w-5/6", "w-2/3"];
+/** A loading placeholder shaped like a `ContentDataTable`, for `Suspense` fallbacks. */
export const DataTableSkeleton = ({
columns,
header = false,
diff --git a/packages/vitnode/src/components/table/expansion.tsx b/packages/vitnode/src/components/table/expansion.tsx
index 5cfae78edb..030209bb07 100644
--- a/packages/vitnode/src/components/table/expansion.tsx
+++ b/packages/vitnode/src/components/table/expansion.tsx
@@ -11,12 +11,20 @@ import { MotionFeatures } from "../motion-features";
import { Button } from "../ui/button";
import { TableCell, TableRow } from "../ui/table";
+/**
+ * Expandable rows: each row gets a toggle that opens a full-width panel below
+ * it. Rows that leave the page are collapsed.
+ */
export interface ExpandableDataTable {
+ /** Whether a row gets a toggle. Defaults to every row. */
canExpand?: (row: T) => boolean;
+ /** Row ids open on first render. */
defaultExpanded?: DataTableRowId[];
+ /** The panel's content, rendered only while the row is open. */
render: (row: T) => React.ReactNode;
}
+/** Which rows are open. Ids no longer on the page are dropped when it changes. */
export const useRowExpansionDataTable = ({
defaultExpanded,
rowIds,
@@ -56,12 +64,14 @@ export const useRowExpansionDataTable = ({
return { isExpanded, toggle };
};
+/** The toggle column's header: a screen-reader-only label. */
export function ExpandHeaderDataTable() {
const t = useTranslations("core.global.data_table");
return {t("row_details")};
}
+/** A row's open/close button, linked to its panel through `aria-controls`. */
export function ExpandToggleDataTable({
controls,
expanded,
@@ -94,6 +104,7 @@ export function ExpandToggleDataTable({
);
}
+/** The full-width row holding an open panel; `id` is what the toggle controls. */
export function ExpandedRowDataTable({
children,
colSpan,
diff --git a/packages/vitnode/src/components/table/filters.tsx b/packages/vitnode/src/components/table/filters.tsx
index c78f53ace1..a1df1f524e 100644
--- a/packages/vitnode/src/components/table/filters.tsx
+++ b/packages/vitnode/src/components/table/filters.tsx
@@ -21,20 +21,41 @@ import { Spinner } from "../ui/spinner";
import { useDataTableUrl } from "./navigation";
import { readTableFilter, withTableFilter } from "./url-state";
+/** One choice in a faceted filter. */
export interface FilterOption {
+ /** Extra words the filter's search box matches, for static `options` only. */
keywords?: string[];
+ /** What the option shows in the filter list. */
label: React.ReactNode;
+ /** Written to the URL; matched by the search box for static `options`. */
value: string;
}
+/**
+ * A faceted filter in a data table's toolbar. The selection lives in the URL
+ * as `?{id}=a,b`, so the page's query reads it from there; changing it returns
+ * the table to the first page.
+ */
export interface FilterDataTable {
+ /** The URL parameter the selected values are written to, comma-separated. */
id: string;
+ /** The button's text and the search box's placeholder. */
label: string;
+ /**
+ * Loads options from the server instead of `options`: called with `""` when
+ * the popover opens, then with the typed text after a 400 ms pause.
+ */
onSearch?: (search: string) => Promise;
+ /** A fixed list of choices, searched in the browser. Ignored with `onSearch`. */
options?: FilterOption[];
+ /**
+ * Allows one value at a time and adds an "all" entry with this label that
+ * clears it. Picking the selected value again also clears it.
+ */
single?: { allLabel: string };
}
+/** The selection after toggling `value`; with `single`, at most that one value. */
export const nextFilterSelection = (
selected: readonly string[],
value: string,
@@ -199,6 +220,7 @@ function FilterItem({ filter }: { filter: FilterDataTable }) {
);
}
+/** One filter button per entry; `ContentDataTable` renders it from its `filters` prop. */
export function FiltersDataTable({ filters }: { filters: FilterDataTable[] }) {
return (
<>
diff --git a/packages/vitnode/src/components/table/navigation.tsx b/packages/vitnode/src/components/table/navigation.tsx
index 575b7abf87..7536e9b487 100644
--- a/packages/vitnode/src/components/table/navigation.tsx
+++ b/packages/vitnode/src/components/table/navigation.tsx
@@ -1,6 +1,12 @@
import React from "react";
+/**
+ * How a data table reads and writes the URL. Sorting, paging, search and
+ * filters all live in the query string, so the host decides how a change
+ * navigates (e.g. TanStack Router's `navigate({ search })`).
+ */
export interface DataTableNavigation {
+ /** Navigates to the given query string. Runs inside a React transition. */
navigate: (nextSearch: string) => Promise | void;
/** The query string the table is currently rendering. Never mutated. */
searchParams: URLSearchParams;
@@ -9,6 +15,7 @@ export interface DataTableNavigation {
const DataTableNavigationContext =
React.createContext(null);
+/** Provides {@link DataTableNavigation} to every data table control below it. Required by `ContentDataTable`. */
export const DataTableNavigationProvider = ({
children,
value,
@@ -21,6 +28,12 @@ export const DataTableNavigationProvider = ({
);
+/**
+ * The table's current query string and a `navigate` wrapped in a transition,
+ * whose `isPending` controls can show while the next page loads.
+ *
+ * @throws Outside a {@link DataTableNavigationProvider}.
+ */
export const useDataTableUrl = (): {
isPending: boolean;
navigate: (nextSearch: string) => void;
diff --git a/packages/vitnode/src/components/table/reorder-handle.tsx b/packages/vitnode/src/components/table/reorder-handle.tsx
index c91fbb4f93..78cab6de54 100644
--- a/packages/vitnode/src/components/table/reorder-handle.tsx
+++ b/packages/vitnode/src/components/table/reorder-handle.tsx
@@ -9,12 +9,14 @@ export const SortableRowHandleContext =
export const ROW_GROUP_CLASS =
"[&:last-child>tr:last-child]:border-0 [&:last-child>tr:last-child>td]:border-b-0 [&>tr>td]:border-b";
+/** The handle column's header: a screen-reader-only label. */
export function ReorderHeaderDataTable() {
const t = useTranslations("core.global.data_table");
return {t("reorder_column")};
}
+/** The current row's drag handle, from the nearest `SortableRowGroupDataTable`. */
export function ReorderHandleDataTable() {
return <>{React.use(SortableRowHandleContext)}>;
}
diff --git a/packages/vitnode/src/components/table/reorder-state.ts b/packages/vitnode/src/components/table/reorder-state.ts
index 56a62430f9..aacebe1e05 100644
--- a/packages/vitnode/src/components/table/reorder-state.ts
+++ b/packages/vitnode/src/components/table/reorder-state.ts
@@ -2,13 +2,17 @@ import type { DataTableRowId } from "./data-table-content";
import { readTableOrder, readTableSearch, type TableOrder } from "./url-state";
+/** One drag, as `onReorder` receives it. Indexes are positions on the current page. */
export interface ReorderDetailsDataTable {
+ /** The dragged row. */
activeId: DataTableRowId;
from: number;
+ /** The row it was dropped on. */
overId: DataTableRowId;
to: number;
}
+/** A drag plus the page's row ids in their new order. */
export interface RowMoveDataTable extends ReorderDetailsDataTable {
ids: DataTableRowId[];
}
@@ -23,6 +27,7 @@ const arrayMove = (items: readonly T[], from: number, to: number): T[] => {
return next;
};
+/** Moves `activeId` to `overId`'s position; `null` when either is missing or they are the same row. */
export const moveRowId = (
ids: readonly DataTableRowId[],
activeId: DataTableRowId,
@@ -42,6 +47,10 @@ export const moveRowId = (
};
};
+/**
+ * Whether the table shows rows in their stored order - no search, no filter
+ * and the default sort - which is the only time dragging can mean anything.
+ */
export const isTableInStoredOrder = (
searchParams: URLSearchParams,
{
diff --git a/packages/vitnode/src/components/table/reorder.tsx b/packages/vitnode/src/components/table/reorder.tsx
index e8c9e22f22..fc79a3716d 100644
--- a/packages/vitnode/src/components/table/reorder.tsx
+++ b/packages/vitnode/src/components/table/reorder.tsx
@@ -42,6 +42,7 @@ const subscribeToNothing = () => () => undefined;
const getAnnouncementContainer = () => document.body;
const getNoAnnouncementContainer = () => null;
+/** The drag-and-drop context for one page of sortable rows. */
export function ReorderProviderDataTable({
children,
ids,
@@ -111,6 +112,7 @@ export function ReorderProviderDataTable({
);
}
+/** One sortable row (a `tbody`, so an expanded panel moves with it), providing its handle. */
export function SortableRowGroupDataTable({
children,
disabled,
diff --git a/packages/vitnode/src/components/table/row-order.ts b/packages/vitnode/src/components/table/row-order.ts
index 032c340ae2..c5c58e9739 100644
--- a/packages/vitnode/src/components/table/row-order.ts
+++ b/packages/vitnode/src/components/table/row-order.ts
@@ -8,9 +8,21 @@ import { moveRowId, type ReorderDetailsDataTable } from "./reorder-state";
type RowIdOf = T extends { id: infer TId } ? TId : DataTableRowId;
+/**
+ * Drag-to-reorder rows, by pointer, touch (press and hold) or keyboard, with
+ * screen-reader announcements. Dragging is turned off while the table is
+ * searched, filtered or sorted by anything but its `defaultOrder`.
+ */
export interface ReorderableDataTable {
+ /** Turns dragging off; the handles stay, disabled, with an explanation. */
disabled?: boolean;
+ /** The row's name in the drag handle's label and in announcements. Defaults to its position. */
getRowLabel?: (row: T) => string;
+ /**
+ * Saves the page's new order, given every row id on the page in that order.
+ * The table shows the new order at once; if this throws or rejects, it goes
+ * back and shows an error toast.
+ */
onReorder: (
ids: RowIdOf[],
details: ReorderDetailsDataTable,
@@ -22,6 +34,7 @@ interface RowOrderOverride {
key: string;
}
+/** The rows in their optimistic order, and the move that persists through `onReorder`. */
export const useRowOrderDataTable = ({
edges,
onReorder,
diff --git a/packages/vitnode/src/components/table/selection.tsx b/packages/vitnode/src/components/table/selection.tsx
index 8099079e43..85f3f555bd 100644
--- a/packages/vitnode/src/components/table/selection.tsx
+++ b/packages/vitnode/src/components/table/selection.tsx
@@ -15,13 +15,21 @@ import { SlidingNumber } from "../ui/sliding-number";
import { TableRow } from "../ui/table";
import { TooltipWithContent } from "../ui/tooltip";
+/**
+ * Row selection on the current page, from {@link useDataTableSelection}.
+ * Selection never spans pages: ids that leave the page are dropped.
+ */
export interface SelectionDataTable {
+ /** Every row on the page is selected (and there is at least one). */
allSelected: boolean;
clear: () => void;
isSelected: (id: DataTableRowId) => boolean;
+ /** The selected row ids. */
selected: DataTableRowId[];
+ /** Some, but not all, rows are selected. */
someSelected: boolean;
toggle: (id: DataTableRowId) => void;
+ /** Selects every row on the page (`true`) or none (`false`). */
toggleAll: (next: boolean) => void;
}
@@ -33,6 +41,33 @@ const getPortalContainer = () => document.body;
/** No DOM to portal into on the server, and the bar starts empty anyway. */
const getNoPortalContainer = () => null;
+/**
+ * The table's row selection, for the buttons passed as `ContentDataTable`'s
+ * `bulkActions` - e.g. a delete action reading `selected`.
+ *
+ * @throws Outside a `ContentDataTable` that has `bulkActions`.
+ *
+ * @example
+ * ```tsx
+ * import { useDataTableSelection } from "@vitnode/core/components/table/selection";
+ * import { Button } from "@vitnode/core/components/ui/button";
+ *
+ * const DeleteSelected = ({ onDelete }: { onDelete: (ids: number[]) => void }) => {
+ * const { clear, selected } = useDataTableSelection();
+ *
+ * return (
+ *
+ * );
+ * };
+ * ```
+ */
export const useDataTableSelection = (): SelectionDataTable => {
const value = React.use(SelectionContext);
@@ -45,6 +80,7 @@ export const useDataTableSelection = (): SelectionDataTable => {
return value;
};
+/** Holds the selection for one page of rows. `ContentDataTable` mounts it for `bulkActions`. */
export function SelectionProviderDataTable({
children,
rowIds,
@@ -92,6 +128,7 @@ export function SelectionProviderDataTable({
);
}
+/** The header checkbox: selects every row on the page, indeterminate when only some are. */
export function SelectAllDataTable() {
const t = useTranslations("core.global.data_table");
const { allSelected, someSelected, toggleAll } = useDataTableSelection();
@@ -106,6 +143,7 @@ export function SelectAllDataTable() {
);
}
+/** One row's checkbox. */
export function SelectRowDataTable({ id }: { id: DataTableRowId }) {
const t = useTranslations("core.global.data_table");
const { isSelected, toggle } = useDataTableSelection();
@@ -119,6 +157,7 @@ export function SelectRowDataTable({ id }: { id: DataTableRowId }) {
);
}
+/** A row marked `data-state="selected"` while it is selected. */
export function RowSelectableDataTable({
children,
id,
@@ -135,6 +174,11 @@ export function RowSelectableDataTable({
);
}
+/**
+ * The floating bar at the bottom of the viewport while rows are selected: the
+ * count, `actions`, and a button that clears the selection. Rendered into
+ * `document.body`, and only in the browser.
+ */
export function BulkActionsDataTable({
actions,
}: {
diff --git a/packages/vitnode/src/components/table/url-state.ts b/packages/vitnode/src/components/table/url-state.ts
index ff3a55847a..fc8ea6da81 100644
--- a/packages/vitnode/src/components/table/url-state.ts
+++ b/packages/vitnode/src/components/table/url-state.ts
@@ -6,8 +6,10 @@ const LAST = "last";
const CURSOR = "cursor";
const PAGE = "page";
+/** Rows per page while the URL names no `first` or `last`. */
export const DEFAULT_TABLE_PAGE_SIZE = 10;
+/** Shorter search text is dropped from the URL instead of searched for. */
export const MIN_TABLE_SEARCH_LENGTH = 3;
export type TableOrderDirection = "asc" | "desc";
@@ -15,6 +17,7 @@ export type TableOrderDirection = "asc" | "desc";
/** Whatever the caller has: a raw query string, or the params it parsed. */
export type TableSearch = string | URLSearchParams;
+/** A sort: the column (the `orderBy` param) and its direction (`order`). */
export interface TableOrder {
column: string;
order: TableOrderDirection;
@@ -31,6 +34,7 @@ const resetPagination = (params: URLSearchParams): void => {
params.delete(PAGE);
};
+/** Whether the URL is past the first page in cursor mode (`cursor` or `last` set). */
export const hasTableCursor = (search: TableSearch): boolean => {
const params = copy(search);
@@ -39,12 +43,18 @@ export const hasTableCursor = (search: TableSearch): boolean => {
);
};
+/** The page number in page-number mode; `1` when missing or invalid. */
export const readTablePage = (search: TableSearch): number => {
const page = Number(copy(search).get(PAGE));
return Number.isInteger(page) && page > 0 ? page : 1;
};
+/**
+ * Goes to `page` in page-number mode. Page 1 removes the parameter, and the
+ * `cursor` and `last` are dropped so the two modes never mix (`first` stays as
+ * the page size).
+ */
export const withTablePageNumber = (
search: TableSearch,
page: number | string,
@@ -90,6 +100,7 @@ export const withTableOrder = (
return params.toString();
};
+/** A header click: `desc` when `column` is already sorted `asc`, otherwise `asc`. */
export const toggleTableOrder = (
search: TableSearch,
{ column, defaultOrder }: { column: string; defaultOrder: TableOrder },
@@ -111,6 +122,7 @@ export const readTablePageSize = (search: TableSearch): number => {
return Number.isInteger(size) && size > 0 ? size : DEFAULT_TABLE_PAGE_SIZE;
};
+/** Shows `pageSize` rows per page, back on the first page. */
export const withTablePageSize = (
search: TableSearch,
pageSize: number | string,
diff --git a/packages/vitnode/src/components/ui/accordion.tsx b/packages/vitnode/src/components/ui/accordion.tsx
index 01654e74e4..ec352a3f2e 100644
--- a/packages/vitnode/src/components/ui/accordion.tsx
+++ b/packages/vitnode/src/components/ui/accordion.tsx
@@ -3,6 +3,9 @@ import { cn } from "cn";
import { ChevronDownIcon } from "lucide-react";
import React from "react";
+/**
+ * A Base UI accordion of collapsible sections, laid out as a full-width column.
+ */
function Accordion({
className,
...props
@@ -16,6 +19,9 @@ function Accordion({
);
}
+/**
+ * One section of an {@link Accordion}; every item but the last gets a bottom border.
+ */
function AccordionItem({
className,
...props
@@ -29,6 +35,10 @@ function AccordionItem({
);
}
+/**
+ * The section's header button, with a chevron that rotates while the section is
+ * open.
+ */
function AccordionTrigger({
className,
children,
@@ -54,6 +64,10 @@ function AccordionTrigger({
);
}
+/**
+ * The section's collapsible panel, which animates its height (only its opacity
+ * under reduced motion).
+ */
function AccordionContent({
className,
children,
diff --git a/packages/vitnode/src/components/ui/alert-dialog.tsx b/packages/vitnode/src/components/ui/alert-dialog.tsx
index d10c451956..17773d411c 100644
--- a/packages/vitnode/src/components/ui/alert-dialog.tsx
+++ b/packages/vitnode/src/components/ui/alert-dialog.tsx
@@ -14,8 +14,17 @@ const AlertDialogContext = React.createContext<{
setOpen: () => {},
});
+/**
+ * The surrounding {@link AlertDialog}'s `open` state and its `setOpen`, for
+ * closing the dialog from inside it, e.g. after an async confirm.
+ */
const useAlertDialog = () => React.use(AlertDialogContext);
+/**
+ * A Base UI alert dialog for confirming a destructive or important action.
+ * Works controlled (`open`) or uncontrolled, and shares its state through
+ * {@link useAlertDialog}.
+ */
function AlertDialog({
onOpenChange,
open: openProp,
@@ -51,6 +60,7 @@ function AlertDialog({
);
}
+/** The element that opens the dialog. */
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
@@ -80,6 +90,10 @@ function AlertDialogOverlay({
);
}
+/**
+ * The centered dialog panel, rendered in a portal over a scrim. `size` is
+ * `"default"` (max width `lg`) or `"sm"`; defaults to `"default"`.
+ */
function AlertDialogContent({
className,
size = "default",
@@ -103,6 +117,7 @@ function AlertDialogContent({
);
}
+/** Holds the title, description and optional media. */
function AlertDialogHeader({
className,
...props
@@ -119,6 +134,7 @@ function AlertDialogHeader({
);
}
+/** Holds the cancel and action buttons. */
function AlertDialogFooter({
className,
...props
@@ -135,6 +151,7 @@ function AlertDialogFooter({
);
}
+/** An icon or image shown in the header. */
function AlertDialogMedia({
className,
...props
@@ -151,6 +168,7 @@ function AlertDialogMedia({
);
}
+/** The dialog's accessible title. */
function AlertDialogTitle({
className,
...props
@@ -167,6 +185,7 @@ function AlertDialogTitle({
);
}
+/** The dialog's accessible description. */
function AlertDialogDescription({
className,
...props
@@ -185,6 +204,10 @@ function AlertDialogDescription({
type AlertDialogButtonSize = "default" | "lg" | "sm" | "xs";
+/**
+ * A {@link Button} that closes the dialog. `variant` and `size` default to
+ * `"default"`; the label defaults to the translated "Confirm".
+ */
function AlertDialogAction({
children,
className,
@@ -209,6 +232,11 @@ function AlertDialogAction({
);
}
+/**
+ * A {@link Button} that closes the dialog without acting. `variant` defaults to
+ * `"outline"`, `size` to `"default"`; the label defaults to the translated
+ * "Cancel".
+ */
function AlertDialogCancel({
children,
className,
diff --git a/packages/vitnode/src/components/ui/alert.tsx b/packages/vitnode/src/components/ui/alert.tsx
index 5450d00670..a71fadcc76 100644
--- a/packages/vitnode/src/components/ui/alert.tsx
+++ b/packages/vitnode/src/components/ui/alert.tsx
@@ -49,8 +49,14 @@ const ALERT_VARIANTS: Record<
},
};
+/** The look and meaning of an {@link Alert}. */
export type AlertVariant = keyof typeof ALERT_VARIANTS;
+/**
+ * A callout box for a message. `variant` (default `"default"`) sets its colors
+ * and icon; `warning` and `destructive` use `role="alert"`, the rest
+ * `role="status"`. `icon` replaces the variant's icon, and `null` hides it.
+ */
function Alert({
className,
variant = "default",
@@ -98,6 +104,7 @@ function Alert({
);
}
+/** The alert's heading line. */
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
) {
);
}
+/** The alert's body text. */
function AlertDescription({
className,
...props
@@ -127,6 +135,10 @@ function AlertDescription({
);
}
+/**
+ * A control pinned to the alert's top-right (end) corner, such as a dismiss
+ * button.
+ */
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
{
return [name.slice(0, dot), name.slice(dot)];
};
+/**
+ * The file name. A string keeps its extension visible while the name truncates,
+ * and the full name becomes the `title` tooltip.
+ */
function AttachmentTitle({
children,
className,
@@ -130,6 +145,7 @@ function AttachmentTitle({
);
}
+/** Secondary text, such as the file size or upload progress. */
function AttachmentDescription({
className,
...props
@@ -147,6 +163,7 @@ function AttachmentDescription({
);
}
+/** Holds the attachment's action buttons. */
function AttachmentActions({
className,
...props
@@ -163,6 +180,10 @@ function AttachmentActions({
);
}
+/**
+ * A small ghost icon {@link Button} for one action (size `"icon-xs"` by
+ * default). Has a generic `aria-label`; pass your own describing the action.
+ */
function AttachmentAction({
className,
variant,
@@ -181,6 +202,10 @@ function AttachmentAction({
);
}
+/**
+ * An invisible button over the whole card that makes it clickable
+ * (`type="button"` unless you `render` something else).
+ */
function AttachmentTrigger({
className,
render,
@@ -203,6 +228,7 @@ function AttachmentTrigger({
});
}
+/** A horizontally scrolling, snapping row of attachments. */
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
) {
return (
) {
);
}
+/** Overlapping avatars in a row, sharing one tooltip group. */
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -104,6 +112,11 @@ const AVATAR_LIFT_TRANSITION = {
bounce: 0.15,
} as const;
+/**
+ * One avatar in an {@link AvatarGroup}. With a `label`, it becomes focusable
+ * and shows the label as a tooltip; it lifts on hover and focus unless reduced
+ * motion is on.
+ */
function AvatarGroupItem({
children,
className,
@@ -154,6 +167,9 @@ function AvatarGroupItem({
);
}
+/**
+ * A "+N" bubble for the avatars a group leaves out, sized to match the group.
+ */
function AvatarGroupCount({
className,
...props
diff --git a/packages/vitnode/src/components/ui/badge.tsx b/packages/vitnode/src/components/ui/badge.tsx
index 7b0a4d9a57..4f78a0f2a7 100644
--- a/packages/vitnode/src/components/ui/badge.tsx
+++ b/packages/vitnode/src/components/ui/badge.tsx
@@ -5,6 +5,9 @@ import { cn } from "cn";
import { FEEDBACK_TONE_TEXT, SOLID_PRIMARY_SURFACE } from "./feedback-tone";
+/**
+ * The badge class names per `variant`, for styling another element as a badge.
+ */
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap tabular-nums transition-[color,background-color,border-color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
@@ -38,6 +41,11 @@ const badgeVariants = cva(
},
);
+/**
+ * A small label, rendered as a `span` (or your `render`). `variant` is
+ * `default`, `secondary`, `destructive`, `success`, `warning`, `outline`,
+ * `ghost` or `link`; defaults to `"default"`.
+ */
function Badge({
className,
variant = "default",
diff --git a/packages/vitnode/src/components/ui/bubble.tsx b/packages/vitnode/src/components/ui/bubble.tsx
index 9fd69d8a3c..d5945d3b78 100644
--- a/packages/vitnode/src/components/ui/bubble.tsx
+++ b/packages/vitnode/src/components/ui/bubble.tsx
@@ -4,6 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import * as React from "react";
+/** Stacks chat bubbles in a column. */
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-e-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
@@ -22,6 +23,10 @@ const buttonGroupVariants = cva(
},
);
+/**
+ * Joins buttons and inputs into one connected control, with `role="group"`.
+ * `orientation` defaults to `"horizontal"`.
+ */
function ButtonGroup({
className,
orientation,
@@ -38,6 +43,10 @@ function ButtonGroup({
);
}
+/**
+ * A static text segment inside a {@link ButtonGroup}, rendered as a `div` (or
+ * your `render`).
+ */
function ButtonGroupText({
className,
render,
@@ -61,6 +70,7 @@ function ButtonGroupText({
});
}
+/** A divider between segments of a {@link ButtonGroup}; vertical by default. */
function ButtonGroupSeparator({
className,
orientation = "vertical",
diff --git a/packages/vitnode/src/components/ui/button.tsx b/packages/vitnode/src/components/ui/button.tsx
index 5b2104775b..12abb968ad 100644
--- a/packages/vitnode/src/components/ui/button.tsx
+++ b/packages/vitnode/src/components/ui/button.tsx
@@ -9,6 +9,10 @@ import { ClientButton } from "./button-client";
const coarsePointerHitArea =
"relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-11 pointer-coarse:after:-translate-1/2";
+/**
+ * The button class names per `variant` and `size`, for styling another element
+ * (such as a link) as a button.
+ */
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow,opacity,scale,filter] duration-150 ease-out outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:not-in-data-[slot=button-group]:scale-97 disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 cursor-pointer",
{
@@ -55,6 +59,10 @@ const buttonVariants = cva(
},
);
+/**
+ * Props of {@link Button}. The icon sizes require an `aria-label`, since the
+ * button has no visible text.
+ */
type ButtonProps = Omit &
VariantProps &
(
@@ -66,6 +74,15 @@ type ButtonProps = Omit &
isLoading?: boolean;
};
+/**
+ * A Base UI button. `variant` is `default`, `outline`, `secondary`, `ghost`,
+ * `destructive`, `success`, `warning` or `link`; `size` is `default`, `xs`,
+ * `sm`, `lg` or an icon size (`icon`, `icon-xs`, `icon-sm`, `icon-lg`), both
+ * defaulting to `"default"`. `isLoading` disables it and swaps its content for
+ * a spinner, with a translated "Loading..." label; `disabledTooltip` explains
+ * why a disabled button is disabled and keeps it focusable. The `icon`,
+ * `icon-xs` and `icon-sm` sizes get a 44px touch target on coarse pointers.
+ */
function Button(props: ButtonProps) {
return ;
}
diff --git a/packages/vitnode/src/components/ui/calendar.tsx b/packages/vitnode/src/components/ui/calendar.tsx
index 178b2cbf5b..a7089680e7 100644
--- a/packages/vitnode/src/components/ui/calendar.tsx
+++ b/packages/vitnode/src/components/ui/calendar.tsx
@@ -18,6 +18,10 @@ import { useLocale, useTranslations } from "use-intl";
import { type ButtonProps, buttonVariants } from "./button";
import { getFirstDayOfWeek, toDateString } from "./calendar-utils";
+/**
+ * Props of {@link Calendar}: every `react-day-picker` `DayPicker` prop, plus
+ * the nav buttons' `buttonVariant`.
+ */
type CalendarProps = React.ComponentProps & {
buttonVariant?: ButtonProps["variant"];
};
@@ -72,6 +76,12 @@ const CalendarWeekNumber = ({
);
+/**
+ * A `react-day-picker` calendar styled for VitNode. Without a `locale` prop it
+ * follows the UI language: month captions are formatted in it, the week starts
+ * on its first day, and the accessibility labels are translated. Shows outside
+ * days by default; `buttonVariant` for the nav buttons defaults to `"ghost"`.
+ */
const Calendar = ({
className,
classNames,
@@ -240,6 +250,10 @@ const Calendar = ({
);
};
+/**
+ * The button for one day, used by {@link Calendar}; it takes focus when the day
+ * is focused and exposes the selection and range state as `data-*` attributes.
+ */
const CalendarDayButton = ({
className,
day,
diff --git a/packages/vitnode/src/components/ui/card.tsx b/packages/vitnode/src/components/ui/card.tsx
index d798eb8005..3b6957afb1 100644
--- a/packages/vitnode/src/components/ui/card.tsx
+++ b/packages/vitnode/src/components/ui/card.tsx
@@ -1,6 +1,10 @@
import { cn } from "cn";
import React from "react";
+/**
+ * A bordered surface for grouped content. `size` is `"default"` or `"sm"`
+ * (tighter spacing); defaults to `"default"`.
+ */
function Card({
className,
size = "default",
@@ -19,6 +23,7 @@ function Card({
);
}
+/** The card's top area for its title, description and action. */
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
) {
);
}
+/** The card's heading. */
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
) {
);
}
+/** Secondary text under the title. */
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
) {
);
}
+/** A control placed at the header's top-right (end) corner. */
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
) {
);
}
+/** The card's main body. */
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
) {
);
}
+/** The card's bottom area, e.g. for buttons. */
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
+/** Options of {@link Carousel}. */
interface CarouselProps {
+ /** Embla options, e.g. `{ loop: true }`. */
opts?: CarouselOptions;
+ /** Scroll axis. Defaults to `"horizontal"`. */
orientation?: "horizontal" | "vertical";
+ /** Embla plugins, e.g. autoplay. */
plugins?: CarouselPlugin;
+ /**
+ * Receives the Embla API once the carousel is ready, for driving it from
+ * outside.
+ */
setApi?: (api: CarouselApi) => void;
}
@@ -53,6 +62,11 @@ function useCarousel() {
return context;
}
+/**
+ * An Embla carousel region (`aria-roledescription="carousel"`). Arrow keys move
+ * between slides, respecting right-to-left layouts, except while typing in a
+ * field.
+ */
function Carousel({
orientation = "horizontal",
opts,
@@ -159,6 +173,7 @@ function Carousel({
);
}
+/** The scrolling track that holds the slides. */
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
@@ -180,6 +195,7 @@ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
);
}
+/** One slide (`aria-roledescription="slide"`). */
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
@@ -198,6 +214,10 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
);
}
+/**
+ * Scrolls to the previous slide; disabled at the start. Labelled with the
+ * translated "Previous".
+ */
function CarouselPrevious({
className,
variant = "outline",
@@ -233,6 +253,10 @@ function CarouselPrevious({
);
}
+/**
+ * Scrolls to the next slide; disabled at the end. Labelled with the translated
+ * "Next".
+ */
function CarouselNext({
className,
variant = "outline",
diff --git a/packages/vitnode/src/components/ui/cascader-utils.ts b/packages/vitnode/src/components/ui/cascader-utils.ts
index e8d151b7af..9d80423490 100644
--- a/packages/vitnode/src/components/ui/cascader-utils.ts
+++ b/packages/vitnode/src/components/ui/cascader-utils.ts
@@ -1,5 +1,9 @@
import type React from "react";
+/**
+ * One option in a {@link Cascader}-style tree. An option with `children` is a
+ * branch that opens the next level; only leaves can be chosen.
+ */
export interface CascaderOption {
children?: CascaderOption[];
disabled?: boolean;
diff --git a/packages/vitnode/src/components/ui/cascader.tsx b/packages/vitnode/src/components/ui/cascader.tsx
index 62dbdc34af..e5bd23c352 100644
--- a/packages/vitnode/src/components/ui/cascader.tsx
+++ b/packages/vitnode/src/components/ui/cascader.tsx
@@ -51,17 +51,41 @@ interface CascaderProps {
"aria-invalid"?: boolean;
"aria-labelledby"?: string;
className?: string;
+ /** The selected leaf's `value` when uncontrolled. Defaults to `null`. */
defaultValue?: null | string;
disabled?: boolean;
+ /**
+ * Opens a branch on `"click"` (default) or `"hover"`; hover only applies to
+ * the columns layout.
+ */
expandTrigger?: "click" | "hover";
id?: string;
+ /**
+ * `"drill"` (default) shows one level at a time; `"columns"` shows every
+ * level side by side. Phones always use drill.
+ */
layout?: "columns" | "drill";
+ /**
+ * Called with the chosen leaf's `value` (or `null` when cleared) and the path
+ * of options leading to it.
+ */
onValueChange?: (value: null | string, path: CascaderOption[]) => void;
+ /** The option tree. */
options: CascaderOption[];
+ /** Shown on the trigger while nothing is selected. */
placeholder?: string;
+ /**
+ * Adds a search box that matches option labels across every level, ignoring
+ * accents. Defaults to `false`.
+ */
searchable?: boolean;
+ /**
+ * Joins the labels of the selected path on the trigger. Defaults to `" / "`.
+ */
separator?: string;
+ /** Shows a button that clears the selection. Defaults to `false`. */
showClear?: boolean;
+ /** The selected leaf's `value` when controlled; `null` for none. */
value?: null | string;
}
@@ -569,6 +593,10 @@ const CascaderTrigger = ({
);
};
+/**
+ * A popover picker for one leaf in a tree of options, controlled (`value`) or
+ * uncontrolled (`defaultValue`).
+ */
const Cascader = ({
defaultValue = null,
disabled = false,
diff --git a/packages/vitnode/src/components/ui/chart-utils.ts b/packages/vitnode/src/components/ui/chart-utils.ts
index 3cd0fb644a..01501d0b62 100644
--- a/packages/vitnode/src/components/ui/chart-utils.ts
+++ b/packages/vitnode/src/components/ui/chart-utils.ts
@@ -10,12 +10,18 @@ export const chartColorKeys = (config: ChartConfig) =>
.filter(([, item]) => item.theme ?? item.color)
.map(([key]) => key);
+/**
+ * The `@tanstack/charts` color scale for a {@link ChartConfig}: each key that
+ * has a color maps to its `var(--color-)` CSS variable, which
+ * `ChartContainer` defines.
+ */
export const chartColor = (config: ChartConfig): ChartColorOptions => {
const domain = chartColorKeys(config);
return { domain, range: domain.map(key => `var(--color-${key})`) };
};
+/** The spring VitNode charts use to move their tooltip. */
export const chartTooltipMotion = {
damping: 40,
mass: 1,
diff --git a/packages/vitnode/src/components/ui/chart.tsx b/packages/vitnode/src/components/ui/chart.tsx
index 07d91d9c4a..37fa4e9125 100644
--- a/packages/vitnode/src/components/ui/chart.tsx
+++ b/packages/vitnode/src/components/ui/chart.tsx
@@ -9,6 +9,11 @@ import { chartColorKeys } from "./chart-utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
+/**
+ * Each series' label, icon and color, keyed by the series key. Give a `color`
+ * or a `theme` with a `light` and a `dark` color; each becomes the CSS variable
+ * `--color-` inside the chart.
+ */
export type ChartConfig = Record<
string,
(
@@ -58,6 +63,12 @@ const CHART_THEME = {
"--ts-chart-tooltip-active-row-shadow": "none",
} as React.CSSProperties;
+/**
+ * Wraps a `@tanstack/charts` chart: it applies the VitNode chart theme, defines
+ * a `--color-