diff --git a/README.md b/README.md index d36834635..badb7919f 100644 --- a/README.md +++ b/README.md @@ -3,97 +3,101 @@ - VitNode Logo + VitNode

-# 🚀 VitNode +# VitNode -**VitNode** is an extendable framework for building modern applications with TanStack Start and Hono.js. It provides a structured, plugin-based architecture that makes development faster and less complex. +VitNode is a plugin-first framework for community applications. It combines a +TanStack Start front end, Hono API, Postgres, and AdminCP so features can ship as +installable plugins instead of becoming permanent residents of one giant app. > [!NOTE] -> 🚧 You're viewing the `canary` branch (VitNode 2.0), which is under active development and may contain unstable code. For the stable version, check the `v1` branch. +> This is the VitNode 2.0 `canary` branch. It is actively developed, so use the +> docs and source together while it keeps getting sharper. -## 🏁 Getting Started +## Start here -### Supported Package Managers +You need Node.js 22+ and Postgres (or Docker). Create an app with the package +manager you use every day: -- [bun](https://bun.com/) (min: v1.1, recommended: v1.3) -- [pnpm](https://pnpm.io/) (min: v10, recommended: v11) -- [node.js](https://nodejs.org/) (min: v22, recommended: v24) +### Bun -### Quick Setup +```bash +bun create vitnode-app@canary +``` -1. **Install dependencies** +### pnpm - ```bash - pnpm create vitnode-app@canary - // or - bun create vitnode-app@canary - // or - npx create-vitnode-app@canary - ``` +```bash +pnpm create vitnode-app@canary +``` -2. **Start database container** +### npm - ```bash - pnpm docker:dev - // or - bun docker:dev - // or - npm run docker:dev - ``` +```bash +npm create vitnode-app@canary +``` -3. **Launch development server** - ```bash - pnpm dev - // or - bun dev - // or - npm run dev - ``` +When prompted, choose **Turborepo** if you plan to build plugins. VitNode puts +product pages, APIs, data, translations, and AdminCP extensions in plugins first. -## 📝 Available Scripts +Start local services, migrate, and run the app: -- `pnpm dev` - Start development server with auto-reload -- `pnpm build` - Build for production -- `pnpm start` - Start production server -- `pnpm lint` - Check code quality -- `pnpm lint:fix` - Fix code quality issues -- `pnpm db:migrate` - Run database migrations by hand (`pnpm dev` already does this for you) -- `pnpm dev:email` - Start email development server +### Bun -## ✨ What's New in VitNode 2.0 +```bash +bun run docker:dev +bun run db:migrate +bun dev +``` -- **Simplified Architecture**: Single-repo application structure (no monorepo) -- **Modern Backend**: Hono.js replaces NestJS for better performance -- **ESM-Only**: Full support for ECMAScript Modules -- **AI Integration**: New AI Rules and Multi-Cloud Provider support -- **Enhanced Plugin System**: Improved CLI tools for plugins -- **Better Documentation**: Completely rewritten docs and website -- **Streamlined Configuration**: Single config file for all settings -- **Zod 4**: Upgraded to the latest version for schema validation +### pnpm -## 🔍 Project Scope +```bash +pnpm docker:dev +pnpm db:migrate +pnpm dev +``` -VitNode provides: +### npm -- **Plugin Architecture**: Extend core functionality with custom plugins -- **Admin Control Panel**: Built-in management interface -- **Authentication System**: Support for credentials and SSO providers -- **Role-Based Access Control**: Comprehensive permission management -- **Internationalization**: Multi-language support out of the box -- **Theme System**: Light/dark mode with customizable components -- **API Documentation**: Auto-generated OpenAPI documentation +```bash +npm run docker:dev +npm run db:migrate +npm run dev +``` -## 📊 Project Status +Open `http://localhost:3000`, then sign in at `/admin`. -VitNode 2.0 is currently in **active development** (canary branch). While many features are functional, expect changes and improvements as we work toward a stable release. +## Build features as plugins -> [!NOTE] -> 📚 Documentation is still in progress. Our website is under construction! +1. [Create a plugin](https://vitnode.com/docs/dev/plugins/create). +2. Give it a route, API module, data model, or AdminCP screen. +3. Register the package in the host app’s configuration. +4. Deploy from the [Start here](https://vitnode.com/docs/dev/deployments/self-hosted) documentation. + +The host app owns composition and global infrastructure. The plugin owns the +feature. That boundary pays rent surprisingly quickly. + +## Documentation + +- [Getting started](https://vitnode.com/docs/dev/setup) +- [Build your first plugin](https://vitnode.com/docs/guides/first-plugin) +- [Plugin routes](https://vitnode.com/docs/dev/routing) +- [Admin Control Panel](https://vitnode.com/docs/dev/plugins/admin) +- [Content delivery and SEO](https://vitnode.com/docs/dev/content-engine/content-delivery-and-seo) +- [Write documentation](https://vitnode.com/docs/dev/documentation) + +## Project scope + +- Plugin architecture with TanStack Start routes and typed Hono API modules +- Postgres data models, migrations, search, uploads, and content delivery +- Built-in authentication, roles, staff permissions, i18n, and AdminCP +- Self-hosted and cloud deployment guidance -## 📄 License +## License MIT License diff --git a/apps/web/content/docs/dev/cache.mdx b/apps/web/content/docs/dev/cache.mdx index baefd7262..300d4ae24 100644 --- a/apps/web/content/docs/dev/cache.mdx +++ b/apps/web/content/docs/dev/cache.mdx @@ -27,12 +27,13 @@ cache: a route's `loader` warms an entry, the component reads the same one back, and a mutation invalidates exactly what it changed. One request per navigation instead of one per component. -### Warm it in the loader, read it in the screen +### Load it from the plugin route -```ts title="src/routes/announcements.tsx" -export const Route = createFileRoute('/announcements')({ - loader: async ({ context }) => - await context.queryClient.ensureQueryData(announcementsQueryOptions()), +```ts title="plugins/announcements/src/routes/announcements-page.tsx" +import { definePluginRoute } from '@vitnode/core/routing' + +export const route = definePluginRoute({ + load: async () => await fetchAnnouncements(), // [!code ++] }) ``` @@ -40,12 +41,9 @@ export const Route = createFileRoute('/announcements')({ const { data } = useSuspenseQuery(announcementsQueryOptions()) ``` -The `queryOptions` object is what the two halves share - the same key, the same -fetcher, the same `staleTime` - so the screen can never ask for something the -loader did not warm. See [Data loading](/docs/dev/data-loading) for the -isomorphic fetcher that sits behind it, and [Loading -states](/docs/dev/routing/loading-states) for what the screen shows while an -entry is still cold. +Keep the fetcher and any `queryOptions` helper inside the plugin too. The plugin +route is the SSR boundary; its screen can reuse the same query key for client +updates. See [Data loading](/docs/dev/data-loading) for the isomorphic fetcher. ### Pick a lifetime that matches the data @@ -55,12 +53,17 @@ can sit for minutes; anything per-visitor should be short or zero: ```ts export const announcementsQueryOptions = () => queryOptions({ - queryKey: ['announcements'], + queryKey: ['@acme/announcements', 'announcements'], // [!code ++] queryFn: fetchAnnouncements, staleTime: 5 * 60 * 1000, }) ``` + + `invalidateQueries` matches prefixes. Put the plugin ID first so one plugin + cannot read from or invalidate another plugin's similarly named key. + + A session, a permission set or a personal file list must not share a cache entry with anybody else. Key it by the identity it belongs to, and keep the @@ -81,7 +84,9 @@ moved: const queryClient = useQueryClient() await publishAnnouncement(id) -await queryClient.invalidateQueries({ queryKey: ['announcements'] }) +await queryClient.invalidateQueries({ + queryKey: ['@acme/announcements', 'announcements'], // [!code ++] +}) ``` A row that could be on any page under any sort means invalidating the list's @@ -112,7 +117,7 @@ with another plugin's. `remember` takes a key, a TTL in seconds, and the loader to run on a miss. It returns the value either way, so the call site never branches: -```ts title="src/api/modules/stats/routes/overview.route.ts" +```ts title="plugins/announcements/src/api/modules/stats/routes/overview.route.ts" handler: async c => { // [!code ++:5] const stats = await c.get('cache').remember( @@ -137,7 +142,7 @@ that is a miss you cannot afford. The write that changes the answer is the write that expires it. There is no TTL short enough to substitute for this: -```ts title="src/api/modules/stats/routes/update.route.ts" +```ts title="plugins/announcements/src/api/modules/stats/routes/update.route.ts" await c.get('cache').delete(`stats:${containerId}`) // [!code ++] ``` diff --git a/apps/web/content/docs/dev/captcha/custom-adapter.mdx b/apps/web/content/docs/dev/captcha/custom-adapter.mdx index ae70ef9fd..c876fa2c4 100644 --- a/apps/web/content/docs/dev/captcha/custom-adapter.mdx +++ b/apps/web/content/docs/dev/captcha/custom-adapter.mdx @@ -19,7 +19,7 @@ import { TypeTable } from 'fumadocs-ui/components/type-table' Three values, and a `
` for the widget to land in. -```tsx title="src/site/contact/contact-form.tsx" +```tsx title="plugins/contact/src/views/contact/contact-form.tsx" import type React from 'react' import { useCaptcha } from '@vitnode/core/hooks/use-captcha' // [!code ++] @@ -129,31 +129,16 @@ export const createContactRoute = buildRoute({ -### Read the deployment's config +### Read the deployment's config in the plugin screen -The site key is public, and `middlewareConfigQueryOptions()` is the read as a -TanStack Query definition. Warm it in the route's `loader` so the widget is not -waiting on a request after hydration. +The site key is public. A plugin page can read it with the shared TanStack Query +definition before rendering its form: -```tsx title="src/routes/_main/contact.tsx" -import { createFileRoute } from '@tanstack/react-router' -import { middlewareConfigQueryOptions } from '@vitnode/core/tanstack/auth' // [!code ++] - -import { ContactScreen } from '#/site/contact/contact-screen' - -export const Route = createFileRoute('/_main/contact')({ - // [!code ++:2] - loader: async ({ context }) => - await context.queryClient.ensureQueryData(middlewareConfigQueryOptions()), - component: ContactScreen, -}) -``` - -```tsx title="src/site/contact/contact-screen.tsx" +```tsx title="plugins/contact/src/routes/contact-page.tsx" import { useSuspenseQuery } from '@tanstack/react-query' import { middlewareConfigQueryOptions } from '@vitnode/core/tanstack/auth' -import { ContactForm } from './contact-form' +import { ContactForm } from '../views/contact-form' export const ContactScreen = () => { const { data: config } = useSuspenseQuery(middlewareConfigQueryOptions()) @@ -243,11 +228,11 @@ A minimal captcha-gated feature is four files, and only one of them knows the hook exists. - - - + + + - + diff --git a/apps/web/content/docs/dev/content-engine/content-delivery-and-seo.mdx b/apps/web/content/docs/dev/content-engine/content-delivery-and-seo.mdx index f34bfb9c1..c44f03f17 100644 --- a/apps/web/content/docs/dev/content-engine/content-delivery-and-seo.mdx +++ b/apps/web/content/docs/dev/content-engine/content-delivery-and-seo.mdx @@ -1,167 +1,131 @@ --- -title: Content Delivery & SEO -description: Deliver Content Engine records over the web with TanStack Start, automatic 308 redirects, plugin route manifests, and rich SEO metadata. +title: Content Delivery and SEO +description: Deliver Content Engine records from a plugin with canonical metadata, slug redirects, hreflang, and XML sitemap support. icon: Compass --- -import { Step, Steps } from 'fumadocs-ui/components/steps' +import { RouteIcon, SearchIcon } from 'lucide-react' -Content Engine provides delivery abstractions to turn your schema into public URLs. It handles the heavy lifting: automatic 308 redirects when slugs change, Open Graph tags, canonical links, hreflang alternates, and XML sitemaps. +Content delivery starts in the plugin that owns the content type. Opt into the +public API first, then let the same plugin claim the page URL. Search engines +get a stable story; future you gets fewer scattered files. -Best of all: in VitNode, delivery lives in your **plugin** first. That means your content types and their public views ship together as one reusable package. - -{/* Image prompt: A clean infographic showing a Content Engine article moving through TanStack Start routing. Left: Database record with slug "launch-day". Middle: Plugin route manifest resolving /articles/:slug with automatic 308 redirect logic. Right: Rendered HTML page with rich SEO tags in the head and localized strings in the body. Dark theme, 1600x800. */} - -## Step-by-Step Delivery Setup +{/* Image prompt: Dark-theme flow diagram showing a Content Engine record with a slug, a plugin route manifest for /articles/:slug, and a rendered page head containing title, description, canonical URL, Open Graph, hreflang, and sitemap symbols. 1600x900. */} + - -### Enable Delivery in your Content Definition +### Enable public delivery on the content type -Define your content type with `delivery` enabled. Configure its base path, SEO fields, and sitemap settings: +`publicApi` explicitly chooses exposed fields. Delivery then projects only those +fields into metadata and sitemap entries. -```ts title="plugins/example/src/content/article.ts" +```ts title="plugins/site-notes/src/content/article.ts" import { defineContentType, field } from '@vitnode/core/content' export const articleContentType = defineContentType({ - id: 'example.article', - tableName: 'example_articles', - publication: true, - editorial: true, - // [!code ++:13] + id: '@acme/site-notes.article', + tableName: 'site_notes_articles', + publication: { enabled: true }, + editorial: { enabled: true }, + fields: { + excerpt: field.textarea({ nullable: true }), + slug: field.slug({ source: 'title' }), + title: field.text({ required: true }), + }, + // [!code ++:17] + publicApi: { + enabled: true, + fields: ['id', 'title', 'slug', 'excerpt', 'publishedAt'], + path: 'articles', + }, delivery: { - basePath: '/articles', - redirects: true, // Automatically 308-redirects old slugs on rename + enabled: true, + redirects: { enabled: true }, seo: { - titleField: 'title', descriptionField: 'excerpt', + titleField: 'title', }, - sitemap: { - changefreq: 'weekly', - priority: 0.8, - }, - }, - fields: { - title: field.text({ required: true }), - slug: field.slug({ from: 'title' }), - excerpt: field.textarea({ nullable: true }), + sitemap: { enabled: true, changeFrequency: 'weekly', priority: 0.8 }, }, }) ``` - - - -### Claim the Dynamic URL in your Plugin Manifest + + -Plugins claim routes as plain data. Notice VitNode's parameter spelling: `:slug`, which TanStack Start maps directly to `$slug`. +### Claim the public URL in the plugin -```ts title="plugins/example/src/routes/manifest.ts" -import type { PluginRouteDefinition } from '@vitnode/core/routing' - -export const routes: PluginRouteDefinition[] = [ +```ts title="plugins/site-notes/src/routes/manifest.ts" +export const routes = [ // [!code ++:6] { entry: 'routes/article-page', - id: 'article-view', - namespaces: ['@vitnode/example.articles'], + id: 'article', path: '/articles/:slug', }, ] ``` - - - -### Implement the Plugin Route Module + + -Export a default component and a `route` definition using `definePluginRoute`. No framework dependencies needed—just clean, isomorphic code: +### Render data and metadata from the plugin route -```tsx title="plugins/example/src/routes/article-page.tsx" +```tsx title="plugins/site-notes/src/routes/article-page.tsx" import type { PluginRoutePageProps } from '@vitnode/core/routing' import { definePluginRoute } from '@vitnode/core/routing' -import { useTranslations } from 'use-intl' // [!code ++] -import { fetchArticle } from '../api/fetch-article' -interface ArticleData { - excerpt: string - publishedAt: string +interface Article { + excerpt: string | null title: string } -// [!code ++:9] +// [!code ++:8] export const route = definePluginRoute({ - load: async ({ params }) => { - const article = await fetchArticle(params.slug) - return article - }, + load: async ({ params }) => await fetchArticle(params.slug), head: ({ loaderData }) => ({ - title: loaderData?.title, description: loaderData?.excerpt, + title: loaderData?.title, }), }) -const ArticlePage = ({ loaderData }: PluginRoutePageProps) => { - const t = useTranslations('@vitnode/example.articles') // [!code ++] - - return ( -
-
-

- {loaderData.title} -

-

- {t('published_on', { date: loaderData.publishedAt })} -

-
- -
-

{loaderData.excerpt}

-
-
- ) -} +const ArticlePage = ({ loaderData }: PluginRoutePageProps
) => ( +
+

{loaderData.title}

+

{loaderData.excerpt}

+
+) export default ArticlePage ``` - - - -### Optional: Override from the Host Application - -Need a totally unique design for this one site? You can declare a route file directly in your TanStack Start app: - -```tsx title="apps/web/src/routes/_main/articles.$slug.tsx" -import { createFileRoute } from '@tanstack/react-router' -import { pageHead } from '#/lib/page-head' // [!code ++] - -export const Route = createFileRoute('/_main/articles/$slug')({ - // [!code ++:4] - head: () => - pageHead({ - title: 'Article - VitNode', - robots: 'index, follow', - }), - component: AppArticlePage, -}) - -function AppArticlePage() { - const { slug } = Route.useParams() - return
Custom host layout for article: {slug}
-} -``` - -
- + -## Automatic SEO Capabilities - -VitNode gives your content search engine superpowers without the manual headaches: - -- **308 Permanent Redirects**: Renaming a slug automatically issues a 308 redirect from the old URL to the new one, saving your Google rank from 404 disasters. -- **Canonical URLs**: Formatted cleanly using your application's base URL and canonical slug. -- **Hreflang Tags**: Emits `` tags for active translations so multilingual crawlers stay happy. -- **XML Sitemaps**: Pre-configured XML sitemaps served straight from the API. + + Do not recreate the article page in the host app. The content type, slug + rules, public API, and public route evolve together, so they belong together. + + +## What search engines receive + +- Canonical URLs and optional Open Graph fields from `delivery.seo`. +- Redirect history for editorial content when a public slug changes. +- Localized alternates when you configure `hreflang`. +- Sitemap entries for public records when `sitemap.enabled` is true. + + + } + title="Plugin routing" + description="Add a route module with loaders, metadata, and a stable URL contract." + href="/docs/dev/routing" + /> + } + title="Public API and caching" + description="Expose deliberate fields and understand cache invalidation." + href="/docs/dev/content-engine/public-api-and-caching" + /> + diff --git a/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx b/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx index 435d1ba80..c60b07fd6 100644 --- a/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx +++ b/apps/web/content/docs/dev/content-engine/public-api-and-caching.mdx @@ -1,128 +1,109 @@ --- -title: Public API, Search & Caching -description: Step-by-step guide to exposing public read-only endpoints, field allowlists, full-text search indexing, and SWR tag-based caching. +title: Public API and Caching +description: Expose safe Content Engine fields from a plugin API and configure cache invalidation without framework-specific server actions. icon: Globe --- -Content Engine allows exposing read-only endpoints to your web frontend while keeping administrative data private. +import { NetworkIcon, SearchIcon } from 'lucide-react' -## Prerequisites & Context - -Public read access requires a content type definition (e.g. `articleContentType` in `src/content/article.ts`) compiled into a server model `articleContent` in `src/database/articles.ts`: - -```ts title="src/database/articles.ts" -import { createContentModel } from '@vitnode/core/content/server' -import { articleContentType } from '@/content/article' - -export const articleContent = createContentModel(articleContentType) -``` - -- **Field Allowlisting**: By default, no fields are exposed publicly. You must explicitly declare `publicApi.fields`. -- **`buildContentPublicModule`**: An API plugin module from `@vitnode/core/content/server` that mounts public endpoints at `/api/{plugin}/{entity}`. - ---- - -## Step-by-Step Implementation +Public content is an API concern owned by the plugin. Start with an explicit +allowlist; no field is public just because it looked innocent in a database +column at 2 a.m. + - -### Step 1: Configure Public API Field Allowlist +### Define the public response -In `src/content/article.ts`, add `publicApi` and list fields allowed for public reads: - -```ts title="src/content/article.ts" +```ts title="plugins/site-notes/src/content/article.ts" export const articleContentType = defineContentType({ - id: 'example.article', - tableName: 'example_articles', - publication: true, - publicApi: { - // [!code ++] - fields: ['title', 'code', 'excerpt', 'publishedAt', 'author'], // [!code ++] - }, // [!code ++] + id: '@acme/site-notes.article', + tableName: 'site_notes_articles', fields: { - title: field.text({ required: true }), - code: field.text({ required: true }), + adminNotes: field.textarea({ nullable: true }), excerpt: field.textarea({ nullable: true }), - adminNotes: field.textarea({ nullable: true }), // Excluded from public API + slug: field.slug({ source: 'title' }), + title: field.text({ required: true }), + }, + // [!code ++:5] + publicApi: { + enabled: true, + fields: ['id', 'title', 'slug', 'excerpt', 'publishedAt'], + path: 'articles', }, }) ``` - +`adminNotes` remains private because it is not in `fields`. - -### Step 2: Register Public API Module + + -Attach `buildContentPublicModule` at the root level of your API plugin configuration: +### Build the public module in the plugin API -```ts title="src/config.api.ts" -import { buildContentPublicModule } from '@vitnode/core/content/server' // [!code ++] -import { articleContent } from '@/database/articles' +```ts title="plugins/site-notes/src/config.api.ts" +import { buildApiPlugin } from '@vitnode/core/api/lib/plugin' +import { buildContentPublicModule } from '@vitnode/core/content/server' -export const exampleApiPlugin = () => +import { CONFIG_PLUGIN } from './const' +import { articleContent } from './database/articles' + +export const siteNotesApiPlugin = () => buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, modules: [ - adminModule, + // [!code ++:4] buildContentPublicModule({ - // [!code ++] - pluginId: CONFIG_PLUGIN.pluginId, // [!code ++] - contentTypes: [articleContent], // [!code ++] - }), // [!code ++] + contentTypes: [articleContent], + pluginId: CONFIG_PLUGIN.pluginId, + }), ], }) ``` -This creates: - -- `GET /api/example/articles`: Paginated list of published articles. -- `GET /api/example/articles/[id]`: Single published article details. +The module serves published records under the plugin’s public content path, for +example `/api/@acme/site-notes/articles`. - + + - -### Step 3: Enable Global Search Indexing +### Only configure revalidation when a front end caches renders -Add `search` options to automatically sync items with the global search index: +Content mutations already invalidate VitNode’s content tags. If a separate front +end caches its rendered pages, add its origin so the API can notify it through +the framework-neutral revalidation endpoint: -```ts title="src/content/article.ts" -export const articleContentType = defineContentType({ - id: 'example.article', - tableName: 'example_articles', - search: { +```ts title="apps/api/src/vitnode.api.config.ts" +export const vitNodeApiConfig = buildApiConfig({ + content: { // [!code ++] - titleField: 'title', // [!code ++] - textField: 'excerpt', // [!code ++] - }, // [!code ++] - fields: { - title: field.text({ required: true }), - excerpt: field.textarea({ nullable: true }), + revalidateOrigins: ['https://www.example.com'], }, }) ``` - - - -### Step 4: Manage SWR Cache Tags in Server Actions - -Use VitNode's cache helpers to invalidate or revalidate tags inside Server Actions: - -```ts title="src/actions/update-article.ts" -'use server' - -import { revalidateTag, updateTag } from '@vitnode/core/cache' - -export async function updateArticleAction(id: number, data: unknown) { - // Perform update... - - // Revalidate SWR list and item tags - revalidateTag(`content-public-item-example-article-${id}`, 'max') - updateTag(`user-${userId}`) -} -``` - - +Leave this unset when the front end reads directly from the public content API. +There is no server action to wire up in a plugin. + + +## Keep the public surface intentional + +Use a plugin route for the page that consumes the endpoint, and add search +indexing only when users need to discover the content outside its own section. + + + } + title="Plugin API modules" + description="Add typed Hono modules and routes alongside the feature they serve." + href="/docs/dev/plugins/api/modules" + /> + } + title="Search" + description="Choose Postgres or Elasticsearch search for plugin-owned records." + href="/docs/dev/search" + /> + diff --git a/apps/web/content/docs/dev/data-loading.mdx b/apps/web/content/docs/dev/data-loading.mdx index 79f19b3da..f98b87640 100644 --- a/apps/web/content/docs/dev/data-loading.mdx +++ b/apps/web/content/docs/dev/data-loading.mdx @@ -4,9 +4,8 @@ description: Load data in TanStack Start routes with server-side query warming a icon: DownloadCloud --- -VitNode loads data through two primary mechanisms: -1. **Plugin Routes**: Loaded through `definePluginRoute({ load })` with isomorphic data passing. -2. **Host App Routes**: Warmed in the route `loader` via TanStack Query and read in components via `useSuspenseQuery`. +VitNode loads feature data through plugin routes. `definePluginRoute({ load })` +runs for SSR and client navigation, then hands typed data to the plugin page. ## Quick start @@ -15,8 +14,8 @@ VitNode loads data through two primary mechanisms: Plugins load data using `definePluginRoute`. The loader runs during SSR and client navigation, handing typed `loaderData` to the page component: ```tsx title="plugins/blog/src/routes/announcements-page.tsx" -import type { PluginRoutePageProps } from "@vitnode/core/routing" -import { definePluginRoute } from "@vitnode/core/routing" +import type { PluginRoutePageProps } from '@vitnode/core/routing' +import { definePluginRoute } from '@vitnode/core/routing' interface Announcement { id: string @@ -30,7 +29,9 @@ export const route = definePluginRoute({ }, }) -const AnnouncementsPage = ({ loaderData }: PluginRoutePageProps) => ( +const AnnouncementsPage = ({ + loaderData, +}: PluginRoutePageProps) => (
{loaderData.map((item) => (

@@ -43,74 +44,14 @@ const AnnouncementsPage = ({ loaderData }: PluginRoutePageProps) export default AnnouncementsPage ``` ---- - -### 2. In an Application Route File - -For application-owned routes, warm TanStack Query in the route loader and consume it with `useSuspenseQuery`: - -#### Step 1: Define the Query - -```ts title="apps/web/src/features/announcements/query.ts" -import { queryOptions } from "@tanstack/react-query" -import { RECORD_STALE_TIME } from "@vitnode/core/lib/query-freshness" - -export const announcementsQueryKey = () => ["announcements"] as const - -export const announcementsQuery = () => - queryOptions({ - queryKey: announcementsQueryKey(), - queryFn: async () => await fetchAnnouncements(), - staleTime: RECORD_STALE_TIME, - }) -``` - -#### Step 2: Warm in Route Loader - -```tsx title="apps/web/src/routes/_main/announcements.tsx" -import { createFileRoute } from "@tanstack/react-router" -import { announcementsQuery } from "#/features/announcements/query" - -export const Route = createFileRoute("/_main/announcements")({ - // [!code ++:6] - loader: async ({ context }) => - await context.queryClient.ensureQueryData({ - ...announcementsQuery(), - revalidateIfStale: true, - }), - component: AnnouncementsPage, -}) -``` - -#### Step 3: Consume in Component - -```tsx title="apps/web/src/features/announcements/announcements-page.tsx" -import { useSuspenseQuery } from "@tanstack/react-query" -import { announcementsQuery } from "./query" - -export const AnnouncementsPage = () => { - const { data } = useSuspenseQuery(announcementsQuery()) - - return ( -
- {data.map((item) => ( -
{item.title}
- ))} -
- ) -} -``` - ---- - ## Invalidating After Mutations After creating, editing, or deleting a record, invalidate the query key so TanStack Query refetches fresh data: ```tsx -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { toast } from "sonner" -import { announcementsQueryKey } from "./query" +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { announcementsQueryKey } from './query' const queryClient = useQueryClient() @@ -119,7 +60,7 @@ const mutation = useMutation({ onSuccess: async () => { // [!code ++:2] await queryClient.invalidateQueries({ queryKey: announcementsQueryKey() }) - toast.success("Announcement published!") + toast.success('Announcement published!') }, }) ``` @@ -130,10 +71,10 @@ const mutation = useMutation({ VitNode provides standard stale times in `@vitnode/core/lib/query-freshness`: -| Constant | Duration | Use Case | -| :--- | :--- | :--- | -| `RECORD_STALE_TIME` | 30 seconds | Fast-changing user feeds, announcements | -| `STATIC_STALE_TIME` | 5 minutes | Site configuration, navigation, permissions | +| Constant | Duration | Use Case | +| :------------------ | :--------- | :------------------------------------------ | +| `RECORD_STALE_TIME` | 30 seconds | Fast-changing user feeds, announcements | +| `STATIC_STALE_TIME` | 5 minutes | Site configuration, navigation, permissions | ## Learn More diff --git a/apps/web/content/docs/dev/database/pagination.mdx b/apps/web/content/docs/dev/database/pagination.mdx index 3db93045c..24b4adbec 100644 --- a/apps/web/content/docs/dev/database/pagination.mdx +++ b/apps/web/content/docs/dev/database/pagination.mdx @@ -1,29 +1,35 @@ --- title: Pagination -description: Cursor pagination in VitNode - the withPagination helper on your Hono route, and the validated search, query, and data table that consume it. +description: Add stable cursor pagination to a plugin API route and navigate its results from a typed plugin page. icon: ChevronsRight --- -import { TypeTable } from "fumadocs-ui/components/type-table" +import { TypeTable } from 'fumadocs-ui/components/type-table' VitNode uses cursor-based pagination rather than offset pagination. Cursors ensure stable page boundaries even while records are created or deleted, with zero database performance degradation on large tables. ## Quick start -### 1. Backend: Paginated API Route + + + +### Add a paginated API route Use `withPagination` from `@vitnode/core/api/lib/with-pagination`: ```ts title="plugins/blog/src/api/modules/posts/routes/get.route.ts" -import { getColumns } from "drizzle-orm" -import { withPagination, zodPaginationQuery } from "@vitnode/core/api/lib/with-pagination" -import { blog_posts } from "@/database/posts" +import { getColumns } from 'drizzle-orm' +import { + withPagination, + zodPaginationQuery, +} from '@vitnode/core/api/lib/with-pagination' +import { blog_posts } from '@/database/posts' export const getPostsRoute = buildRoute({ - pluginId: "blog", + pluginId: 'blog', route: { - method: "get", - path: "/", + method: 'get', + path: '/', request: { query: zodPaginationQuery, }, @@ -31,13 +37,13 @@ export const getPostsRoute = buildRoute({ handler: async (c) => { const data = await withPagination({ c, - params: { query: c.req.valid("query") }, + params: { query: c.req.valid('query') }, primaryCursor: blog_posts.id, table: blog_posts, - orderBy: { column: blog_posts.createdAt, order: "desc" }, + orderBy: { column: blog_posts.createdAt, order: 'desc' }, query: async ({ cursorSelection, limit, where, orderBy }) => await c - .get("db") + .get('db') .select({ ...getColumns(blog_posts), ...cursorSelection }) .from(blog_posts) .where(where) @@ -52,15 +58,16 @@ export const getPostsRoute = buildRoute({ The route automatically accepts `?cursor=...&first=10` and returns `{ edges, pageInfo }`. ---- + + ## Why Cursors Over Offsets -| Metric | `LIMIT ... OFFSET` | Keyset Cursor | -| ------ | ------------------ | ------------- | -| Insert / Delete mid-walk | Rows shift, duplicate, or skip | Unaffected (stable pointer) | -| Deep pagination (Page 10,000) | Full table scan up to offset | Single indexed B-tree lookup | -| Sort order stability | Prone to non-deterministic ties | Guaranteed by primaryCursor tiebreaker | +| Metric | `LIMIT ... OFFSET` | Keyset Cursor | +| ----------------------------- | ------------------------------- | -------------------------------------- | +| Insert / Delete mid-walk | Rows shift, duplicate, or skip | Unaffected (stable pointer) | +| Deep pagination (Page 10,000) | Full table scan up to offset | Single indexed B-tree lookup | +| Sort order stability | Prone to non-deterministic ties | Guaranteed by primaryCursor tiebreaker | --- @@ -69,44 +76,47 @@ The route automatically accepts `?cursor=...&first=10` and returns `{ edges, pag Promise", + type: '(args) => Promise', }, where: { - description: "Additional SQL filter conditions applied before pagination.", + description: + 'Additional SQL filter conditions applied before pagination.', required: false, - type: "SQL", + type: 'SQL', }, search: { - description: "Array of text columns to match against ?search= with ILIKE.", + description: + 'Array of text columns to match against ?search= with ILIKE.', required: false, - type: "PgColumn[]", + type: 'PgColumn[]', }, }} /> @@ -133,12 +143,27 @@ The route automatically accepts `?cursor=...&first=10` and returns `{ edges, pag @@ -146,73 +171,88 @@ The route automatically accepts `?cursor=...&first=10` and returns `{ edges, pag ## Frontend Integration -### 1. Route Search Schema + + -In your route file, validate pagination search parameters using `zodPaginationQuery`: +### Move through results from a plugin page -```tsx title="apps/web/src/routes/_main/posts.tsx" -import { createFileRoute } from "@tanstack/react-router" -import { zodPaginationQuery } from "@vitnode/core/api/lib/with-pagination" -import { postsQuery } from "#/features/posts/query" +Plugin routes normalize their own query string with `parseSearch`, then receive +the typed `search`, `loaderData`, and same-page `navigate` function. That is all +a list needs—no host route file required. -export const Route = createFileRoute("/_main/posts")({ - validateSearch: (search) => zodPaginationQuery.parse(search), - loaderDeps: ({ search }) => search, - loader: async ({ context, deps }) => - await context.queryClient.ensureQueryData(postsQuery(deps)), - component: PostsPage, -}) -``` +```tsx title="plugins/blog/src/routes/posts-page.tsx" +import type { PluginRoutePageProps } from '@vitnode/core/routing' +import { definePluginRoute } from '@vitnode/core/routing' -### 2. Render `ContentDataTable` - -Feed the paginated data directly into VitNode's `ContentDataTable`: - -```tsx title="apps/web/src/features/posts/posts-table.tsx" -import { ContentDataTable } from "@vitnode/core/components/table/content" -import { DataTableNavigationProvider } from "@vitnode/core/components/table/provider" -import { useSuspenseQuery } from "@tanstack/react-query" -import { Route } from "#/routes/_main/posts" -import { postsQuery } from "./query" - -export const PostsTable = () => { - const search = Route.useSearch() - const navigate = Route.useNavigate() - const { data } = useSuspenseQuery(postsQuery(search)) - - return ( - - - - ) +interface PostsSearch { + cursor?: string + first: number } + +// [!code ++:11] +export const route = definePluginRoute({ + parseSearch: (input) => { + const search = input as Record + const first = Number(search.first) + + return { + cursor: typeof search.cursor === 'string' ? search.cursor : undefined, + first: Number.isInteger(first) && first > 0 ? first : 10, + } + }, + load: async ({ search }) => await fetchPosts(search), +}) + +const PostsPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps< + Awaited>, + PostsSearch +>) => ( + +) + +export default PostsPage ``` ---- + + ## Custom Filtering and Search To add search and custom filters, pass the parameters into `withPagination`: ```ts title="plugins/blog/src/api/modules/posts/routes/get.route.ts" -import { eq } from "drizzle-orm" +import { eq } from 'drizzle-orm' const data = await withPagination({ c, - params: { query: c.req.valid("query") }, + params: { query: c.req.valid('query') }, table: blog_posts, primaryCursor: blog_posts.id, - orderBy: { column: blog_posts.createdAt, order: "desc" }, + orderBy: { column: blog_posts.createdAt, order: 'desc' }, // [!code ++:3] search: [blog_posts.title, blog_posts.content], where: categoryId ? eq(blog_posts.categoryId, categoryId) : undefined, query: async ({ cursorSelection, limit, where, orderBy }) => await c - .get("db") + .get('db') .select({ ...getColumns(blog_posts), ...cursorSelection }) .from(blog_posts) .where(where) @@ -226,11 +266,15 @@ const data = await withPagination({ ## Best Practices - Cursors encode the specific column ordering. If the user changes sort order or filter criteria, reset the cursor to `undefined` so pagination starts from the first page. + Cursors encode the specific column ordering. If the user changes sort order or + filter criteria, reset the cursor to `undefined` so pagination starts from the + first page. - `withPagination` injects cursor conditions directly into the callback's `where` and `orderBy`. Always pass those arguments straight to Drizzle's query methods without rebuilding them. + `withPagination` injects cursor conditions directly into the callback's + `where` and `orderBy`. Always pass those arguments straight to Drizzle's query + methods without rebuilding them. ## Learn More diff --git a/apps/web/content/docs/dev/database/search.mdx b/apps/web/content/docs/dev/database/search.mdx index c5e96eb8b..6d86e229e 100644 --- a/apps/web/content/docs/dev/database/search.mdx +++ b/apps/web/content/docs/dev/database/search.mdx @@ -1,18 +1,26 @@ --- -title: Search Your Tables -description: Add case-insensitive search filtering to paginated database tables with withPagination. +title: Search +description: Add case-insensitive search to one paginated plugin table with withPagination and the shared table UI. icon: Search --- `withPagination` includes built-in multi-column search filtering. When `search` columns are specified, incoming `?search=` query parameters automatically apply case-insensitive `ILIKE` clauses across the chosen columns. - For site-wide search across multiple collections and models, see [Search & Discovery](/docs/dev/search). + For site-wide search across multiple collections and models, see [Search & + Discovery](/docs/dev/search). ## Quick start -### 1. Accept `search` in Route Query Schema +`ContentDataTable` is VitNode's reusable table UI: it renders rows, toolbar, +search, sorting, and pagination. It does not fetch data. See [Data +Table](/docs/ui/data-table) for the navigation provider it needs around it. + + + + +### Accept `search` in the route query Extend `zodPaginationQuery` to accept the optional search string: @@ -27,9 +35,10 @@ request: { } ``` ---- + + -### 2. Supply Search Columns to `withPagination` +### Supply searchable columns List the columns to filter in the `search` array: @@ -43,7 +52,7 @@ const data = await withPagination({ search: [reactions.emoji, reactions.description], // Filters across both columns query: async ({ cursorSelection, limit, where, orderBy }) => await c - .get("db") + .get('db') .select({ ...getColumns(reactions), ...cursorSelection }) .from(reactions) .where(where) @@ -52,26 +61,30 @@ const data = await withPagination({ }) ``` ---- + + -### 3. Enable in Data Table +### Show the table search input -Pass the `search` configuration to ``: +Enable the shared table UI after its route loader returns `{ edges, pageInfo }`: ```tsx title="src/views/reactions-table.tsx" ``` The table automatically updates the `?search=` query parameter as the user types with debouncing. + + + ## Learn More @@ -81,7 +94,7 @@ The table automatically updates the `?search=` query parameter as the user types href="/docs/dev/database/pagination" /> diff --git a/apps/web/content/docs/dev/deployments/cloud/meta.json b/apps/web/content/docs/dev/deployments/cloud/meta.json index 922684fb7..4f7699326 100644 --- a/apps/web/content/docs/dev/deployments/cloud/meta.json +++ b/apps/web/content/docs/dev/deployments/cloud/meta.json @@ -1,5 +1,6 @@ { "title": "Cloud", "description": "Managed platforms, and what they cannot run", + "icon": "Cloud", "pages": ["vercel", "..."] } diff --git a/apps/web/content/docs/dev/deployments/meta.json b/apps/web/content/docs/dev/deployments/meta.json index e261b3aea..86ca336d6 100644 --- a/apps/web/content/docs/dev/deployments/meta.json +++ b/apps/web/content/docs/dev/deployments/meta.json @@ -1,6 +1,6 @@ { "title": "Deployments", - "description": "Take a VitNode app to production", + "description": "Deploy a VitNode application to production with cloud or self-hosted infrastructure", "icon": "HardDriveUpload", "pages": ["self-hosted", "cloud"] } diff --git a/apps/web/content/docs/dev/deployments/self-hosted.mdx b/apps/web/content/docs/dev/deployments/self-hosted.mdx index 48e68ca5e..2a4e9544d 100644 --- a/apps/web/content/docs/dev/deployments/self-hosted.mdx +++ b/apps/web/content/docs/dev/deployments/self-hosted.mdx @@ -4,7 +4,7 @@ description: Build, migrate, and run VitNode on your own server or Docker contai icon: Server --- -import { Tab, Tabs } from "fumadocs-ui/components/tabs" +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' VitNode compiles into a standard Node.js server and static assets. The only required dependency is a PostgreSQL database (Redis is optional for caching and multi-instance scaling). @@ -38,7 +38,9 @@ npm start - Production processes do not automatically run migrations at boot. Running `db:migrate` ensures the database schema matches your compiled application before traffic is served. + Production processes do not automatically run migrations at boot. Running + `db:migrate` ensures the database schema matches your compiled application + before traffic is served. --- @@ -98,16 +100,40 @@ server { Keep VitNode running continuously with automatic restarts on crash: -```bash + + +```bash tab="bun" # Install PM2 -npm install -g pm2 +bun add -g pm2 + +# Start both web and API processes +pm2 start "bun start" --name "vitnode-web" +pm2 save +pm2 startup +``` + +```bash tab="pnpm" +# Install PM2 +pnpm add -g pm2 -# Start both web and api processes +# Start both web and API processes pm2 start "pnpm start" --name "vitnode-web" pm2 save pm2 startup ``` +```bash tab="npm" +# Install PM2 +npm install -g pm2 + +# Start both web and API processes +pm2 start "npm start" --name "vitnode-web" +pm2 save +pm2 startup +``` + + + ## Learn More diff --git a/apps/web/content/docs/dev/email/index.mdx b/apps/web/content/docs/dev/email/index.mdx index 520d5bfe7..ab62c3b68 100644 --- a/apps/web/content/docs/dev/email/index.mdx +++ b/apps/web/content/docs/dev/email/index.mdx @@ -8,36 +8,47 @@ VitNode provides a unified transactional email service accessible via `c.get("em ## Quick start -Send an email directly from any Hono route handler: + + + +### Queue an email from a Hono route ```ts title="plugins/blog/src/api/modules/newsletter/routes/welcome.route.ts" -import { buildRoute } from "@vitnode/core/api/lib/route" +import { buildRoute } from '@vitnode/core/api/lib/route' export const sendWelcomeRoute = buildRoute({ - pluginId: "blog", + pluginId: 'blog', route: { - method: "post", - path: "/welcome", - responses: { 200: { description: "Email sent" } }, + method: 'post', + path: '/welcome', + responses: { 200: { description: 'Email sent' } }, }, handler: async (c) => { - const user = c.get("user") + const user = c.get('user') - // [!code ++:6] - const email = await c.get("email").build({ + // [!code ++:5] + await c.get('email').send({ user, // Resolves email address and preferred locale - subject: "Welcome to VitNode!", - content: () => "Thank you for joining our community.", + subject: 'Welcome to VitNode!', + content: () => 'Thank you for joining our community.', }) - await c.get("email").deliver(email) - return c.json({ sent: true }) }, }) ``` ---- +`send()` renders the template and queues delivery. It keeps a successful API +response quick even when the mail provider is having a moody afternoon. + + + + + + Use `await c.get('email').build(args)` followed by `deliver(email)` only when + a route truly needs immediate delivery. `send(args)` is the normal, queued + path. + ## Configuring Email Adapters @@ -46,11 +57,11 @@ Configure your delivery transport in `apps/api/src/vitnode.api.config.ts`: ### 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/core/api/adapters/email/resend' export const vitNodeApiConfig = buildApiConfig({ email: { - from: "noreply@yourdomain.com", + from: 'noreply@yourdomain.com', adapter: ResendEmailAdapter({ apiKey: process.env.RESEND_API_KEY!, }), @@ -61,11 +72,11 @@ export const vitNodeApiConfig = buildApiConfig({ ### 2. SMTP Adapter ```ts title="apps/api/src/vitnode.api.config.ts" -import { SmtpEmailAdapter } from "@vitnode/core/api/adapters/email/smtp" +import { SmtpEmailAdapter } from '@vitnode/core/api/adapters/email/smtp' export const vitNodeApiConfig = buildApiConfig({ email: { - from: "noreply@yourdomain.com", + from: 'noreply@yourdomain.com', adapter: SmtpEmailAdapter({ host: process.env.SMTP_HOST!, port: Number(process.env.SMTP_PORT ?? 587), @@ -83,6 +94,7 @@ export const vitNodeApiConfig = buildApiConfig({ {/* Image prompt: VitNode AdminCP System -> Integrations screen at /admin/core/system/integrations. Email card showing status "Configured" with a "Send Test Email" modal containing recipient address input and delivery confirmation toast. Dark theme, 1440x900. */} Verify email configuration in the AdminCP at **System → Integrations** (`/admin/core/system/integrations`): + - Click **Test Email** on the email card. - Enter an email address to dispatch an immediate test delivery. diff --git a/apps/web/content/docs/dev/events/meta.json b/apps/web/content/docs/dev/events/meta.json index 5c2bb5c9b..f4e25442a 100644 --- a/apps/web/content/docs/dev/events/meta.json +++ b/apps/web/content/docs/dev/events/meta.json @@ -1,5 +1,6 @@ { "title": "Events", "description": "Emit typed domain events and react to them from any plugin", + "icon": "Radio", "pages": ["index", "built-in-events", "custom-adapter"] } diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx index 7ffeb3ac4..b274d1c07 100644 --- a/apps/web/content/docs/dev/fetcher.mdx +++ b/apps/web/content/docs/dev/fetcher.mdx @@ -4,9 +4,10 @@ description: End-to-end type-safe RPC client for calling your Hono API from SSR icon: ArrowRightLeft --- -import { TypeTable } from "fumadocs-ui/components/type-table" +import { TypeTable } from 'fumadocs-ui/components/type-table' VitNode provides type-safe RPC fetchers directly linked to your Hono API modules: + - `fetcher` on the **server** (SSR / server functions) using the real API module. - `fetcherClient` in the **browser** using a lightweight module type reference. @@ -15,14 +16,14 @@ VitNode provides type-safe RPC fetchers directly linked to your Hono API modules Use `fetcher` in SSR renders and `.server()` branches of `createIsomorphicFn`: ```ts -import { usersModule } from "@vitnode/core/api/modules/users/users.module" -import { fetcher } from "@vitnode/core/tanstack/fetcher/server" +import { usersModule } from '@vitnode/core/api/modules/users/users.module' +import { fetcher } from '@vitnode/core/tanstack/fetcher/server' // [!code ++:8] const response = await fetcher(usersModule, { - method: "get", - module: "users", - path: "/session", + method: 'get', + module: 'users', + path: '/session', }) if (response.ok) { @@ -39,21 +40,21 @@ if (response.ok) { In client components and browser query functions, use `fetcherClient`: ```ts -import { clientModule, fetcherClient } from "@vitnode/core/lib/fetcher-client" -import type { usersModule } from "@vitnode/core/api/modules/users/users.module" +import { clientModule, fetcherClient } from '@vitnode/core/lib/fetcher-client' +import type { usersModule } from '@vitnode/core/api/modules/users/users.module' // Create lightweight module reference (no runtime backend imports bundled) -const moduleRef = clientModule("@vitnode/core") +const moduleRef = clientModule('@vitnode/core') // [!code ++:13] const response = await fetcherClient(moduleRef, { - method: "post", - module: "users", - path: "/sign_in", + method: 'post', + module: 'users', + path: '/sign_in', args: { body: { - email: "user@example.com", - password: "password123", + email: 'user@example.com', + password: 'password123', }, }, allowSaveCookies: true, // Necessary when the route mints a session @@ -62,16 +63,63 @@ const response = await fetcherClient(moduleRef, { --- +## 3. Use it on a plugin page + +Plugin route loaders run for SSR and client navigation. Wrap both transports +once, then call that function from the page that owns the feature: + +```ts title="plugins/devices/src/lib/fetch-devices.ts" +import { createIsomorphicFn } from '@tanstack/react-start' +import { clientModule, fetcherClient } from '@vitnode/core/lib/fetcher-client' +import { fetcher } from '@vitnode/core/tanstack/fetcher/server' +import type { usersModule } from '@vitnode/core/api/modules/users/users.module' + +const usersModuleRef = clientModule('@vitnode/core') + +export const fetchDevices = createIsomorphicFn() + .server(async () => { + const response = await fetcher(usersModule, { + method: 'get', + module: 'users', + path: '/devices', + }) + return await response.json() + }) + .client(async () => { + const response = await fetcherClient(usersModuleRef, { + method: 'get', + module: 'users', + path: '/devices', + }) + return await response.json() + }) +``` + +```tsx title="plugins/devices/src/routes/devices-page.tsx" +import { definePluginRoute } from '@vitnode/core/routing' +import { fetchDevices } from '../lib/fetch-devices' + +// [!code ++:3] +export const route = definePluginRoute({ + load: async () => await fetchDevices(), +}) +``` + +That is the page-level usage: the initial render uses `fetcher`; later +navigations use `fetcherClient`. Same result, no extra host route file. + +--- + ## Fetcher Options -| Option | Required When | Description | -| :--- | :--- | :--- | -| `module` | **Always** | Target module key declared on API | -| `method` | **Always** | HTTP method (`get`, `post`, `put`, `delete`) | -| `path` | **Always** | Route path pattern (e.g. `/`, `/:id`) | -| `args` | Body / params / query exist | Strongly typed payload: `{ body?, params?, query? }` | -| `allowSaveCookies` | Optional | Set `true` when logging in or saving session cookies | -| `captchaToken` | Optional | Challenge token for routes guarded by `withCaptcha` | +| Option | Required When | Description | +| :----------------- | :-------------------------- | :--------------------------------------------------- | +| `module` | **Always** | Target module key declared on API | +| `method` | **Always** | HTTP method (`get`, `post`, `put`, `delete`) | +| `path` | **Always** | Route path pattern (e.g. `/`, `/:id`) | +| `args` | Body / params / query exist | Strongly typed payload: `{ body?, params?, query? }` | +| `allowSaveCookies` | Optional | Set `true` when logging in or saving session cookies | +| `captchaToken` | Optional | Challenge token for routes guarded by `withCaptcha` | For generated Content Engine routes that have no static TypeScript module definitions, use `rawFetcher({ pluginId, path, method })`. diff --git a/apps/web/content/docs/dev/i18n/index.mdx b/apps/web/content/docs/dev/i18n/index.mdx index 104f6da6e..e908b1d1f 100644 --- a/apps/web/content/docs/dev/i18n/index.mdx +++ b/apps/web/content/docs/dev/i18n/index.mdx @@ -4,7 +4,7 @@ description: How VitNode merges translations from installed packages, and how to icon: Globe --- -import { Tab, Tabs } from "fumadocs-ui/components/tabs" +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' VitNode provides full internationalization out of the box. Every package (`@vitnode/core` and plugins) maintains its own locale files, which VitNode merges per request: core strings first, plugin strings second, and your host app overrides last. @@ -43,11 +43,11 @@ Declare supported languages in `apps/web/src/i18n.ts`: ```ts title="apps/web/src/i18n.ts" export const i18n = { - defaultLocale: "en", + defaultLocale: 'en', locales: [ - { code: "en", name: "English" }, + { code: 'en', name: 'English' }, // [!code ++:1] - { code: "de", name: "Deutsch" }, + { code: 'de', name: 'Deutsch' }, ], } ``` @@ -74,11 +74,11 @@ Because your app overrides are merged last, only the keys you specify are overwr ## Translation Architecture -| Source | Role | Order | -| :--- | :--- | :--- | -| `@vitnode/core` | Base strings for auth, admin shells, and dialogs | Base layer | -| **Plugins** | Domain strings declared in `plugins/*/src/locales` | Second layer | -| **Host Application** | Custom overrides in `apps/web/src/locales` | Highest priority (wins) | +| Source | Role | Order | +| :------------------- | :------------------------------------------------- | :---------------------- | +| `@vitnode/core` | Base strings for auth, admin shells, and dialogs | Base layer | +| **Plugins** | Domain strings declared in `plugins/*/src/locales` | Second layer | +| **Host Application** | Custom overrides in `apps/web/src/locales` | Highest priority (wins) | Missing keys automatically fall back to `defaultLocale` (`en`), preventing raw key paths from displaying in production. @@ -96,7 +96,7 @@ Missing keys automatically fall back to `defaultLocale` (`en`), preventing raw k href="/docs/dev/i18n/messages" /> diff --git a/apps/web/content/docs/dev/i18n/namespaces.mdx b/apps/web/content/docs/dev/i18n/namespaces.mdx index dae7692d9..c97485438 100644 --- a/apps/web/content/docs/dev/i18n/namespaces.mdx +++ b/apps/web/content/docs/dev/i18n/namespaces.mdx @@ -76,90 +76,24 @@ namespace. ## Asking for a namespace -A plugin route declares its namespaces in the route manifest, which is covered in -[Pages](/docs/dev/i18n/pages). A route in your own app does the same thing in two -lines: warm the query in the loader, mount the provider in the component. - - - - - -### Warm the messages in the loader - -```tsx title="src/routes/_main/reports.tsx" -import { intlQueryOptions } from '@vitnode/core/tanstack/i18n' // [!code ++] - -const REPORTS_NAMESPACES = ['core.global', 'my-app.reports'] as const // [!code ++] - -export const Route = createFileRoute('/_main/reports')({ - loader: async ({ context }) => { - // [!code ++:3] - await context.queryClient.ensureQueryData( - intlQueryOptions({ - locale: context.locale, - namespaces: REPORTS_NAMESPACES, - }), - ) +Declare the exact branches a plugin page renders in the manifest. VitNode loads +them with the route chunk, so public pages do not download a plugin’s AdminCP +copy just because it exists. + +```ts title="plugins/my-plugin/src/routes/manifest.ts" +export const routes = [ + { + entry: 'routes/reports-page', + id: 'reports', + // [!code ++] + namespaces: ['my-plugin.reports'], + path: '/reports', }, - component: ReportsPage, -}) -``` - -`context.locale` comes from the root route, so `/pl/reports` arrives with Polish -already in the cache instead of painting English and flipping after hydration. - - - - - -### Mount `RouteMessages` with the same list - -```tsx title="src/routes/_main/reports.tsx" -import { RouteMessages } from '@vitnode/core/tanstack/i18n' // [!code ++] - -function ReportsPage() { - return ( - - {/* [!code ++] */} - - - ) -} +] ``` -`RouteMessages` **reads**, it does not fetch: it runs `useSuspenseQuery` over the -same `intlQueryOptions` the loader already warmed. Same locale, same namespaces, -same cache key - so on the first render the entry is there and nothing suspends. - - - - - -### Read the strings - -```tsx -const t = useTranslations('my-app.reports') - -return

{t('title')}

-``` - -Load the page and the heading is translated. If it renders as -`my-app.reports.title` instead, the namespace is not in the list above it. - -
- -
- - - The loader and the provider must ask for the **identical** set. A loader that - warms `["core.global"]` under a provider asking for `["core.global", - "my-app.reports"]` fills an entry nothing reads, the provider suspends, and - the first paint costs a round trip. Hoist the array into a `const` and pass - that to both, the way `SETTINGS_NAMESPACES` does in core. - - -Providers nest, and the inner one wins for the keys it names - so a layout can -name the set once for the pages under it, and a page can add its own on top. +The route module can then call `useTranslations('my-plugin.reports')`. If a key +renders as its own name, confirm the manifest declared the matching namespace. ## Limits @@ -208,8 +142,7 @@ import { MAX_NAMESPACES } from '@vitnode/core/tanstack/i18n' The symptom of a namespace that was never asked for. Check that the route - declares it (a plugin's `manifest.ts`) or that a `RouteMessages` above the - component names it - and that the loader warms the same list. + declares it in the plugin's `manifest.ts`. { - const t = useTranslations("@vitnode/blog.about") + const t = useTranslations('@vitnode/blog.about') - return

{t("title")}

+ return

{t('title')}

} export default AboutPage @@ -51,52 +51,16 @@ And define the messages in `plugins/blog/src/locales/en.json`: } ``` ---- - -### 2. In an Application Route File - -For host app routes, warm the translation query in your `loader` and wrap the component in ``: - -```tsx title="apps/web/src/routes/_main/about.tsx" -import { createFileRoute } from "@tanstack/react-router" -import { intlQueryOptions, RouteMessages } from "@vitnode/core/tanstack/i18n" -import { useTranslations } from "use-intl" - -const ABOUT_NAMESPACES = ["core.global", "app.about"] - -export const Route = createFileRoute("/_main/about")({ - // [!code ++:7] - loader: async ({ context }) => - await context.queryClient.ensureQueryData( - intlQueryOptions({ - locale: context.locale, - namespaces: ABOUT_NAMESPACES, - }), - ), - component: AboutRoute, -}) - -function AboutRoute() { - return ( - - - - ) -} - -function AboutContent() { - const t = useTranslations("app.about") - return

{t("title")}

-} -``` - ---- + + Host messages are for the site shell. A product page should declare its plugin + namespace in the manifest and keep its locale JSON beside the route. + ## Placeholders and Pluralization VitNode supports ICU message syntax out of the box: -```json title="src/locales/en.json" +```json title="plugins/blog/src/locales/en.json" { "cart": { "greeting": "Hello, {name}!", @@ -108,18 +72,19 @@ VitNode supports ICU message syntax out of the box: In your React component: ```tsx -const t = useTranslations("cart") +const t = useTranslations('cart') return (
-

{t("greeting", { name: "Alex" })}

-

{t("items", { count: 3 })}

+

{t('greeting', { name: 'Alex' })}

+

{t('items', { count: 3 })}

) ``` - `core.global` is provided by the root shell to every route, supplying shared strings for dialogs, toasts, and buttons. + `core.global` is provided by the root shell to every route, supplying shared + strings for dialogs, toasts, and buttons. ## Learn More @@ -136,7 +101,7 @@ return ( href="/docs/dev/i18n/namespaces" /> diff --git a/apps/web/content/docs/dev/i18n/server.mdx b/apps/web/content/docs/dev/i18n/server.mdx index f18ecdd15..d348eb484 100644 --- a/apps/web/content/docs/dev/i18n/server.mdx +++ b/apps/web/content/docs/dev/i18n/server.mdx @@ -1,6 +1,6 @@ --- -title: Server-side -description: Translate emails and API responses with the request-scoped translator. +title: API i18n +description: Translate emails and API responses with VitNode's request-scoped API translator. icon: Server --- diff --git a/apps/web/content/docs/dev/index.mdx b/apps/web/content/docs/dev/index.mdx index fe4f18759..e6c18eff6 100644 --- a/apps/web/content/docs/dev/index.mdx +++ b/apps/web/content/docs/dev/index.mdx @@ -1,33 +1,55 @@ --- title: Introduction -description: VitNode is a plugin-based framework for community apps - a TanStack Start front end, a Hono API, Postgres, and an AdminCP you do not have to build. +description: Build a TanStack Start and Hono application with VitNode plugins, then deploy and operate it confidently. icon: Power --- +import { + BookOpenIcon, + PackagePlusIcon, + RocketIcon, + ServerIcon, +} from 'lucide-react' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -VitNode gives you the parts of an application nobody enjoys writing twice: -accounts, roles, sessions, staff permissions, uploads, search, an admin panel and -a content layer - already built, already migrated, already translated. Your own -features arrive as **plugins** that claim URLs, database tables, API routes and -AdminCP screens as first-class citizens rather than as bolted-on extras. +VitNode is a **TanStack Start** front end, a **Hono** API, Postgres, and an +AdminCP. Its important rule is pleasantly simple: product features live in +**plugins**. A plugin owns its pages, APIs, data, translations, and AdminCP +extensions, so your host app does not become a drawer full of mystery cables. -Underneath it is an ordinary modern stack: a [TanStack -Start](https://tanstack.com/start) front end on Vite and React 19, a -[Hono](https://hono.dev/) API that owns every security decision, and -[Drizzle](https://orm.drizzle.team/) over Postgres. +{/* Image prompt: Clean dark-theme architecture diagram. A TanStack Start application and Hono API sit in the center, Postgres below, and three colorful plugin packages connect to routes, API modules, data, and AdminCP. Keep labels large and legible, 1600x900. */} -{/* Image prompt: A clean architecture diagram, 1600x900, dark theme with two accent colours. Left to right: a browser window, an arrow to a box labelled "TanStack Start - routes, rendering, TanStack Query", an arrow to a box labelled "Hono API - auth, permissions, modules", an arrow to a Postgres cylinder labelled "Drizzle". Below, three small stacked cards labelled "plugin" with arrows feeding into both the Start box (routes) and the Hono box (API modules and tables). */} - - - These pages track the `canary` line (VitNode 2.0), which moves quickly. If a - page and the code disagree, the code wins - and a - [contribution](/docs/dev/contribution) fixing the page is very welcome. + + These pages follow VitNode 2.0 on the `canary` branch. It moves quickly; when + code and prose disagree, trust the code and send the prose a friendly PR. -## Quick start +## Start with a running app + + + } + title="Get an app running" + description="Scaffold VitNode, migrate Postgres, and enter AdminCP." + href="/docs/dev/setup" + /> + } + title="Create a plugin" + description="Make the package that will own your first feature." + href="/docs/dev/plugins/create" + /> + } + title="Deploy VitNode" + description="Build, migrate, and run your app on a server or container." + href="/docs/dev/deployments/self-hosted" + /> + + +## Create an app - + ```bash tab="bun" bun create vitnode-app@canary @@ -38,106 +60,46 @@ pnpm create vitnode-app@canary ``` ```bash tab="npm" -npx create-vitnode-app@canary +npm create vitnode-app@canary ``` -The CLI asks which shape you want - **Single App** (a TanStack Start app with the -Hono API mounted inside it at `/api`), **Monorepo App** (front end and API served -separately) or **Only API** - then scaffolds it, generates your first migration -and, if you let it, installs everything. The full walkthrough, including the -first sign-in, is in [Getting started](/docs/dev/setup). - -## What you need - -| Software | Minimum | Recommended | What it is for | -| --------------------------------------- | ------- | ----------- | ----------------------------------------------------------------------------------- | -| [Node.js](https://nodejs.org/) | 22 | 24 | Runs the Hono API, the Vite dev server and the built Nitro server. | -| [Postgres](https://www.postgresql.org/) | 17 | 17.5 | Every table VitNode has: users, roles, sessions, languages, files, content, search. | -| [pnpm](https://pnpm.io/) | 11 | 11.9 | Installing packages and running scripts - the manager this repository pins. | -| [bun](https://bun.com/) or npm | – | – | Fully supported alternatives; the CLI writes whichever you pick. | - -Where those numbers come from, so you can check them: `engines.node` in the root -`package.json` says `>=22`, `.nvmrc` says `22`, and CI builds on Node 24 with -`pnpm@11.9.0` (the version pinned by `packageManager`). Numbers we can point at -beat numbers that merely sound reassuring, which is why bun and npm have none - -nothing in the repository pins them, and `create-vitnode-app` records whichever -release you already have. - - - Every migration in this repository was generated against Postgres 17. The - bundled `docker-compose.yml` does not pull the stock image, though - it builds - `docker/postgres/Dockerfile`, which is `postgres:17.5-alpine` plus a hunspell - Polish dictionary and a registered `polish` text-search configuration, because - the stock image ships neither and Postgres full-text search is the default - [search engine](/docs/dev/search). Older majors are untested rather than - known-broken. - - -## Optional services - -None of these are required to run VitNode. Each one turns on a capability, and -each one is a small block of config away. - -| Service | Package | What it adds | -| ---------------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| [Redis 8](/docs/dev/advanced/redis) | built in | A shared cache on `c.get("cache")`, cached session lookups, and a [rate limiter](/docs/dev/advanced/rate-limiter) that holds across instances. | -| [Docker](https://www.docker.com/) | – | Postgres and Redis locally with one `docker:dev`, using the compose file the CLI writes for you. | -| [Elasticsearch 9](/docs/dev/search) | `@vitnode/elasticsearch` | Moves search off Postgres full-text, which is the default engine. | -| [SMTP](/docs/dev/email/nodemailer) | `@vitnode/nodemailer` | Transactional email through any SMTP server. | -| [Resend](/docs/dev/email/resend) | `@vitnode/resend` | Transactional email through Resend's API instead. | -| [S3 or Cloudflare R2](/docs/dev/storage/s3-r2) | `@vitnode/s3` | File uploads in object storage instead of on the API's disk. | -| [Supabase](/docs/dev/storage/supabase) | `@vitnode/supabase-storage` | Uploads in Supabase buckets - and a managed Postgres to aim `POSTGRES_URL` at. | -| [node-cron](/docs/dev/cron/node-cron) | `@vitnode/node-cron` | Runs scheduled tasks in-process, without an external scheduler. | -| [Captcha](/docs/dev/captcha) | built in | Cloudflare Turnstile or reCAPTCHA v3 on sign-up and password reset. | -| [SSO](/docs/dev/sso) | built in | Sign in with Discord, Google or Facebook. | -| [AI](/docs/dev/ai) | built in | Model access through the Vercel AI SDK registry on `c.get("ai")`. | +Choose **Single App** for one TanStack Start app with Hono at `/api`, or a +monorepo when web and API deploy separately. If you plan to create plugins, +enable **Turborepo** during setup: the generator needs a workspace root. -## How this section is organised +## The five-minute path -The sidebar has five groups, and they run roughly in the order you will need -them: +1. Start Postgres (Docker is the low-drama local choice). +2. Run `db:migrate` to create core tables and your first administrator. +3. Run `dev`, then visit `http://localhost:3000/admin`. +4. Create a plugin for your first product page, API endpoint, content type, or + dashboard widget. -| Group | What lives there | -| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Start here** | Installing, plugin creation, deployment and shape of an app: [Getting started](/docs/dev/setup), [Create a plugin](/docs/dev/plugins/create), [Deployments](/docs/dev/deployments/self-hosted), [Architecture](/docs/dev/architecture). | -| **Framework** | The TanStack Start runtime: [Routing](/docs/dev/routing), [Data loading](/docs/dev/data-loading), [Fetcher](/docs/dev/fetcher), [Cache](/docs/dev/cache), [Database](/docs/dev/database), [i18n](/docs/dev/i18n). | -| **Extend VitNode** | Your own features: [Content Engine](/docs/dev/content-engine), [Roles](/docs/dev/working-with-users/roles), [Events](/docs/dev/events). | -| **Services** | Things VitNode talks to: [Search](/docs/dev/search), [Storage](/docs/dev/storage), [Email](/docs/dev/email), [Cron](/docs/dev/cron), [WebSocket](/docs/dev/websocket), [Redis](/docs/dev/advanced/redis). | -| **Operate** | Keeping it running smoothly: [Debugging](/docs/dev/debugging), [Swagger](/docs/dev/swagger), [Contribution](/docs/dev/contribution). | +Every command above is expanded in [Getting started](/docs/dev/setup). From +there, [Build your first plugin](/docs/guides/first-plugin) gives you a real +route to visit—not just a philosophical plugin. -## Where to go next +## Find the right reference - - - } + title="Plugin route manifest" + description="Claim public or AdminCP URLs without adding host route files." + href="/docs/dev/plugins/route-manifest" /> } + title="Admin Control Panel" + description="Add plugin-owned screens, navigation, permissions, and widgets." + href="/docs/dev/plugins/admin" /> } title="Content Engine" - description="Declare a content type, get the CRUD and AdminCP for free" + description="Define content once and receive data, API, and AdminCP tools." href="/docs/dev/content-engine" /> diff --git a/apps/web/content/docs/dev/meta.json b/apps/web/content/docs/dev/meta.json index 35c2c6a21..23681d534 100644 --- a/apps/web/content/docs/dev/meta.json +++ b/apps/web/content/docs/dev/meta.json @@ -7,13 +7,13 @@ "---Start here---", "index", "setup", - "plugins", "deployments", "architecture", "---Framework---", + "plugins", "routing", - "data-loading", "fetcher", + "data-loading", "cache", "server-functions", "database", @@ -26,6 +26,7 @@ "---Services---", "ai", "search", + "search-elasticsearch", "storage", "email", "captcha", diff --git a/apps/web/content/docs/dev/performance.mdx b/apps/web/content/docs/dev/performance.mdx index a1862d41a..6a9bd0439 100644 --- a/apps/web/content/docs/dev/performance.mdx +++ b/apps/web/content/docs/dev/performance.mdx @@ -4,7 +4,7 @@ description: Optimize bundle sizes, code splitting, and loading performance in V icon: Gauge --- -import { Tab, Tabs } from "fumadocs-ui/components/tabs" +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' TanStack Start splits components into separate lazy chunks by default, keeping the initial entry bundle light. Follow these key practices to maintain instant page loads. @@ -22,13 +22,13 @@ TanStack Start splits components into separate lazy chunks by default, keeping t TanStack Router automatically extracts route `component`, `errorComponent`, and `notFoundComponent` into lazy chunks: -| Route Option | Read When | In Initial Bundle? | Optimization | -| :--- | :--- | :--- | :--- | -| `path`, `id` | Route tree generation | **Yes** | Keep route paths concise | -| `head` | Page navigation | **Yes** | Isolate metadata strings in leaf files | -| `loader` | Pre-render | **Yes (fn only)** | `await import()` large dependencies inside fn | -| `pendingComponent` | Route loading | **Yes** | Use lightweight SVG/CSS skeleton primitives | -| `component` | Render | **No (Lazy Chunk)** | Automatically code-split | +| Route Option | Read When | In Initial Bundle? | Optimization | +| :----------------- | :-------------------- | :------------------ | :-------------------------------------------- | +| `path`, `id` | Route tree generation | **Yes** | Keep route paths concise | +| `head` | Page navigation | **Yes** | Isolate metadata strings in leaf files | +| `loader` | Pre-render | **Yes (fn only)** | `await import()` large dependencies inside fn | +| `pendingComponent` | Route loading | **Yes** | Use lightweight SVG/CSS skeleton primitives | +| `component` | Render | **No (Lazy Chunk)** | Automatically code-split | --- @@ -38,22 +38,25 @@ TanStack Router automatically extracts route `component`, `errorComponent`, and Because `head` is evaluated in the main route bundle, importing strings from component files accidentally pulls entire component trees into the initial download: -```tsx title="apps/web/src/routes/_main/index.tsx" +```tsx title="plugins/home/src/routes/home-page.tsx" +import { definePluginRoute } from '@vitnode/core/routing' + // BAD: Pulls HomeRouteContent and all its heavy icons/charts into main entry -import { HOME_TITLE, HomeRouteContent } from "#/site/home/home-content" // [!code --] +import { HOME_TITLE, HomeRouteContent } from '../views/home-content' // [!code --] // GOOD: Metadata strings live in a lightweight leaf file -import { HomeRouteContent } from "#/site/home/home-content" // [!code ++] -import { HOME_DESCRIPTION, HOME_TITLE } from "#/site/home/metadata" // [!code ++] +import { HomeRouteContent } from '../views/home-content' // [!code ++] +import { HOME_DESCRIPTION, HOME_TITLE } from '../views/metadata' // [!code ++] -export const Route = createFileRoute("/_main/")({ +export const route = definePluginRoute({ head: () => pageHead({ title: HOME_TITLE, description: HOME_DESCRIPTION, }), - component: HomeRouteContent, }) + +export default HomeRouteContent ``` ### 2. Lazy Dialogs and Heavy Form Editors @@ -61,14 +64,14 @@ export const Route = createFileRoute("/_main/")({ Heavy editors (like Tiptap) or complex modals should be lazy-loaded with `React.lazy` and `Suspense`: ```tsx title="plugins/blog/src/views/admin/article-editor.tsx" -import React, { Suspense } from "react" -import { Loader } from "@vitnode/core/components/ui/loader" +import React, { Suspense } from 'react' +import { Loader } from '@vitnode/core/components/ui/loader' // [!code ++:6] const RichEditor = React.lazy(async () => - import("@vitnode/core/components/form/fields/editor").then((mod) => ({ + import('@vitnode/core/components/form/fields/editor').then((mod) => ({ default: mod.AutoFormEditor, - })) + })), ) export const ArticleEditor = (props) => ( @@ -82,11 +85,13 @@ export const ArticleEditor = (props) => ( When a route loader requires a heavy calculation or parsing library, import it dynamically: -```tsx title="apps/web/src/routes/_main/stats.tsx" -export const Route = createFileRoute("/_main/stats")({ - loader: async () => { +```tsx title="plugins/stats/src/routes/stats-page.tsx" +import { definePluginRoute } from '@vitnode/core/routing' + +export const route = definePluginRoute({ + load: async () => { // Only downloaded when visitor navigates to /stats - const { calculateStats } = await import("#/features/stats/calculator") // [!code ++] + const { calculateStats } = await import('../features/stats/calculator') // [!code ++] return calculateStats() }, }) 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 c77227322..ca0015e6c 100644 --- a/apps/web/content/docs/dev/plugins/admin/dashboard-widgets.mdx +++ b/apps/web/content/docs/dev/plugins/admin/dashboard-widgets.mdx @@ -1,238 +1,116 @@ --- title: Dashboard Widgets -description: Add drag-and-drop widgets to the AdminCP dashboard from your plugin with customizable layouts, sizing, and settings dialogs. +description: Add a configurable AdminCP dashboard widget from a VitNode plugin with sizing, permissions, and optional settings. icon: LayoutDashboard --- -import { TypeTable } from "fumadocs-ui/components/type-table" +import { BarChart3Icon, Settings2Icon } from 'lucide-react' -The AdminCP dashboard at `/admin/core` is an interactive grid of widgets. Each administrator arranges their own board: drag to reorder, resize cards, or add widgets from the side panel. +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. -{/* Image prompt: VitNode AdminCP dashboard widget customization drawer open at /admin/core. Left side displays active dashboard widgets in a responsive grid layout with drag handles. Right side displays the slide-over drawer "Available widgets" showing custom plugin widgets with icons, titles, and descriptions. Save and Cancel buttons at the bottom. Dark theme, 1600x900. */} - -## Register a Widget +{/* Image prompt: VitNode AdminCP dashboard with a “Site notes” statistics widget in a draggable grid. Show the widget drawer, resize affordance, and settings gear. Dark theme, 1600x900. */} - - -### 1. Write the Widget Component + -Create a React component accepting `AdminDashboardWidgetProps`: +### Build the widget component -```tsx title="plugins/blog/src/views/admin/widgets/stats-widget.tsx" -import type { AdminDashboardWidgetProps } from "@vitnode/core/lib/plugin" +```tsx title="plugins/site-notes/src/views/admin/widgets/stats-widget.tsx" +import type { AdminDashboardWidgetProps } from '@vitnode/core/lib/plugin' -export const StatsWidget = ({ settings }: AdminDashboardWidgetProps) => { - return ( -
- Total Published Articles -

42

-
- ) -} +export const StatsWidget = ({ settings }: AdminDashboardWidgetProps) => ( +
+ Published notes + + {String(settings.total ?? 42)} + +
+) ``` -
- + + -### 2. Register Widget in Plugin Config +### Register it in the existing plugin factory -Add the widget to `admin.dashboard.widgets` in `src/config.tsx`: +```tsx title="plugins/site-notes/src/config.tsx" +import { BarChart3Icon } from 'lucide-react' -```tsx title="plugins/blog/src/config.tsx" -import { buildPlugin } from "@vitnode/core/lib/plugin" -import { BarChart3Icon } from "lucide-react" -import { StatsWidget } from "./views/admin/widgets/stats-widget" +import { StatsWidget } from './views/admin/widgets/stats-widget' -export const blogPlugin = () => +export const siteNotesPlugin = () => buildPlugin({ - pluginId: "blog", admin: { dashboard: { - // [!code ++:11] widgets: [ + // [!code ++:9] { - id: "stats", component: StatsWidget, - icon: , - defaultSpan: 1, - defaultRows: 1, defaultEnabled: true, + defaultRows: 1, + defaultSpan: 1, + icon: , + id: 'stats', }, ], }, }, + messages, + pluginId: '@acme/site-notes', + routes, }) ``` - - +The full widget id is namespaced by the plugin, so `stats` will not collide with +another plugin’s idea of a stats card. A good thing—statistics are dramatic +enough already. -### 3. Add Translations + + -Provide the widget's title and description in your plugin's locale file: +### Add a permission or settings UI when needed -```json title="plugins/blog/src/locales/en.json" -{ - "@vitnode/blog": { - "admin": { - "dashboard": { - "widgets": { - "stats": { - "title": "Blog Statistics", - "desc": "Overview of published articles and views." - } - } - } - } - } -} -``` - - -
- ---- +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. -## Adding a Settings Dialog - -Admins can configure widget preferences (e.g. date range or filtering) via a gear icon on the card: - - - - -### 1. Create the Settings Form with `AutoForm` - -Use `useWidgetSettingsDialog` to save values and close the dialog: - -```tsx title="plugins/blog/src/views/admin/widgets/stats-settings.tsx" -import { AutoForm } from "@vitnode/core/components/form/auto-form" -import { AutoFormSelect } from "@vitnode/core/components/form/fields/select" -import { useWidgetSettingsDialog } from "@vitnode/core/views/admin/views/core/dashboard/grid/widget-settings-dialog" -import { z } from "zod" - -const formSchema = z.object({ - range: z.enum(["month", "year"]).default("month"), -}) - -export const StatsSettings = ({ settings }: { settings: { range?: "month" | "year" } }) => { - const { save } = useWidgetSettingsDialog() - - return ( - , - }, - ]} - formSchema={formSchema} - onSubmit={async (values) => { - await save({ range: values.range }) // [!code highlight] - }} - submitButtonProps={{ children: "Save Settings" }} - /> - ) +```tsx title="plugins/site-notes/src/config.tsx" +{ + component: StatsWidget, + // [!code ++:4] + permission: { module: 'site_notes', permission: 'can_view_stats' }, + settingsComponent: StatsSettings, + id: 'stats', } ``` - - - -### 2. Attach `settingsComponent` in Config - -```tsx title="plugins/blog/src/config.tsx" -widgets: [ - { - id: "stats", - component: StatsWidget, - settingsComponent: StatsSettings, // [!code ++] - }, -] -``` - - + ---- - -## Sizing and Grid Responsiveness - -Columns collapse responsively across screen viewports: - -| Viewport | Columns | Behavior | -| :--- | :--- | :--- | -| `< 768px` (Mobile) | 1 | All widgets occupy full width | -| `768px – 1279px` (Tablet) | 2 | Max 2 columns; span 3 collapses to 2 | -| `≥ 1280px` (Desktop) | 3 | Renders full declared `span` (1, 2, or 3) | - ---- - -## Widget Configuration Options - -", - }, - icon: { - description: "Lucide icon displayed in the widget drawer and card header.", - type: "React.ReactNode", - }, - defaultSpan: { - default: "1", - description: "Default width in grid columns (1, 2, or 3).", - type: "1 | 2 | 3", - }, - defaultRows: { - default: "1", - description: "Default height in grid rows (1, 2, or 3).", - type: "1 | 2 | 3", - }, - minSpan: { - default: "1", - description: "Minimum columns the admin can resize down to.", - type: "1 | 2 | 3", - }, - defaultEnabled: { - default: "false", - description: "Whether the widget is placed on fresh dashboards by default.", - type: "boolean", - }, - allowMultiple: { - default: "false", - description: "Allows admins to place multiple independent copies.", - type: "boolean", - }, - permission: { - description: "Hides the widget unless the admin holds this staff permission.", - type: "{ module: string; permission: string }", - }, - settingsComponent: { - description: "Form component rendered inside configuration modal.", - type: "React.ComponentType", - }, - }} -/> +## Widget options -## Learn More +| Option | What it controls | +| ----------------------------- | ------------------------------------------------- | +| `defaultSpan` / `defaultRows` | Initial grid size from 1 to 3 columns or rows. | +| `minSpan` | Narrowest allowed width. | +| `defaultEnabled` | Whether a new dashboard receives the widget. | +| `allowMultiple` | Whether an admin may place more than one copy. | +| `permission` | Staff permission required to see the widget. | +| `settingsComponent` | Plugin form shown by the widget settings control. | } + title="AdminCP pages" + description="Add plugin-owned routes and browser-safe sidebar navigation." href="/docs/dev/plugins/admin" /> } + title="Staff permissions" + description="Define permissions before exposing staff-only capabilities." href="/docs/dev/working-with-users/staff-permissions" /> diff --git a/apps/web/content/docs/dev/plugins/admin/index.mdx b/apps/web/content/docs/dev/plugins/admin/index.mdx index d7c0525f0..34e460df5 100644 --- a/apps/web/content/docs/dev/plugins/admin/index.mdx +++ b/apps/web/content/docs/dev/plugins/admin/index.mdx @@ -1,126 +1,100 @@ --- title: AdminCP Pages -description: Ship custom plugin pages inside the VitNode admin panel with sidebar navigation, breadcrumbs, and staff permission gates. +description: Add plugin-owned AdminCP pages, navigation, permissions, and dashboard extensions to the VitNode admin panel. icon: PanelsTopLeft --- -import { Tab, Tabs } from "fumadocs-ui/components/tabs" +import { LayoutDashboardIcon, RouteIcon, ShieldCheckIcon } from 'lucide-react' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -An AdminCP page is an ordinary plugin route configured with `area: "admin"`. VitNode frames your page with the admin panel's sidebar, breadcrumbs header, command palette, and authenticated staff session. - -## Add an AdminCP Page +AdminCP is a plugin surface. Give the plugin an `area: 'admin'` route and a +browser-safe navigation declaration. The host supplies the panel shell; your +plugin supplies the useful bit. - - -### 1. Declare Route in Manifest + -Add the route to `src/routes/manifest.ts` with `area: "admin"`: +### Claim an AdminCP route -```ts title="plugins/blog/src/routes/manifest.ts" -import type { PluginRouteDefinition } from "@vitnode/core/routing" +```ts title="plugins/site-notes/src/routes/manifest.ts" +import type { PluginRouteDefinition } from '@vitnode/core/routing' export const routes: PluginRouteDefinition[] = [ - // [!code ++:7] + // [!code ++:6] { - area: "admin", - entry: "routes/admin-settings", - id: "settings", - namespaces: ["@vitnode/blog.admin.settings"], - path: "/admin/blog/settings", + area: 'admin', + entry: 'routes/admin-settings-page', + id: 'settings', + path: '/admin/site-notes/settings', }, ] ``` - - - -### 2. Create the Page Component + + -Export a React component as `default`: +### Render the plugin page -```tsx title="plugins/blog/src/routes/admin-settings.tsx" -import { useTranslations } from "use-intl" - -const AdminSettingsPage = () => { - const t = useTranslations("@vitnode/blog.admin.settings") - - return ( -
-
-

{t("title")}

-

{t("desc")}

-
-
- ) -} +```tsx title="plugins/site-notes/src/routes/admin-settings-page.tsx" +const AdminSettingsPage = () => ( +
+

Site notes settings

+

Manage the notes plugin.

+
+) export default AdminSettingsPage ``` -
- + + -### 3. Add to AdminCP Sidebar Navigation +### Add a sidebar entry -Export `adminNav` from `src/admin/nav.tsx` to display a link in the AdminCP sidebar: +Export `adminNav` from the plugin. Its small browser-safe shape is what the +generated AdminCP registry imports—do not edit `admin-nav.gen.ts` yourself. -```tsx title="plugins/blog/src/admin/nav.tsx" -import type { PluginAdminNav } from "@vitnode/core/lib/plugin" -import { SettingsIcon } from "lucide-react" +```tsx title="plugins/site-notes/src/admin/nav.tsx" +import type { AdminNavPluginSource } from '@vitnode/core/lib/plugin' +import { SettingsIcon } from 'lucide-react' -export const adminNav: PluginAdminNav = { - nav: [ - { - id: "settings", - icon: , - href: "/admin/blog/settings", - // Restrict visibility to authorized staff - permission: { module: "blog", permission: "can_manage_settings" }, - }, - ], -} -``` - -Export the module in `package.json`: - -```json title="plugins/blog/package.json" -"exports": { - "./admin/nav": "./dist/src/admin/nav.js" -} +export const adminNav = { + pluginId: '@acme/site-notes', + admin: { + nav: [ + // [!code ++:6] + { + href: '/admin/site-notes/settings', + icon: , + id: 'settings', + permission: { module: 'site_notes', permission: 'can_manage_settings' }, + }, + ], + }, +} satisfies AdminNavPluginSource ``` - - - -### 4. Add Localization Messages +The generator’s `./*` package export already exposes `admin/nav`; no manual +`package.json` export is required. Spread the declaration into the plugin’s +existing factory so the navigation and full registration share one source: -Provide translations for the page and sidebar link in `src/locales/en.json`: +```tsx title="plugins/site-notes/src/config.tsx" +import { adminNav } from './admin/nav' // [!code ++] -```json title="plugins/blog/src/locales/en.json" -{ - "@vitnode/blog": { - "admin": { - "nav": { - "settings": "Settings" - }, - "settings": { - "title": "Blog Settings", - "desc": "Configure blog comments, moderation, and notifications." - } - } - } -} +export const siteNotesPlugin = () => + buildPlugin({ + ...adminNav, // [!code ++] + messages, + routes, + }) ``` - - - -### 5. Verify the Page + + -Start the dev server: +### Verify the screen - + ```bash tab="bun" bun dev @@ -136,48 +110,33 @@ npm run dev -Open `http://localhost:3000/admin/blog/settings` to see your admin screen integrated into the panel. - - -
- ---- +Visit `http://localhost:3000/admin/site-notes/settings` as a staff account with +the declared permission. -## Gating What the Page Shows +{/* Image prompt: VitNode AdminCP settings page contributed by a plugin. Show the existing admin sidebar with “Site notes” selected, a compact settings screen, breadcrumb, and staff-permission badge. Dark theme, 1440x900. */} -Protect administrative actions inside your component using `useStaffPermissions`: - -```tsx title="plugins/blog/src/routes/admin-settings.tsx" -import { useStaffPermissions } from "@vitnode/core/hooks/use-staff-permissions" - -const AdminSettingsPage = () => { - const { hasPermission } = useStaffPermissions() - const canDelete = hasPermission({ module: "blog", permission: "can_delete_posts" }) - - return ( -
- {canDelete && } -
- ) -} -``` + + -## Learn More +## Extend the panel } + title="Dashboard widgets" + description="Add a plugin widget to the admin dashboard’s configurable grid." href="/docs/dev/plugins/admin/dashboard-widgets" /> } + title="Staff permissions" + description="Define the permissions that gate staff actions and navigation." href="/docs/dev/working-with-users/staff-permissions" /> } + title="Route manifest" + description="Configure route areas, namespaces, guards, and parameters." href="/docs/dev/plugins/route-manifest" /> diff --git a/apps/web/content/docs/dev/plugins/admin/meta.json b/apps/web/content/docs/dev/plugins/admin/meta.json index a2aa9d176..d7696b1da 100644 --- a/apps/web/content/docs/dev/plugins/admin/meta.json +++ b/apps/web/content/docs/dev/plugins/admin/meta.json @@ -1,6 +1,6 @@ { - "title": "Admin", - "description": "Give your plugin pages, navigation and dashboard widgets inside the VitNode Admin Control Panel", + "title": "Admin Control Panel", + "description": "Add plugin-owned AdminCP pages, navigation, permissions and dashboard widgets", "icon": "LayoutDashboard", "pages": ["index", "dashboard-widgets"] } diff --git a/apps/web/content/docs/dev/plugins/api/meta.json b/apps/web/content/docs/dev/plugins/api/meta.json index e7f8365a1..224dcc25a 100644 --- a/apps/web/content/docs/dev/plugins/api/meta.json +++ b/apps/web/content/docs/dev/plugins/api/meta.json @@ -1,4 +1,6 @@ { "title": "REST API", - "pages": ["modules", "..."] + "description": "Build typed Hono modules and routes inside your VitNode plugin", + "icon": "Network", + "pages": ["modules", "routes"] } diff --git a/apps/web/content/docs/dev/plugins/api/modules.mdx b/apps/web/content/docs/dev/plugins/api/modules.mdx index c7bc7672d..8563c15cb 100644 --- a/apps/web/content/docs/dev/plugins/api/modules.mdx +++ b/apps/web/content/docs/dev/plugins/api/modules.mdx @@ -1,160 +1,116 @@ --- title: API Modules -description: Group plugin endpoints into named Hono modules and mount them under your plugin API namespace. +description: Add a typed Hono API module to a VitNode plugin, register it once, and keep endpoint ownership with the feature. icon: Box --- -import { TypeTable } from "fumadocs-ui/components/type-table" +import { NetworkIcon, RouteIcon, ShieldCheckIcon } from 'lucide-react' -A module is a Hono sub-application in VitNode. It organizes related routes, cron tasks, queue handlers, and event listeners under a common URL prefix. +Start with [a plugin](/docs/dev/plugins/create), not a host endpoint. A module +groups the plugin's Hono routes under one URL prefix and gives OpenAPI a tidy +place to describe them. -## Quick start +{/* Image prompt: Dark-theme API ownership diagram. A Site notes plugin contains a Hono route, notes module, and config.api file; the app API configuration composes the plugin once. Show resulting GET endpoint, 1600x900. */} -### 1. Create a Module + + -Define a module with routes: +### Define one plugin endpoint -```ts title="plugins/blog/src/api/modules/categories/categories.module.ts" -import { z } from "@hono/zod-openapi" -import { buildModule } from "@vitnode/core/api/lib/module" -import { buildRoute } from "@vitnode/core/api/lib/route" +```ts title="plugins/site-notes/src/api/modules/notes/list.route.ts" +import { z } from '@hono/zod-openapi' +import { buildRoute } from '@vitnode/core/api/lib/route' -const listCategoriesRoute = buildRoute({ - pluginId: "blog", +export const listNotesRoute = buildRoute({ + pluginId: '@acme/site-notes', route: { - method: "get", - path: "/", + method: 'get', + path: '/', responses: { + // [!code ++:7] 200: { content: { - "application/json": { - schema: z.object({ categories: z.array(z.string()) }), + 'application/json': { + schema: z.object({ notes: z.array(z.string()) }), }, }, - description: "List of categories", + description: 'Published site notes.', }, }, }, - handler: (c) => c.json({ categories: ["news", "tech"] }), -}) - -// [!code ++:5] -export const categoriesModule = buildModule({ - pluginId: "blog", - name: "categories", - routes: [listCategoriesRoute], + handler: (c) => c.json({ notes: ['Hello plugin'] }), }) ``` ---- + + -### 2. Register Module in Plugin API Config +### Group it and export the plugin API -Mount the module in `src/config.api.ts`: +```ts title="plugins/site-notes/src/api/modules/notes/notes.module.ts" +import { buildModule } from '@vitnode/core/api/lib/module' -```ts title="plugins/blog/src/config.api.ts" -import { buildApiPlugin } from "@vitnode/core/api/lib/plugin" -import { categoriesModule } from "./api/modules/categories/categories.module" +import { listNotesRoute } from './list.route' -export const blogApiPlugin = () => - buildApiPlugin({ - pluginId: "blog", - // [!code ++:3] - modules: [ - categoriesModule, - ], - }) +export const notesModule = buildModule({ + name: 'notes', + pluginId: '@acme/site-notes', + routes: [listNotesRoute], // [!code ++] +}) ``` -The route is now mounted at `GET /api/blog/categories`. +```ts title="plugins/site-notes/src/config.api.ts" +import { buildApiPlugin } from '@vitnode/core/api/lib/plugin' ---- +import { notesModule } from './api/modules/notes/notes.module' -## URL Path Assembly - -Endpoint URLs are composed automatically: +export const siteNotesApiPlugin = () => + buildApiPlugin({ + modules: [notesModule], // [!code ++] + pluginId: '@acme/site-notes', + }) +``` -| Segment | Origin | Example | -| :--- | :--- | :--- | -| `/api` | Root API route prefix | `/api` | -| `/{pluginId}` | `pluginId` on `buildApiPlugin` | `/blog` | -| `/{moduleName}` | `name` on `buildModule` | `/categories` | -| `/{routePath}` | `path` on `buildRoute` | `/` $ ightarrow$ `/api/blog/categories` | + + ---- +### Compose it in the app API config -## Submodules +The app decides which installed plugins are active. Add the factory to the Hono +config that serves your app (`apps/web` for a single app, or `apps/api` when it +is separate): -Nest modules to organize complex features: +```ts title="apps/web/src/vitnode.api.config.ts" +import { siteNotesApiPlugin } from '@acme/site-notes/config.api' // [!code ++] -```ts -export const adminModule = buildModule({ - pluginId: "blog", - name: "admin", - subModules: [ - postsAdminModule, - categoriesAdminModule, - ], +export const vitNodeApiConfig = buildApiConfig({ + plugins: [siteNotesApiPlugin()], // [!code ++] }) ``` -Routes in `postsAdminModule` answer under `/api/blog/admin/posts/...`. +The endpoint is now `GET /api/@acme/site-notes/notes`. OpenAPI picks it up too; +one less hand-written map to maintain. ---- - -## `buildModule` Options - - - -## Learn More + + } + title="Validate inputs" + description="Use Zod schemas for params, queries, request bodies, and typed responses." + href="/docs/dev/plugins/api/routes" + /> + } + title="Protect staff APIs" + description="Require a staff permission before an AdminCP action reaches its handler." + href="/docs/dev/working-with-users/staff-permissions" /> } + title="Call the API" + description="Use VitNode's typed fetcher from a plugin-owned page or component." href="/docs/dev/fetcher" /> diff --git a/apps/web/content/docs/dev/plugins/api/routes.mdx b/apps/web/content/docs/dev/plugins/api/routes.mdx index 586d6ab08..ddb7324e8 100644 --- a/apps/web/content/docs/dev/plugins/api/routes.mdx +++ b/apps/web/content/docs/dev/plugins/api/routes.mdx @@ -1,150 +1,116 @@ --- title: API Routes -description: Build OpenAPI-validated Hono API routes in VitNode plugins with path params, query validation, and typed response bodies. +description: Validate plugin-owned Hono route inputs and responses with Zod, then protect staff actions with a clear permission. icon: Network --- -import { TypeTable } from "fumadocs-ui/components/type-table" +import { BoxIcon, ShieldCheckIcon, ZapIcon } from 'lucide-react' -Every API route in VitNode is defined with `buildRoute` from `@vitnode/core/api/lib/route`. It validates incoming requests with Zod, infers types for `fetcherClient`, and generates interactive OpenAPI/Swagger documentation automatically. +Create [the plugin](/docs/dev/plugins/create) and its [API module](/docs/dev/plugins/api/modules) +first. Then put the endpoint in that module so its validation, permission, and +OpenAPI record travel together. -## Quick start + + -Define an API route with validation schemas: +### Validate input and response -```ts title="plugins/blog/src/api/modules/posts/routes/get-by-id.route.ts" -import { z } from "@hono/zod-openapi" -import { buildRoute } from "@vitnode/core/api/lib/route" +```ts title="plugins/site-notes/src/api/modules/notes/get.route.ts" +import { z } from '@hono/zod-openapi' +import { buildRoute } from '@vitnode/core/api/lib/route' -// [!code ++:27] -export const getPostByIdRoute = buildRoute({ - pluginId: "blog", +export const getNoteRoute = buildRoute({ + pluginId: '@acme/site-notes', route: { - method: "get", - path: "/{id}", + method: 'get', + path: '/{id}', request: { - params: z.object({ - id: z.coerce.number(), - }), + params: z.object({ id: z.coerce.number().int().positive() }), }, responses: { + // [!code ++:7] 200: { content: { - "application/json": { - schema: z.object({ - id: z.number(), - title: z.string(), - }), + 'application/json': { + schema: z.object({ id: z.number(), title: z.string() }), }, }, - description: "Post details", + description: 'One site note.', }, }, }, - handler: async (c) => { - const { id } = c.req.valid("param") - return c.json({ id, title: "Getting started with VitNode" }) + handler: (c) => { + const { id } = c.req.valid('param') + return c.json({ id, title: 'Plugin-owned note' }) }, }) ``` -Register the route inside a module's `routes` array: + + -```ts title="plugins/blog/src/api/modules/posts/posts.module.ts" -export const postsModule = buildModule({ - pluginId: "blog", - name: "posts", - routes: [getPostByIdRoute], // [!code ++] -}) -``` - ---- +### Gate an AdminCP action -## Handling Inputs +Add `adminStaffPermission` to an action that only staff should call. The API +still authorizes server-side; a hidden button is merely good manners. -### 1. Request Body (JSON) +```ts title="plugins/site-notes/src/api/modules/notes/publish.route.ts" +import { z } from '@hono/zod-openapi' +import { buildRoute } from '@vitnode/core/api/lib/route' -```ts -request: { - body: { - content: { - "application/json": { - schema: z.object({ - title: z.string().min(3), - content: z.string(), - }), +export const publishNoteRoute = buildRoute({ + // [!code ++:13] + adminStaffPermission: { + module: 'site_notes', + permission: 'can_publish', + }, + pluginId: '@acme/site-notes', + route: { + method: 'post', + path: '/{id}/publish', + responses: { + 200: { + content: { + 'application/json': { + schema: z.object({ published: z.literal(true) }), + }, + }, + description: 'The note was published.', }, }, }, -} - -// In handler: -const { title, content } = c.req.valid("json") -``` - -### 2. Query Parameters - -```ts -request: { - query: z.object({ - page: z.coerce.number().default(1), - filter: z.string().optional(), - }), -} - -// In handler: -const { page, filter } = c.req.valid("query") + handler: async (c) => c.json({ published: true }), +}) ``` ---- + + -## `buildRoute` Options - - Promise | Response", - }, - adminStaffPermission: { - description: "Restricts endpoint access to staff holding this permission.", - type: "{ module: string; permission: string }", - }, - withCaptcha: { - default: "false", - description: "Demands and verifies challenge token before execution.", - type: "boolean", - }, - }} -/> +{/* Image prompt: Dark-theme API documentation screenshot. Show an OpenAPI endpoint for a plugin route with path parameter validation, a successful JSON response, and a staff-permission lock badge. 1440x900. */} -## Learn More + + Keep handlers with the feature that owns the data. The host API config only + composes plugins; it should not become a surprise sequel to your business + logic. + } + title="API modules" + description="Register a module and its plugin API factory with the Hono application." href="/docs/dev/plugins/api/modules" /> } + title="Staff permissions" + description="Define the permission catalog that gates AdminCP actions." + href="/docs/dev/working-with-users/staff-permissions" /> } + title="Typed fetcher" + description="Call validated plugin endpoints from the TanStack Start UI." + href="/docs/dev/fetcher" /> diff --git a/apps/web/content/docs/dev/plugins/breadcrumbs.mdx b/apps/web/content/docs/dev/plugins/breadcrumbs.mdx index 7fa78bb8e..9654388b9 100644 --- a/apps/web/content/docs/dev/plugins/breadcrumbs.mdx +++ b/apps/web/content/docs/dev/plugins/breadcrumbs.mdx @@ -1,24 +1,25 @@ --- title: Breadcrumbs -description: Declare breadcrumbs in TanStack Router routes - in plugin route modules or host route staticData. +description: Declare localized breadcrumbs in plugin route modules for public pages and AdminCP screens. icon: Milestone --- -VitNode provides breadcrumb slots in both the AdminCP header and the public site layout. The deepest matched route that declares a breadcrumb wins. +VitNode provides breadcrumb slots in both the AdminCP header and public site +layout. The deepest plugin route that declares a breadcrumb wins. ## Quick start -### 1. In a Plugin Route (Recommended) +### Add a plugin breadcrumb Plugin routes export a breadcrumb component on `definePluginRoute`: ```tsx title="plugins/blog/src/routes/overview-page.tsx" -import { definePluginRoute } from "@vitnode/core/routing" -import { useTranslations } from "use-intl" +import { definePluginRoute } from '@vitnode/core/routing' +import { useTranslations } from 'use-intl' const OverviewBreadcrumb = () => { - const t = useTranslations("@vitnode/blog") - return {t("overview")} + const t = useTranslations('@vitnode/blog') + return {t('overview')} } // [!code ++:3] @@ -29,45 +30,11 @@ export const route = definePluginRoute({ The crumb inherits the route's declared `namespaces`, so `useTranslations` resolves seamlessly. ---- - -### 2. In an Application Route File - -For host app routes, place a JSX element on `staticData.breadcrumb`: - -```tsx title="apps/web/src/routes/_admin/admin.core.index.tsx" -import { createFileRoute } from "@tanstack/react-router" -import { AdminBreadcrumb } from "@vitnode/core/tanstack/admin" - -export const Route = createFileRoute("/_admin/admin/core/")({ - component: AdminDashboardRoute, - // [!code ++:3] - staticData: { - breadcrumb: , - }, -}) -``` - ---- - -## Resolution Hierarchy - -| `staticData.breadcrumb` | Behavior | -| :--- | :--- | -| `` | Renders the declared crumb. | -| `null` | Deliberately clears any ancestor breadcrumbs. | -| `undefined` | Falls back to the closest ancestor route that declared a crumb. | - ---- - ## Best Practices - - In host app route files, always write `breadcrumb: `, not `breadcrumb: MyCrumb`. The shell renders the element directly. - - - `` resolves titles automatically from the AdminCP sidebar navigation dictionary. + AdminCP labels resolve from the plugin navigation dictionary. Keep the route, + sidebar entry, and translations in the same package. ## Learn More diff --git a/apps/web/content/docs/dev/plugins/create.mdx b/apps/web/content/docs/dev/plugins/create.mdx index a90acb73a..03a0fa322 100644 --- a/apps/web/content/docs/dev/plugins/create.mdx +++ b/apps/web/content/docs/dev/plugins/create.mdx @@ -1,26 +1,31 @@ --- title: Create a Plugin -description: Scaffold a VitNode plugin with one command, register it in your app, and open the page it serves. +description: Scaffold a VitNode plugin, register its package in your host app, and serve its first TanStack Start page. icon: PackagePlus --- -import { File, Files, Folder } from "fumadocs-ui/components/files" -import { Tab, Tabs } from "fumadocs-ui/components/tabs" +import { DatabaseIcon, LayoutDashboardIcon, RouteIcon } from 'lucide-react' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -VitNode plugins are workspace packages containing a route manifest, API modules, and database models. Plugin code remains isolated inside `plugins/*` and compiles into its own `dist/`. +A plugin is the starting point for a VitNode feature. It keeps routes, API +modules, data, translations, and AdminCP extensions together in one installable +package. Nice boundaries; fewer archaeological digs later. -## Prerequisites - -Run the plugin generator from your repository root (where `turbo.json` and `pnpm-workspace.yaml` reside): - -## Scaffold and Register a Plugin + + Run the generator from a repository with `turbo.json`. When creating an app, + turn on Turborepo first; a plain single-folder app has nowhere for + `plugins/*`. + - + + +### Generate the package -### 1. Run the Generator +Run this at the workspace root and enter a package name such as +`@acme/site-notes` when prompted: - + ```bash tab="bun" bun create vitnode-app@canary --plugin @@ -31,94 +36,63 @@ pnpm create vitnode-app@canary --plugin ``` ```bash tab="npm" -npx create-vitnode-app@canary --plugin +npm create vitnode-app@canary -- --plugin ``` -Specify your plugin name (e.g., `my-plugin`). The CLI creates `plugins/my-plugin/` and registers it across your workspace dependencies. +The CLI creates `plugins/site-notes`, adds it as a workspace dependency, and +gives it a route, locale, and config skeleton. It does **not** enable the +feature for the host—that explicit switch is next. - - + + -### 2. Inspect the Plugin Contract +### Keep the route in the plugin -A new plugin contains two essential setup files under `src/`: +The generated manifest is the public contract. Add another record here when the +plugin needs another URL; never copy its page into `apps/web/src/routes`. -#### The Route Manifest -Defines which URLs the plugin serves: - -```ts title="plugins/my-plugin/src/routes/manifest.ts" -import type { PluginRouteDefinition } from "@vitnode/core/routing" +```ts title="plugins/site-notes/src/routes/manifest.ts" +import type { PluginRouteDefinition } from '@vitnode/core/routing' export const routes: PluginRouteDefinition[] = [ + // [!code ++:5] { - entry: "routes/home-page", - id: "home", - path: "/my-plugin", - namespaces: ["my-plugin"], + entry: 'routes/home-page', + id: 'home', + path: '/site-notes', }, ] ``` -#### The Plugin Config -Exports the plugin definition consumed by the host application: - -```tsx title="plugins/my-plugin/src/config.tsx" -import { buildPlugin } from "@vitnode/core/lib/plugin" -import messages from "./locales" -import { routes } from "./routes/manifest" + + -export const myPlugin = () => - buildPlugin({ - pluginId: "my-plugin", - messages, - routes, - }) -``` +### Register the plugin with the host - - - -### 3. Register Plugin in Your Host Application - -Add the plugin factory to `apps/web/src/vitnode.config.ts`: +Import the plugin factory in the host config and add it to `plugins`: ```ts title="apps/web/src/vitnode.config.ts" -import { buildConfig } from "@vitnode/core/vitnode.config" -import { myPlugin } from "my-plugin/config" // [!code ++] -import { appMessages } from "./locales/app" -import { vitNodeShellConfig } from "./vitnode.shell.config" +import { buildConfig } from '@vitnode/core/vitnode.config' +import { siteNotesPlugin } from '@acme/site-notes/config' // [!code ++] export const vitNodeConfig = buildConfig({ - ...vitNodeShellConfig, - i18n: { ...vitNodeShellConfig.i18n, messages: appMessages }, plugins: [ - myPlugin(), // [!code ++] + siteNotesPlugin(), // [!code ++] ], }) ``` -And connect your plugin's localization messages in `apps/web/src/locales/packages.ts`: - -```ts title="apps/web/src/locales/packages.ts" -export const packageMessages: Record = { - [CORE.pluginId]: { - en: async () => await import("@vitnode/core/locales/en.json"), - }, - // [!code ++:3] - "my-plugin": { - en: async () => await import("my-plugin/locales/en.json"), - }, -} -``` +The factory already carries the plugin's manifest and message loaders. Keep host +configuration to composition; the feature stays in its package. - - + + -### 4. Start the Dev Server +### Run it and visit the route - + ```bash tab="bun" bun dev @@ -134,44 +108,33 @@ npm run dev -Visit `http://localhost:3000/my-plugin` to view your new plugin live. +Open `http://localhost:3000/site-notes`. The page comes from the plugin, gets +its own chunk, and never moves house. Tiny victory dance optional. - - - ---- - -## Generated Files Reference - -During dev or build, Vite inspects your configured plugins and automatically synchronizes these files: - -| Generated File | Content | -| :--- | :--- | -| `src/plugin-route-manifest.gen.ts` | Route table with paths and layout trees | -| `src/plugin-routes.gen.ts` | Lazy `import()` statements for route chunks | -| `src/admin-nav.gen.ts` | AdminCP navigation items from plugins | -| `src/content-registry.gen.ts` | Content Engine administrative screens | +{/* Image prompt: Split-screen developer tutorial image. Left shows a plugin folder with manifest, locale, and route files. Right shows the resulting /site-notes page in a VitNode app. Dark theme, precise code-like labels, 1600x900. */} - - Files ending in `.gen.ts` are regenerated automatically. Always edit your plugin's source files instead. - + + -## Learn More +## Add the next capability } + title="Route manifest" + description="Add dynamic URLs, loaders, metadata, and route guards." href="/docs/dev/plugins/route-manifest" /> } + title="Database models" + description="Put Drizzle models and migrations beside the feature that owns them." + href="/docs/dev/database" /> } + title="Admin Control Panel" + description="Ship staff screens, navigation, permissions, and dashboard widgets." + href="/docs/dev/plugins/admin" /> diff --git a/apps/web/content/docs/dev/plugins/meta.json b/apps/web/content/docs/dev/plugins/meta.json index 3f7d0ce11..5d0907701 100644 --- a/apps/web/content/docs/dev/plugins/meta.json +++ b/apps/web/content/docs/dev/plugins/meta.json @@ -1,6 +1,6 @@ { "title": "Plugins", - "description": "Build a VitNode plugin: pages, API modules, database tables, AdminCP screens and translations in one installable package", + "description": "Build installable VitNode plugins for pages, APIs, data, AdminCP screens, and translations", "icon": "Plug", "defaultOpen": true, "pages": ["create", "route-manifest", "api", "admin", "breadcrumbs", "..."] diff --git a/apps/web/content/docs/dev/plugins/route-manifest.mdx b/apps/web/content/docs/dev/plugins/route-manifest.mdx index f7a70b9ae..b3d662979 100644 --- a/apps/web/content/docs/dev/plugins/route-manifest.mdx +++ b/apps/web/content/docs/dev/plugins/route-manifest.mdx @@ -1,142 +1,76 @@ --- title: Route Manifest -description: Declare plugin routes cleanly as serializable data with routes/manifest.ts - dynamic paths, layouts, guards, and module exports. +description: Declare a plugin-owned TanStack Start route with a stable URL, loader data, metadata, and no host page files. icon: Map --- -import { Accordion, Accordions } from "fumadocs-ui/components/accordion" -import { File, Files, Folder } from "fumadocs-ui/components/files" -import { Tab, Tabs } from "fumadocs-ui/components/tabs" -import { TypeTable } from "fumadocs-ui/components/type-table" +import { DatabaseIcon, LayoutDashboardIcon, RouteIcon } from 'lucide-react' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -A plugin owns its pages. It declares what pages it has and where they live in `src/routes/manifest.ts`. VitNode reads this manifest at build time and mounts each page into TanStack Router with code splitting and SSR out of the box. +Start by [creating a plugin](/docs/dev/plugins/create). A route manifest is the +plugin's promise: which URL it owns and which module renders it. The host turns +that promise into a lazy TanStack Start route—no copied page files, no drama. -## Quick start - -Three fields define a complete route: - -```ts title="plugins/my-plugin/src/routes/manifest.ts" -import type { PluginRouteDefinition } from "@vitnode/core/routing" - -export const routes: PluginRouteDefinition[] = [ - // [!code ++:5] - { - entry: "routes/post-page", - id: "post", - path: "/blog/:slug", - }, -] -``` - -And the page module it points to: - -```tsx title="plugins/my-plugin/src/routes/post-page.tsx" -const PostPage = () => ( -
-

Hello from plugin!

-
-) - -export default PostPage -``` - -## Directory Structure - -All route files live inside your plugin's `src/routes/` directory: - - - - - - - - - - - - -## Add a route step by step +{/* Image prompt: Dark-theme developer diagram: a plugin route manifest points to a route module, then into a TanStack Start route inside the app shell. Emphasize “plugin owns feature” and “host composes”. Clean labels, 1600x900. */} - - -### 1. Declare the route in manifest.ts + -Export an array of routes from `src/routes/manifest.ts`: +### Declare the URL in the plugin -```ts title="plugins/my-plugin/src/routes/manifest.ts" -import type { PluginRouteDefinition } from "@vitnode/core/routing" +```ts title="plugins/site-notes/src/routes/manifest.ts" +import type { PluginRouteDefinition } from '@vitnode/core/routing' export const routes: PluginRouteDefinition[] = [ // [!code ++:5] { - entry: "routes/post-page", - id: "post", - path: "/blog/:slug", + entry: 'routes/note-page', + id: 'note', + path: '/notes/:slug', }, ] ``` - - +Use `:slug` for dynamic segments. VitNode converts it to TanStack Start's +internal `$slug` spelling while keeping your plugin manifest portable. -### 2. Create the route component module + + -Export a React component as `default`. Optionally export `route = definePluginRoute({ ... })` for data loading and metadata: +### Keep behavior beside the page -```tsx title="plugins/my-plugin/src/routes/post-page.tsx" -import type { PluginRoutePageProps } from "@vitnode/core/routing" -import { definePluginRoute } from "@vitnode/core/routing" +```tsx title="plugins/site-notes/src/routes/note-page.tsx" +import type { PluginRoutePageProps } from '@vitnode/core/routing' +import { definePluginRoute } from '@vitnode/core/routing' -interface Post { +interface Note { title: string - content: string } -// [!code ++:6] +// [!code ++:8] export const route = definePluginRoute({ - load: async ({ params }) => ({ - title: `Post ${params.slug}`, - content: "Welcome to this post!", + load: async ({ params }) => ({ title: `Note: ${params.slug}` }), + head: ({ loaderData }) => ({ + description: 'A note delivered by the Site notes plugin.', + title: loaderData?.title, }), }) -const PostPage = ({ loaderData }: PluginRoutePageProps) => ( -
-

{loaderData.title}

-

{loaderData.content}

+const NotePage = ({ loaderData }: PluginRoutePageProps) => ( +
+

{loaderData.title}

) -export default PostPage +export default NotePage ``` - - + + -### 3. Register routes in your plugin config +### Run the plugin route -Pass the `routes` array into `buildPlugin`: - -```tsx title="plugins/my-plugin/src/config.tsx" -import { buildPlugin } from "@vitnode/core/lib/plugin" -import messages from "./locales" -import { routes } from "./routes/manifest" // [!code ++] - -export const myPlugin = () => - buildPlugin({ - pluginId: "my-plugin", - messages, - routes, // [!code ++] - }) -``` - - - - -### 4. Run the app - - + ```bash tab="bun" bun dev @@ -152,209 +86,43 @@ npm run dev -Visit `/blog/hello` to see your route rendered live. +Visit `http://localhost:3000/notes/hello`. The page's code, data, and SEO stay +with the feature that needs them. A surprisingly polite route. - + -## Field Reference - -Nine fields configure a `PluginRouteDefinition`. Only `id`, `path`, and `entry` are required: - - - -## Path Syntax Rules - -| Shape | Write this | Not this | -| --------------- | ---------------------- | ----------------------------------------- | -| Static | `/blog` | - | -| Dynamic segment | `/blog/:slug` | `/blog/[slug]`, `/blog/$slug` | -| Nested | `/blog/:slug/comments` | `/blog/$slug/comments` | -| Root | `/` | `""`, `blog` (a path must start with `/`) | - -- **Use `:slug` in manifests**: VitNode compiles `:slug` into TanStack Router's `$slug` syntax automatically. -- **Lowercase static segments**: Paths match case-insensitively. Always write `/blog/post`, not `/Blog/Post`. -- **Never include locale prefixes**: `/blog` automatically serves `/pl/blog` or any configured locale. +## Choose the route shape -## Layouts and Nesting - -Group related routes inside a shared layout using `kind: 'layout'` and `parentId`: - -```ts title="plugins/my-plugin/src/routes/manifest.ts" -export const routes: PluginRouteDefinition[] = [ - // Parent layout - { - id: "docs", - entry: "routes/docs-layout", - path: "/docs", - kind: "layout", // [!code ++] - }, - // Child pages - { - id: "docs-index", - entry: "routes/docs-index-page", - path: "/docs", - parentId: "docs", // [!code ++] - }, - { - id: "docs-topic", - entry: "routes/docs-topic-page", - path: "/docs/:topic", - parentId: "docs", // [!code ++] - }, -] -``` +| Need | Add to the manifest | +| -------------------- | ------------------------------------------------------ | +| Public feature page | `path: '/notes'` (the default `area` is `main`) | +| Staff screen | `area: 'admin'` and a full path such as `/admin/notes` | +| Signed-in visitor | `requires: 'authenticated'` | +| Shared plugin layout | `kind: 'layout'` plus child `parentId` values | -In the layout component, render `` where child routes appear: - -```tsx title="plugins/my-plugin/src/routes/docs-layout.tsx" -import { Outlet } from "@tanstack/react-router" - -const DocsLayout = () => ( -
- -
- -
-
-) - -export default DocsLayout -``` - -## AdminCP Pages - -Set `area: "admin"` to mount your route inside the AdminCP shell (with sidebar, breadcrumbs, and staff auth): - -```ts title="plugins/my-plugin/src/routes/manifest.ts" -{ - id: "settings", - entry: "routes/admin-settings-page", - path: "/admin/my-plugin/settings", - area: "admin", // [!code ++] -} -``` - -To add an item in the AdminCP sidebar, register it in `src/admin/nav.tsx` as well. See [AdminCP Pages](/docs/dev/plugins/admin) for details. - -## What the Route Module Exports - -A route module exports a default component and an optional `definePluginRoute` configuration: - -```tsx title="plugins/my-plugin/src/routes/topic-page.tsx" -import type { PluginRoutePageProps } from "@vitnode/core/routing" -import { definePluginRoute } from "@vitnode/core/routing" - -interface Topic { - title: string - description: string -} - -// [!code ++:8] -export const route = definePluginRoute({ - load: async ({ context, params }) => { - return await fetchTopic(params.topic, context.locale) - }, - head: ({ loaderData }) => ({ - title: loaderData?.title, - description: loaderData?.description, - }), -}) - -const TopicPage = ({ loaderData }: PluginRoutePageProps) => ( -
-

{loaderData.title}

-

{loaderData.description}

-
-) - -export default TopicPage -``` - -### Route Lifecycle Hooks - -| Hook | Description | -| ---- | ----------- | -| `load` | Runs on server and client before render to load data. Receives `{ context, params, search }`. | -| `head` | Emits page ``, `<meta>`, and Open Graph tags. Receives `{ loaderData, params }`. | -| `breadcrumb` | Component rendering breadcrumb item in shell header. | -| `parseSearch` | Normalizes URL query string parameters for typed `search` access. | - -<Callout type="info" title="Declare load above head"> - TypeScript infers `loaderData` type in `head` and the page component from what `load` returns. Always declare `load` above `head` in `definePluginRoute`. +<Callout type="idea" title="The host is the exception"> + Use host routes only for shells, docs, or site-wide infrastructure. A product + page belongs in its plugin, even when it starts life as one brave little URL. </Callout> -## Best Practices & Gotchas - -<Callout type="warn" title="No file extensions in entry"> - Write `entry: 'routes/post-page'`, never `'routes/post-page.tsx'`. Export subpaths resolve automatically via your plugin's `package.json` export map. -</Callout> - -<Callout type="info" title="Rebuilding after adding new routes"> - When you add a brand new route to `manifest.ts`, restart your dev server so the Vite plugin recognizes the new file and updates generated registries. -</Callout> - -## Learn More - <Cards> <Card - title="Create a Plugin" - description="Scaffold a plugin and understand the core package structure" - href="/docs/dev/plugins/create" + icon={<DatabaseIcon />} + title="Load data" + description="Use plugin loaders with cache-aware data and query state." + href="/docs/dev/data-loading" /> <Card - title="AdminCP Pages" - description="Add admin navigation, layouts, and permissions" + icon={<LayoutDashboardIcon />} + title="AdminCP pages" + description="Mount a plugin screen in the staff-only Admin Control Panel." href="/docs/dev/plugins/admin" /> <Card - title="Data Loading" - description="SSR query hydration and client caching" - href="/docs/dev/data-loading" + icon={<RouteIcon />} + title="Build the plugin" + description="Generate the package before adding its next route or capability." + href="/docs/dev/plugins/create" /> </Cards> diff --git a/apps/web/content/docs/dev/routing/index.mdx b/apps/web/content/docs/dev/routing/index.mdx index 3d9641d6e..36727ce05 100644 --- a/apps/web/content/docs/dev/routing/index.mdx +++ b/apps/web/content/docs/dev/routing/index.mdx @@ -1,65 +1,67 @@ --- title: Routing -description: Claim a URL in VitNode - plugin manifest routes for reusable packages, and host app routes for site-specific pages. +description: Add public and AdminCP URLs through plugin route manifests, with TanStack Start loaders, metadata, and code splitting. icon: Route --- -import { File, Files, Folder } from "fumadocs-ui/components/files" -import { Tab, Tabs } from "fumadocs-ui/components/tabs" +import { DatabaseIcon, FileTextIcon, RouteIcon } from 'lucide-react' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -VitNode uses TanStack Start for routing. Routes are divided into three tiers: +VitNode uses TanStack Start, but feature routes begin in a plugin. The manifest +is plain data that the host compiles into lazy route imports, so the package owns +the page without copying files into `apps/web/src/routes`. -| Tier | Declaration | Location | Purpose | -| :--- | :--- | :--- | :--- | -| **Plugin Routes** | `routes/manifest.ts` | `plugins/*/src/routes/manifest.ts` | **Recommended**. Reusable across any VitNode install. | -| **Application Routes** | File-based routes | `apps/web/src/routes/**` | Site-specific pages owned directly by your app. | -| **Core Routes** | Code-based routes | Built into `@vitnode/core` | System routes (`/login`, `/admin/*`, `/search`). | - ---- - -## 1. Create a Page in a Plugin (Recommended) - -Plugins declare routes as serializable data. The build system mounts them into the route tree with SSR and automatic code splitting. +| Put it in | Use it for | Default | +| ---------------- | --------------------------------------------------------------- | -------- | +| **Plugin route** | Product pages, feature flows, content delivery, AdminCP screens | Yes | +| **Host route** | Shell, framework wiring, docs, or a truly site-wide integration | Rare | +| **Core route** | Login, AdminCP frame, search, and other VitNode system screens | Built in | <Steps> -<Step> + <Step> -### Declare the Route in `routes/manifest.ts` +### Declare the plugin URL -```ts title="plugins/blog/src/routes/manifest.ts" -import type { PluginRouteDefinition } from "@vitnode/core/routing" +```ts title="plugins/site-notes/src/routes/manifest.ts" +import type { PluginRouteDefinition } from '@vitnode/core/routing' export const routes: PluginRouteDefinition[] = [ // [!code ++:5] { - entry: "routes/blog-page", - id: "blog", - path: "/blog", + entry: 'routes/notes-page', + id: 'notes', + path: '/notes', }, ] ``` -</Step> -<Step> +Use `:slug` for a dynamic segment, such as `path: '/notes/:slug'`. VitNode +maps it to TanStack Start’s internal `$slug` spelling for you. + + </Step> + <Step> -### Create the Page Component +### Add the page module -```tsx title="plugins/blog/src/routes/blog-page.tsx" -const BlogPage = () => ( - <div className="container mx-auto p-4"> - <h1 className="text-3xl font-bold">Blog Overview</h1> +```tsx title="plugins/site-notes/src/routes/notes-page.tsx" +const NotesPage = () => ( + <div className="container mx-auto flex max-w-3xl flex-col gap-4 p-4"> + <h2 className="text-3xl font-semibold">Site notes</h2> + <p className="text-muted-foreground"> + A route that lives with its feature. + </p> </div> ) -export default BlogPage +export default NotesPage ``` -</Step> -<Step> + </Step> + <Step> -### View the Page +### Run it -<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]}> +<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Run a plugin route"> ```bash tab="bun" bun dev @@ -75,123 +77,69 @@ npm run dev </Tabs> -Open `http://localhost:3000/blog`. - -</Step> -</Steps> - ---- - -## 2. Create a Page in Your Application - -When creating a page that belongs only to your host site, add a file in `apps/web/src/routes/`: - -<Steps> -<Step> - -### Choose a Pathless Shell - -- `_main/` for public pages (with header, navigation, and footer). -- `_admin/` for administrative screens. - -</Step> -<Step> - -### Create the Route File +Open `http://localhost:3000/notes`. -```tsx title="apps/web/src/routes/_main/about.tsx" -import { createFileRoute } from "@tanstack/react-router" -import { pageHead } from "#/lib/page-head" - -export const Route = createFileRoute("/_main/about")({ - head: () => - pageHead({ - title: "About Us", - description: "Learn more about our team and mission.", - }), - component: AboutPage, -}) - -function AboutPage() { - return ( - <div className="container mx-auto p-4"> - <h1 className="text-3xl font-bold">About Us</h1> - </div> - ) -} -``` - -</Step> + </Step> </Steps> ---- - -## Dynamic Segments & Parameters - -Dynamic parameters differ slightly between manifests and route files: +## Load data and set metadata -| Context | Syntax | Example | Access Parameter | -| :--- | :--- | :--- | :--- | -| **Plugin Manifest** | `:param` | `path: "/blog/:slug"` | `params.slug` in `load` / props | -| **App Route File** | `$param` | `_main/blog.$slug.tsx` | `Route.useParams().slug` | +Export `route` from the same plugin module. Declare `load` before `head` so +TypeScript carries the inferred data into your metadata: -VitNode compiles `:slug` into TanStack Router's `$slug` syntax automatically. +```tsx title="plugins/site-notes/src/routes/note-page.tsx" +import type { PluginRoutePageProps } from '@vitnode/core/routing' +import { definePluginRoute } from '@vitnode/core/routing' ---- - -## Plugin Route Lifecycle (`definePluginRoute`) - -To load data, define metadata, or customize breadcrumbs in a plugin route module, export `route`: - -```tsx title="plugins/blog/src/routes/post-page.tsx" -import type { PluginRoutePageProps } from "@vitnode/core/routing" -import { definePluginRoute } from "@vitnode/core/routing" - -interface Post { - title: string +interface Note { body: string + title: string } -// [!code ++:10] +// [!code ++:9] export const route = definePluginRoute({ - load: async ({ params }) => { - return await fetchPostBySlug(params.slug) - }, + load: async ({ params }) => await fetchNote(params.slug), head: ({ loaderData }) => ({ + description: loaderData?.body.slice(0, 155), title: loaderData?.title, }), }) -const PostPage = ({ loaderData }: PluginRoutePageProps<Post>) => ( - <article className="container mx-auto p-4"> - <h1 className="text-3xl font-bold">{loaderData.title}</h1> +const NotePage = ({ loaderData }: PluginRoutePageProps<Note>) => ( + <article className="container mx-auto max-w-3xl p-4"> + <h2 className="text-3xl font-semibold">{loaderData.title}</h2> <p>{loaderData.body}</p> </article> ) -export default PostPage +export default NotePage ``` -<Callout type="info" title="Declare load above head"> - TypeScript infers `loaderData` in `head` and the page component from what `load` returns. Always declare `load` before `head`. +<Callout type="idea" title="The host is the exception"> + Keep host routes for app-wide framing or infrastructure. If a route belongs to + a feature, make a plugin first—even when the feature starts small. Small + things have a habit of bringing friends. </Callout> -## Learn More +## Continue from the route <Cards> <Card - title="Route Manifest" - description="Complete reference for layouts, admin areas, and guards" + icon={<RouteIcon />} + title="Route manifest reference" + description="Configure areas, layouts, namespaces, guards, and dynamic paths." href="/docs/dev/plugins/route-manifest" /> <Card - title="Data Loading" - description="Server data loading and client hydration" + icon={<DatabaseIcon />} + title="Data loading" + description="Use loader data with TanStack Query and server-aware caching." href="/docs/dev/data-loading" /> <Card - title="Metadata & SEO" - description="Page titles, descriptions, and Open Graph tags" + icon={<FileTextIcon />} + title="Metadata and SEO" + description="Set useful titles, descriptions, robots, and social metadata." href="/docs/dev/routing/metadata" /> </Cards> diff --git a/apps/web/content/docs/dev/routing/loading-states.mdx b/apps/web/content/docs/dev/routing/loading-states.mdx index 6f70040dc..042b31618 100644 --- a/apps/web/content/docs/dev/routing/loading-states.mdx +++ b/apps/web/content/docs/dev/routing/loading-states.mdx @@ -4,19 +4,19 @@ description: Render instant skeleton shapes while TanStack Start routes load dat icon: Loader2 --- -import { TypeTable } from "fumadocs-ui/components/type-table" +import { TypeTable } from 'fumadocs-ui/components/type-table' While a route's loader runs or its code chunk downloads, TanStack Router displays a pending component. VitNode provides pre-built skeleton layouts matching common UI patterns. ## Quick start -### 1. In a Plugin Component (Suspense) +### Use Suspense in a plugin component Plugin routes load dynamically. Use React `Suspense` with VitNode's pending skeletons: ```tsx title="plugins/blog/src/routes/posts-page.tsx" -import { FeedPendingSkeleton } from "@vitnode/core/tanstack/pending" -import React, { Suspense } from "react" +import { FeedPendingSkeleton } from '@vitnode/core/tanstack/pending' +import React, { Suspense } from 'react' const PostsList = () => { // Data loading component @@ -33,35 +33,18 @@ const PostsPage = () => ( export default PostsPage ``` -### 2. In an Application Route File - -Attach `pendingComponent` directly to `createFileRoute`: - -```tsx title="apps/web/src/routes/_main/posts.tsx" -import { createFileRoute } from "@tanstack/react-router" -import { FeedPendingSkeleton } from "@vitnode/core/tanstack/pending" // [!code ++] - -export const Route = createFileRoute("/_main/posts")({ - loader: async () => await fetchPosts(), - component: PostsPage, - pendingComponent: FeedPendingSkeleton, // [!code ++] -}) -``` - ---- - ## Pre-Built Pending Skeletons Import these shapes from `@vitnode/core/tanstack/pending`: -| Shape | Layout | Typical Use Case | -| :--- | :--- | :--- | -| `FeedPendingSkeleton` | Card timeline with avatars | Activity feeds, search results, articles | -| `TablePendingSkeleton` | Toolbar, table header, and rows | Data tables, AdminCP lists, files | -| `FormPendingSkeleton` | Card with inputs and button actions | Settings pages, edit dialogs | -| `CardsPendingSkeleton` | Responsive 1/2/3 column card grid | Dashboard overview, integrations | -| `AuthPendingSkeleton` | Centered authentication card | Sign in, registration, password reset | -| `RoutePendingSpinner` | Centered accessible spinner | Minimalistic or unconventional pages | +| Shape | Layout | Typical Use Case | +| :--------------------- | :---------------------------------- | :--------------------------------------- | +| `FeedPendingSkeleton` | Card timeline with avatars | Activity feeds, search results, articles | +| `TablePendingSkeleton` | Toolbar, table header, and rows | Data tables, AdminCP lists, files | +| `FormPendingSkeleton` | Card with inputs and button actions | Settings pages, edit dialogs | +| `CardsPendingSkeleton` | Responsive 1/2/3 column card grid | Dashboard overview, integrations | +| `AuthPendingSkeleton` | Centered authentication card | Sign in, registration, password reset | +| `RoutePendingSpinner` | Centered accessible spinner | Minimalistic or unconventional pages | --- @@ -72,18 +55,18 @@ Standard skeleton shapes accept custom classes and row counts: <TypeTable type={{ className: { - description: "Additional CSS classes merged onto outer container.", - type: "string", + description: 'Additional CSS classes merged onto outer container.', + type: 'string', }, rows: { - default: "3 to 6", - description: "Number of skeleton rows or cards to display.", - type: "number", + default: '3 to 6', + description: 'Number of skeleton rows or cards to display.', + type: 'number', }, label: { default: "'Loading'", - description: "Screen-reader text rendered for accessibility.", - type: "string", + description: 'Screen-reader text rendered for accessibility.', + type: 'string', }, }} /> diff --git a/apps/web/content/docs/dev/routing/metadata.mdx b/apps/web/content/docs/dev/routing/metadata.mdx index ac56979e0..d9c3fb0d5 100644 --- a/apps/web/content/docs/dev/routing/metadata.mdx +++ b/apps/web/content/docs/dev/routing/metadata.mdx @@ -1,12 +1,12 @@ --- title: Metadata & SEO -description: Give every page a title, description, robots directive, and Open Graph tags with TanStack Router head. +description: Give plugin pages concise titles, descriptions, and robots directives through TanStack Start route metadata. icon: Tags --- -import { TypeTable } from "fumadocs-ui/components/type-table" - -VitNode manages SEO metadata through TanStack Router's `head` option. Titles are formatted against your site's global name, emitting standard `<title>`, `<meta name="description">`, and Open Graph tags. +VitNode plugin routes declare their own metadata through `head`. Keep it short, +specific, and useful enough that a search result does not sound like it was +written by a toaster. ## Quick start @@ -15,7 +15,7 @@ VitNode manages SEO metadata through TanStack Router's `head` option. Titles are Plugins declare metadata using `definePluginRoute`. Metadata can read dynamically from `loaderData`: ```tsx title="plugins/blog/src/routes/article-page.tsx" -import { definePluginRoute } from "@vitnode/core/routing" +import { definePluginRoute } from '@vitnode/core/routing' export const route = definePluginRoute({ load: async ({ params }) => await fetchArticle(params.slug), @@ -23,83 +23,24 @@ export const route = definePluginRoute({ head: ({ loaderData }) => ({ title: loaderData?.title, description: loaderData?.summary, - robots: "index, follow", + robots: 'index, follow', }), }) ``` The browser tab automatically displays **Article Title - VitNode**. -### 2. In an Application Route File - -For host app routes, call `pageHead` inside `createFileRoute`: - -```tsx title="apps/web/src/routes/_main/about.tsx" -import { createFileRoute } from "@tanstack/react-router" -import { pageHead } from "#/lib/page-head" - -export const Route = createFileRoute("/_main/about")({ - // [!code ++:6] - head: () => - pageHead({ - title: "About Us", - description: "Learn more about our team and vision.", - robots: "index, follow", - }), - component: AboutPage, -}) -``` - -`pageHead` is created from `createRouteHead(metadata)` in `src/lib/page-head.ts`, formatting `"<page> - <site>"` consistently across all pages. - ---- - -## Open Graph & Social Sharing - -Add Open Graph card attributes for rich social previews on Twitter, Facebook, and Discord: - -```tsx title="apps/web/src/routes/_main/about.tsx" -head: () => - pageHead({ - title: "About Us", - description: "Building the modern community platform.", - openGraph: { - type: "website", - image: "https://vitnode.com/og-image.png", - }, - }), -``` - ---- +## Plugin metadata fields -## `pageHead` Options Reference +| Field | Use it for | +| ------------- | ------------------------------------------------------------------------- | +| `title` | A specific, human-readable page title. | +| `description` | A concise search snippet that explains the page’s value. | +| `robots` | `index, follow` for public pages or `noindex, nofollow` for private ones. | -<TypeTable - type={{ - title: { - description: "Page name, automatically formatted into '<title> - <siteName>'.", - required: true, - type: "string", - }, - description: { - description: "Meta description rendered in <meta name='description'>.", - type: "string", - }, - robots: { - default: "'index, follow'", - description: "Search engine crawling directive.", - type: "'index, follow' | 'noindex, nofollow'", - }, - canonical: { - description: "Canonical URL override for duplicate or paginated content.", - type: "string", - }, - openGraph: { - description: "Open Graph metadata object (image, type, title).", - type: "OpenGraphOptions", - }, - }} -/> +For canonical URLs, redirects, Open Graph fields, and XML sitemaps on Content +Engine records, configure [Content delivery and SEO](/docs/dev/content-engine/content-delivery-and-seo) +inside the plugin that owns those records. ## Learn More diff --git a/apps/web/content/docs/dev/routing/navigation.mdx b/apps/web/content/docs/dev/routing/navigation.mdx index 2a7b94ab4..7a2895518 100644 --- a/apps/web/content/docs/dev/routing/navigation.mdx +++ b/apps/web/content/docs/dev/routing/navigation.mdx @@ -62,128 +62,50 @@ So write the logical path. Two things that look reasonable and are not: silent fallback would serve the same page at infinitely many URLs. </Callout> -## Navigate from code +## Update query state from a plugin page -When the destination is decided by your code rather than by a click - after a -form submits, after a mutation resolves - use the route's own `useNavigate`: +Plugin route props expose a narrow `navigate` function for filters, sorting, and +pagination on the page already being viewed. It keeps the plugin independent of +the host router’s entire route tree. -```tsx title="apps/web/src/routes/_main/contact.tsx" -export const Route = createFileRoute('/_main/contact')({ - component: ContactPage, -}) +```tsx title="plugins/catalog/src/routes/catalog-page.tsx" +import type { PluginRoutePageProps } from '@vitnode/core/routing' -const ContactPage = () => { - const navigate = Route.useNavigate() // [!code ++] - - const onSubmit = async (values: ContactValues) => { - const message = await sendMessage(values) - - await navigate({ params: { id: message.id }, to: '/contact/$id' }) // [!code ++] - } - - return <ContactForm onSubmit={onSubmit} /> +interface CatalogSearch { + page: number } -``` - -`Route.useNavigate()` is that route's own, so `to` and `params` are typed -against the route tree - a typo in the destination is a compile error rather -than a 404. The locale rewrite applies here exactly as it does to a `Link`, -because both go through the router's `buildLocation`: a Polish visitor ends up -on `/pl/contact/42`. - -### Replace, when the page behind you is a dead end - -VitNode's own password-reset screen navigates with `replace: true` once the -password has changed, and the reason is worth copying: the URL being left behind -carries a recovery token, and a push would leave it one Back press away. -```tsx -await navigate({ replace: true, to: '/login' }) -``` - -### Redirecting before a page renders - -If the decision is "this visitor may not be here at all", make it in -`beforeLoad` rather than in a component. Core's own authenticated container does -exactly that, and a `redirect()` thrown there means an anonymous visitor never -receives a byte of the protected page - not a flash, not a hydration, not a -`useEffect` that takes it away afterwards: +const CatalogPage = ({ + navigate, + search, +}: PluginRoutePageProps<undefined, CatalogSearch>) => ( + <button + onClick={() => + void navigate({ + resetScroll: false, + search: { page: search.page + 1 }, // [!code ++] + }) + } + type="button" + > + Next page + </button> +) -```tsx -throw redirect({ - search: { returnTo: returnToFor(location) }, - to: '/login', -}) +export default CatalogPage ``` -<Callout type="warn" title="Use `to` in a redirect, never `href`"> - A redirect carrying `href` is used verbatim by the router - it short-circuits - before `buildLocation`, which is where the locale rewrite lives - so it would - drop a Polish visitor on the English page. Split the destination into `to`, - `search` and `hash` instead, and the prefix is written back for free. -</Callout> - ## Plugin route modules -A plugin page must not import a router. That is the whole of what keeps one -plugin installable into any VitNode host, and it means a plugin route module -cannot build a locale-correct href for itself. - -Here is exactly what does and does not exist today: - -| Thing | Status | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| A router-neutral `Link` exported for plugin pages | **Does not exist.** `@vitnode/core/routing` exports no link component at all. | -| `RouterLink` | Real, from `@vitnode/core/tanstack/layout` - but it imports `@tanstack/react-router`, so importing it pins your plugin to a TanStack host. | -| The `LinkComponent` prop convention | Real, and used by every shared view in core. It takes an anchor's props with `href` required, and defaults to `RouterLink`. | -| `navigate`, handed to a plugin page | Real, and narrow: it replaces **this page's query string** and nothing else. | - -So `LinkComponent` is a genuine seam - it is how core's own screens render under -two frameworks without either being imported - but it is a prop a _host_ passes -to a component it renders. The plugin route runtime renders your page itself, -with a fixed set of props: `loaderData`, `params`, `search` and `navigate`. No -link component among them. +A plugin route should not import the host router. Use its `navigate` prop for +same-page filters, sort order, and pagination. For links to another internal +screen, let the host render a link component; a plain `<a>` is for another +origin only. -What works today: - -- **Render text instead of a link** where you can. This is why a plugin - breadcrumb is a label: the example plugin's own crumb is a `<span>`, next to a - comment saying that a locale-correct href needs the host's link component and - a plugin route module is handed nothing to build one with. -- **Use `navigate` for query-string state.** A paginated list, a filter or a - sort control is not really a link - it is the same page with a different query - string, and that means the same thing under every router: - - ```tsx - <button - onClick={() => { - void navigate({ resetScroll: false, search: { page: search.page + 1 } }) - }} - type="button" - > - Next - </button> - ``` - - `resetScroll: false` is what stops a table jumping to the top when only the - page number changed. - -- **Accept a link component as a prop** in the presentational components your - page composes, and let whatever renders them decide. That is the shape core - uses, and it is what will make your components portable the day a - router-neutral link does arrive. -- **Use a plain `<a href>` only for another origin.** An internal one triggers a - full page load and loses the locale prefix. - -## When to use what - -- **`<Link to>`** - anything inside this app. Typed against the route tree, - preloaded on hover, locale handled. -- **`<a href>`** - another origin: GitHub, a status page, a provider's docs. -- **`useNavigate()`** - a destination your code decided, such as after a form - submits or a record is created. -- **`redirect()` in `beforeLoad`** - a visitor who should never see this page at - all. +<Callout type="info" title="Keep plugins portable"> + `navigate` only changes this plugin page's query string. That small boundary + is intentional—and saves future hosts from router spaghetti. +</Callout> ## Next diff --git a/apps/web/content/docs/dev/routing/not-found.mdx b/apps/web/content/docs/dev/routing/not-found.mdx index 028e1267b..89be0491c 100644 --- a/apps/web/content/docs/dev/routing/not-found.mdx +++ b/apps/web/content/docs/dev/routing/not-found.mdx @@ -13,14 +13,12 @@ In TanStack Start, 404 handling is configured as a route option via `notFoundCom Your `apps/web/src/routes/__root.tsx` defines the fallback boundary for all unmatched URLs: ```tsx title="apps/web/src/routes/__root.tsx" -import { createRootRouteWithContext } from "@tanstack/react-router" -import { ErrorActions, NotFound } from "@vitnode/core/tanstack/layout" +import { createRootRouteWithContext } from '@tanstack/react-router' +import { ErrorActions, NotFound } from '@vitnode/core/tanstack/layout' export const Route = createRootRouteWithContext<RootRouterContext>()({ // [!code ++:4] - notFoundComponent: () => ( - <NotFound actions={<ErrorActions />} /> - ), + notFoundComponent: () => <NotFound actions={<ErrorActions />} />, component: RootComponent, }) ``` @@ -33,11 +31,12 @@ export const Route = createRootRouteWithContext<RootRouterContext>()({ When a requested resource (like an article slug or user ID) is not found in the database, throw `notFound()` inside the loader: -```tsx title="apps/web/src/routes/_main/blog/$slug.tsx" -import { createFileRoute, notFound } from "@tanstack/react-router" +```tsx title="plugins/blog/src/routes/article-page.tsx" +import { notFound } from '@tanstack/react-router' +import { definePluginRoute } from '@vitnode/core/routing' -export const Route = createFileRoute("/_main/blog/$slug")({ - loader: async ({ params }) => { +export const route = definePluginRoute({ + load: async ({ params }) => { const post = await fetchPost(params.slug) // [!code ++:3] if (!post) { @@ -51,22 +50,11 @@ export const Route = createFileRoute("/_main/blog/$slug")({ --- -## Custom Route-Level 404 Components +## Keep the fallback in the host -You can assign a customized `notFoundComponent` to specific routes or layout shells: - -```tsx title="apps/web/src/routes/_main/blog/$slug.tsx" -export const Route = createFileRoute("/_main/blog/$slug")({ - loader: async ({ params }) => { /* ... */ }, - // [!code ++:3] - notFoundComponent: () => ( - <div className="p-8 text-center"> - <h2>Article Not Found</h2> - <p>The post you are looking for may have been removed.</p> - </div> - ), -}) -``` +The root 404 boundary is host infrastructure, so configure it once. Feature +routes should throw `notFound()` from their plugin loader and let that shared, +localized fallback do its work. ## Learn More diff --git a/apps/web/content/docs/dev/search-elasticsearch.mdx b/apps/web/content/docs/dev/search-elasticsearch.mdx new file mode 100644 index 000000000..b926851ef --- /dev/null +++ b/apps/web/content/docs/dev/search-elasticsearch.mdx @@ -0,0 +1,98 @@ +--- +title: Elasticsearch +description: Replace VitNode's default Postgres search provider with Elasticsearch or OpenSearch and rebuild your index safely. +icon: SearchCheck +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +Use Elasticsearch when your site-wide search needs fuzzy matching, custom +ranking, or a search cluster separate from Postgres. VitNode keeps Postgres as +the canonical index; this adapter mirrors it, so switching is pleasantly boring. + +<Steps> +<Step> + +### Install the adapter + +Run this in the app or API workspace that owns `vitnode.api.config.ts`. + +<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Install Elasticsearch support"> + +```bash tab="bun" +bun add @vitnode/elasticsearch@canary +``` + +```bash tab="pnpm" +pnpm add @vitnode/elasticsearch@canary +``` + +```bash tab="npm" +npm install @vitnode/elasticsearch@canary +``` + +</Tabs> + +</Step> +<Step> + +### Add the cluster URL and adapter + +Set `ELASTICSEARCH_NODE` to your Elasticsearch or OpenSearch endpoint, then +register the adapter in the API config. + +```bash title=".env" +ELASTICSEARCH_NODE=http://localhost:9200 +``` + +```ts title="apps/api/src/vitnode.api.config.ts" +import { ElasticsearchSearchAdapter } from '@vitnode/elasticsearch' // [!code ++] + +export const vitNodeApiConfig = buildApiConfig({ + // [!code ++:5] + search: { + adapter: ElasticsearchSearchAdapter({ + node: process.env.ELASTICSEARCH_NODE, + index: 'vitnode', + }), + }, + plugins: [blogApiPlugin()], +}) +``` + +For Elastic Cloud, use `cloudId` and `apiKey` instead of `node`. The adapter +also accepts `username` and `password` for a self-hosted secured cluster. + +</Step> +<Step> + +### Rebuild the mirror + +Restart the API, then open **Core → Advanced → Search** in the AdminCP and run +**Rebuild index**. New changes are mirrored automatically; the rebuild fills +the historical records already in Postgres. + +{/* Image prompt: VitNode AdminCP search page showing Elasticsearch as connected, one “Rebuild index” button, a progress state, and collection counts. Dark theme, 1440x900. */} + +</Step> +</Steps> + +<Callout type="warn" title="Keep Postgres"> + Do not delete `core_search_index`. It remains VitNode's source of truth and + lets you switch providers without losing your map back home. +</Callout> + +## Next + +<Cards> + <Card + title="Search & Discovery" + description="Index plugin records and register rebuild indexers." + href="/docs/dev/search" + /> + <Card + title="Content Engine" + description="Make a content type searchable without hand-writing an indexer." + href="/docs/dev/content-engine/content-delivery-and-seo" + /> +</Cards> diff --git a/apps/web/content/docs/dev/search.mdx b/apps/web/content/docs/dev/search.mdx index 5be06c82c..a0e99f532 100644 --- a/apps/web/content/docs/dev/search.mdx +++ b/apps/web/content/docs/dev/search.mdx @@ -4,24 +4,28 @@ description: Enable site-wide full-text search across plugin content with Postgr icon: Search --- -import { TypeTable } from "fumadocs-ui/components/type-table" +import { TypeTable } from 'fumadocs-ui/components/type-table' VitNode includes a unified site-wide search and discovery engine. Searchable records across all plugins are projected into the `core_search_index` table, powering `/search` and `/discover`. <Callout type="info" title="Single table filtering"> - This guide covers **site-wide search**. For search boxes on individual tables, see [Search your tables](/docs/dev/database/search). + This guide covers **site-wide search**. For search boxes on individual tables, + see [Search](/docs/dev/database/search). </Callout> ## Quick start -### 1. Indexing on Create / Update +<Steps> +<Step> + +### Index when a record changes Index or update an item from any Hono route handler via `c.get("search")`: ```ts // [!code ++:10] -await c.get("search").index({ - itemType: "article", +await c.get('search').index({ + itemType: 'article', itemId: article.id, title: article.title, content: article.content, // HTML automatically stripped to plain text @@ -31,36 +35,43 @@ await c.get("search").index({ }) ``` ---- +</Step> +<Step> -### 2. Removing from Search Index +### Delete removed records When an item is deleted, remove it from the index: ```ts -await c.get("search").delete("article", article.id) +await c.get('search').delete('article', article.id) ``` ---- +</Step> +<Step> -## Rebuild Indexers +### 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`: ```ts title="plugins/blog/src/api/indexers/post.indexer.ts" -import { buildSearchIndexer } from "@vitnode/core/api/lib/search" -import { blog_posts } from "@/database/posts" +import { buildSearchIndexer } from '@vitnode/core/api/lib/search' +import { blog_posts } from '@/database/posts' export const postSearchIndexer = buildSearchIndexer({ - itemType: "article", + itemType: 'article', totalCount: async (c) => { - return await c.get("db").$count(blog_posts) + return await c.get('db').$count(blog_posts) }, batch: async (c, { limit, offset }) => { - const posts = await c.get("db").select().from(blog_posts).limit(limit).offset(offset) + const posts = await c + .get('db') + .select() + .from(blog_posts) + .limit(limit) + .offset(offset) return posts.map((post) => ({ - itemType: "article", + itemType: 'article', itemId: post.id, title: post.title, content: post.content, @@ -77,12 +88,13 @@ Register it in `config.api.ts`: ```ts title="plugins/blog/src/config.api.ts" export const blogApiPlugin = () => buildApiPlugin({ - pluginId: "blog", + pluginId: 'blog', searchIndexers: [postSearchIndexer], // [!code ++] }) ``` ---- +</Step> +</Steps> ## Pluggable Search Engines @@ -95,6 +107,9 @@ VitNode supports two search engines: - Offloads indexing and search to an Elasticsearch or OpenSearch cluster. - Unlocks fuzzy matching, phrase boosts, and decay scoring. +For setup, credentials, configuration, and the first rebuild, follow the +[Elasticsearch tutorial](/docs/dev/search-elasticsearch). + --- ## AdminCP Search Management @@ -102,6 +117,7 @@ VitNode supports two search engines: {/* Image prompt: VitNode AdminCP Search settings screen at /admin/core/advanced/search. Dashboard displaying search index statistics, engine status (PostgreSQL / Elasticsearch), registered item types with indexed document counts, and a "Rebuild Index" button. Dark theme, 1440x900. */} Manage search status at **Core → Advanced → Search** (`/admin/core/advanced/search`): + - View indexed record counts across all collections. - Trigger background rebuilds of specific collections or the entire site. @@ -112,33 +128,34 @@ Manage search status at **Core → Advanced → Search** (`/admin/core/advanced/ <TypeTable type={{ itemType: { - description: "Unique collection identifier (e.g. 'article', 'wiki_page').", + description: + "Unique collection identifier (e.g. 'article', 'wiki_page').", required: true, - type: "string", + type: 'string', }, itemId: { - description: "Primary key of the indexed record.", + description: 'Primary key of the indexed record.', required: true, - type: "number", + type: 'number', }, title: { - description: "Document title (indexed with highest search relevance).", + description: 'Document title (indexed with highest search relevance).', required: true, - type: "string", + type: 'string', }, content: { - description: "Body text (HTML is stripped to plain text automatically).", + description: 'Body text (HTML is stripped to plain text automatically).', required: true, - type: "string", + type: 'string', }, url: { - description: "Canonical destination URL for search result clicks.", + description: 'Canonical destination URL for search result clicks.', required: true, - type: "string", + type: 'string', }, authorId: { - description: "User ID of creator (powers member profile activity).", - type: "number", + description: 'User ID of creator (powers member profile activity).', + type: 'number', }, }} /> @@ -147,10 +164,15 @@ Manage search status at **Core → Advanced → Search** (`/admin/core/advanced/ <Cards> <Card - title="Search Tables" + title="Table search" description="Add search filtering to a single database table" href="/docs/dev/database/search" /> + <Card + title="Elasticsearch" + description="Install, configure, and rebuild the external search adapter" + href="/docs/dev/search-elasticsearch" + /> <Card title="Content Engine Delivery" description="Automatic search indexing with Content Engine" diff --git a/apps/web/content/docs/dev/server-functions.mdx b/apps/web/content/docs/dev/server-functions.mdx index 27b662381..083e54786 100644 --- a/apps/web/content/docs/dev/server-functions.mdx +++ b/apps/web/content/docs/dev/server-functions.mdx @@ -8,15 +8,16 @@ VitNode separates backend business logic into Hono API routes while using `creat ## Decision Matrix -| Goal | Recommended Tool | Rationale | -| :--- | :--- | :--- | -| **Route Data Fetching** | `createIsomorphicFn` | SSR fetches directly on server; client fetches via `fetcherClient`. | -| **API Endpoints & Mutations** | Hono API routes | Enforces staff permissions, validation schemas, and database transactions. | -| **Cookie Minting on Host** | `createServerFn` (App only) | Only code inside the host request can set response headers directly. | -| **Plugin Server Code** | Hono API modules | **Plugins must never declare `createServerFn`** (uncompiled handlers resolve to `undefined`). | +| Goal | Recommended Tool | Rationale | +| :---------------------------- | :-------------------------- | :-------------------------------------------------------------------------------------------- | +| **Route Data Fetching** | `createIsomorphicFn` | SSR fetches directly on server; client fetches via `fetcherClient`. | +| **API Endpoints & Mutations** | Hono API routes | Enforces staff permissions, validation schemas, and database transactions. | +| **Cookie Minting on Host** | `createServerFn` (App only) | Only code inside the host request can set response headers directly. | +| **Plugin Server Code** | Hono API modules | **Plugins must never declare `createServerFn`** (uncompiled handlers resolve to `undefined`). | <Callout type="warn" title="No createServerFn in Plugins"> - A plugin package may declare `createIsomorphicFn`, but never `createServerFn`. Server functions belong exclusively to the host application. + A plugin package may declare `createIsomorphicFn`, but never `createServerFn`. + Server functions belong exclusively to the host application. </Callout> --- @@ -25,41 +26,42 @@ VitNode separates backend business logic into Hono API routes while using `creat TanStack Router loaders run on the server during the initial paint, and on the client for subsequent navigations. `createIsomorphicFn` bridges both environments seamlessly: -```ts title="src/features/devices/fetcher.ts" -import { createIsomorphicFn } from "@tanstack/react-start" -import { clientModule, fetcherClient } from "@vitnode/core/lib/fetcher-client" -import { fetcher } from "@vitnode/core/tanstack/fetcher/server" -import type { usersModule } from "@vitnode/core/api/modules/users/users.module" +```ts title="plugins/devices/src/lib/fetcher.ts" +import { createIsomorphicFn } from '@tanstack/react-start' +import { clientModule, fetcherClient } from '@vitnode/core/lib/fetcher-client' +import { fetcher } from '@vitnode/core/tanstack/fetcher/server' +import type { usersModule } from '@vitnode/core/api/modules/users/users.module' -const moduleRef = clientModule<typeof usersModule>("@vitnode/core") +const moduleRef = clientModule<typeof usersModule>('@vitnode/core') export const fetchDevices = createIsomorphicFn() // Server execution (SSR) .server(async () => { const res = await fetcher(usersModule, { - method: "get", - module: "users", - path: "/devices", + method: 'get', + module: 'users', + path: '/devices', }) return await res.json() }) // Client execution (SPA navigation) .client(async () => { const res = await fetcherClient(moduleRef, { - method: "get", - module: "users", - path: "/devices", + method: 'get', + module: 'users', + path: '/devices', }) return await res.json() }) ``` -Consume `fetchDevices` directly in your route loader: +Consume `fetchDevices` from the plugin route that owns the devices screen: -```tsx title="apps/web/src/routes/_main/devices.tsx" -export const Route = createFileRoute("/_main/devices")({ - loader: async () => await fetchDevices(), - component: DevicesPage, +```tsx title="plugins/devices/src/routes/devices-page.tsx" +import { definePluginRoute } from '@vitnode/core/routing' + +export const route = definePluginRoute({ + load: async () => await fetchDevices(), // [!code ++] }) ``` @@ -70,13 +72,13 @@ export const Route = createFileRoute("/_main/devices")({ Use `createServerFn` only when your host application needs to modify response cookies directly: ```ts title="apps/web/src/features/auth/server-fn.ts" -import { createServerFn } from "@tanstack/react-start" -import { setCookie } from "vinxi/http" +import { createServerFn } from '@tanstack/react-start' +import { setCookie } from 'vinxi/http' -export const setSessionTheme = createServerFn({ method: "POST" }) +export const setSessionTheme = createServerFn({ method: 'POST' }) .validator((theme: string) => theme) .handler(async ({ data }) => { - setCookie("theme", data, { path: "/", httpOnly: true }) + setCookie('theme', data, { path: '/', httpOnly: true }) return { success: true } }) ``` diff --git a/apps/web/content/docs/dev/setup.mdx b/apps/web/content/docs/dev/setup.mdx index c0f4720ad..809497e35 100644 --- a/apps/web/content/docs/dev/setup.mdx +++ b/apps/web/content/docs/dev/setup.mdx @@ -1,27 +1,20 @@ --- title: Getting Started -description: Create a VitNode app, start Postgres, run the dev server, and sign in to the AdminCP as your first administrator. +description: Create a VitNode app, prepare Postgres, start TanStack Start, and sign in to AdminCP in five small steps. icon: Rocket --- -import { Tab, Tabs } from "fumadocs-ui/components/tabs" +import { BookOpenIcon, PackagePlusIcon, ServerIcon } from 'lucide-react' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -Get up and running with a complete VitNode application in under 5 minutes. - -## Prerequisites - -- **Node.js 22+** (Node 24 recommended) -- Package manager: **pnpm**, **bun**, or **npm** -- **PostgreSQL 15+** (or Docker to run the included compose file) - ---- - -## Create and Run Your Application +You need Node.js 22+, Postgres (or Docker), and your favorite package manager. +Choose a workspace with Turborepo if you will create plugins; plugins are the +home for product work, not an optional side quest. <Steps> -<Step> + <Step> -### 1. Scaffold the Project +### Scaffold the app <Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Create VitNode app"> @@ -34,24 +27,23 @@ pnpm create vitnode-app@canary ``` ```bash tab="npm" -npx create-vitnode-app@canary +npm create vitnode-app@canary ``` </Tabs> -Select your preferred template when prompted: -- **Single App**: Unified TanStack Start application with Hono API mounted at `/api` (Recommended). -- **Monorepo App**: Turborepo setup separating `apps/web` and `apps/api`. -- **Only API**: Standalone Hono REST/RPC server. +Pick **Single App** for TanStack Start plus Hono at `/api`. Turn on +**Turborepo** if you intend to use `--plugin`; it gives the generator a +workspace root and a place for `plugins/*`. -</Step> -<Step> + </Step> + <Step> -### 2. Start PostgreSQL with Docker +### Start the database -If you chose Docker during scaffolding, start Postgres and Redis in one command: +If you selected Docker, start Postgres and Redis locally: -<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Start database"> +<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Start local services"> ```bash tab="bun" bun run docker:dev @@ -67,12 +59,12 @@ npm run docker:dev </Tabs> -</Step> -<Step> + </Step> + <Step> -### 3. Run Migrations and Seed Initial Admin +### Migrate and create your administrator -<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Migrate and seed"> +<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Prepare the database"> ```bash tab="bun" bun run db:migrate @@ -88,14 +80,14 @@ npm run db:migrate </Tabs> -This creates all core schema tables and prompts you to configure your initial administrator account. +The migration creates core tables and asks for the first admin account. -</Step> -<Step> + </Step> + <Step> -### 4. Start the Development Server +### Start VitNode -<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Start dev server"> +<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Start the development server"> ```bash tab="bun" bun dev @@ -111,34 +103,41 @@ npm run dev </Tabs> -Open `http://localhost:3000` to view your live application. +Open `http://localhost:3000`. -</Step> -<Step> + </Step> + <Step> -### 5. Sign In to the AdminCP +### Check AdminCP, then build a plugin -Visit `http://localhost:3000/admin` and sign in with the admin credentials created during migration. +Sign in at `http://localhost:3000/admin`. When the shell works, resist the +temptation to drop feature files into the host: [create a plugin](/docs/dev/plugins/create) +and let it own the feature. -</Step> +{/* Image prompt: A polished VitNode AdminCP sign-in screen and dashboard overview, dark theme, with a clearly visible sidebar and a subtle callout pointing to the plugin-oriented AdminCP area. 1440x900. */} + + </Step> </Steps> -## Next Steps +## Keep going <Cards> <Card - title="Create a Plugin" - description="Build your first custom VitNode plugin" + icon={<PackagePlusIcon />} + title="Create a plugin" + description="Scaffold, register, and visit an installable feature package." href="/docs/dev/plugins/create" /> <Card - title="Defining a Content Type" - description="Declare database schemas and AdminCP management screens" - href="/docs/dev/content-engine/defining-a-content-type" - /> - <Card + icon={<ServerIcon />} title="Deployments" - description="Deploy VitNode to production servers or containers" + description="Move your TanStack Start and Hono app to production." href="/docs/dev/deployments/self-hosted" /> + <Card + icon={<BookOpenIcon />} + title="First plugin tutorial" + description="Follow a compact example that adds a real plugin page." + href="/docs/guides/first-plugin" + /> </Cards> diff --git a/apps/web/content/docs/guides/blog.mdx b/apps/web/content/docs/guides/blog.mdx index af35646c9..a8c62c9f4 100644 --- a/apps/web/content/docs/guides/blog.mdx +++ b/apps/web/content/docs/guides/blog.mdx @@ -1,40 +1,47 @@ --- -title: Building a Blog -description: Install @vitnode/blog, manage articles in AdminCP, and render them with TanStack Start - the Content Engine reference plugin. +title: Install the Blog Plugin +description: Install @vitnode/blog, enable its API and TanStack Start integration, then publish your first article in AdminCP. icon: NotebookPen --- -import { Tab, Tabs } from "fumadocs-ui/components/tabs" +import { CompassIcon, LayoutDashboardIcon, RouteIcon } from 'lucide-react' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -`@vitnode/blog` is the official reference plugin for Content Engine. It ships article and category models, AdminCP editorial screens, revision histories, draft/publish workflows, and public API endpoints without writing manual CRUD code. +`@vitnode/blog` is the Content Engine reference plugin. It brings articles, +categories, editorial screens, revisions, public delivery data, and search +indexing—without making your host app impersonate a blog plugin. -## Quick start +<Steps> + <Step> -### 1. Install the Plugin +### Install the package <Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Install @vitnode/blog"> ```bash tab="bun" -bun i @vitnode/blog@canary +bun add @vitnode/blog@canary ``` ```bash tab="pnpm" -pnpm i @vitnode/blog@canary +pnpm add @vitnode/blog@canary ``` ```bash tab="npm" -npm i @vitnode/blog@canary +npm install @vitnode/blog@canary ``` </Tabs> ---- + </Step> + <Step> + +### Enable the API plugin -### 2. Register the Plugin +For a Single App, edit `apps/web/src/vitnode.api.config.ts`; a split deployment +uses the same change in `apps/api/src/vitnode.api.config.ts`. -#### On the API: -```ts title="apps/api/src/vitnode.api.config.ts" -import { blogApiPlugin } from "@vitnode/blog/api/config" +```ts title="apps/web/src/vitnode.api.config.ts" +import { blogApiPlugin } from '@vitnode/blog/config.api' // [!code ++] export const vitNodeApiConfig = buildApiConfig({ plugins: [ @@ -43,169 +50,94 @@ export const vitNodeApiConfig = buildApiConfig({ }) ``` -#### In the Web App: -```ts title="apps/web/src/vitnode.config.ts" -import { blogPlugin } from "@vitnode/blog/config" + </Step> + <Step> -export const vitNodeConfig = buildConfig({ - ...vitNodeShellConfig, - plugins: [ - blogPlugin(), // [!code ++] - ], -}) -``` +### Enable the web plugin and its messages + +The host registers the lightweight plugin identity and static locale loaders: -#### Localization Messages: ```ts title="apps/web/src/locales/packages.ts" +import { CONFIG_PLUGIN as BLOG } from '@vitnode/blog/const' // [!code ++] + export const packageMessages = { // [!code ++:3] - blog: { - en: async () => await import("@vitnode/blog/locales/en.json"), + [BLOG.pluginId]: { + en: async () => await import('@vitnode/blog/locales/en.json'), }, } ``` ---- +```ts title="apps/web/src/vitnode.config.ts" +import { CONFIG_PLUGIN as BLOG } from '@vitnode/blog/const' // [!code ++] +import { buildPlugin } from '@vitnode/core/lib/plugin' // [!code ++] -## Managing Articles in AdminCP +export const vitNodeConfig = buildConfig({ + plugins: [ + buildPlugin({ + messages: packageMessages[BLOG.pluginId], + pluginId: BLOG.pluginId, + }), // [!code ++] + ], +}) +``` -{/* Image prompt: VitNode AdminCP Blog article management screen at /admin/content/blog/articles. Table displaying published and draft posts with badges, authors, categories, and an "Add Article" button. Dark theme, 1440x900. */} + </Step> + <Step> -Start your dev server: +### Migrate, run, and publish -<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]}> +<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Prepare and start the blog"> ```bash tab="bun" +bun run db:migrate bun dev ``` ```bash tab="pnpm" +pnpm db:migrate pnpm dev ``` ```bash tab="npm" +npm run db:migrate npm run dev ``` </Tabs> -1. Navigate to **AdminCP → Blog → Categories** (`/admin/content/blog/categories`) and create your first category. -2. Go to **AdminCP → Blog → Articles** (`/admin/content/blog/articles`) and click **Create Article**. -3. Write your post with the rich text editor, choose a category, and click **Publish**. - ---- +In AdminCP, create a category at **Blog → Categories**, then publish an article +at **Blog → Articles**. The plugin owns the editorial work so the host can stay +boringly reliable. -## Render Articles on Your Website +{/* Image prompt: VitNode AdminCP Blog article management screen. Show categories in a side navigation, article list with draft and published badges, and a prominent “Create article” button. Dark theme, 1440x900. */} -Build public blog pages in your web application using TanStack Start routes: + </Step> +</Steps> -### 1. Blog Overview Page +## Deliver it from a plugin -```tsx title="apps/web/src/routes/_main/blog/index.tsx" -import { createFileRoute, Link } from "@tanstack/react-router" -import { useSuspenseQuery } from "@tanstack/react-query" -import { fetcherClient } from "@vitnode/core/lib/fetcher-client" - -export const Route = createFileRoute("/_main/blog/")({ - loader: async ({ context }) => - await context.queryClient.ensureQueryData({ - queryKey: ["blog_articles"], - queryFn: async () => { - const res = await fetch("/api/@vitnode/blog/content/blog") - return await res.json() - }, - }), - component: BlogIndexPage, -}) - -function BlogIndexPage() { - const { data } = useSuspenseQuery({ - queryKey: ["blog_articles"], - queryFn: async () => { - const res = await fetch("/api/@vitnode/blog/content/blog") - return await res.json() - }, - }) - - return ( - <div className="container mx-auto py-8 flex flex-col gap-6"> - <h1 className="text-4xl font-bold tracking-tight">Blog</h1> - <div className="grid md:grid-cols-2 gap-6"> - {data.edges.map((article: any) => ( - <article key={article.id} className="p-4 border rounded-lg flex flex-col gap-2"> - <h2 className="text-2xl font-semibold"> - <Link to="/blog/$slug" params={{ slug: article.slug }}> - {article.title} - </Link> - </h2> - <p className="text-muted-foreground">{article.summary}</p> - </article> - ))} - </div> - </div> - ) -} -``` - ---- - -### 2. Single Article Page - -```tsx title="apps/web/src/routes/_main/blog/$slug.tsx" -import { createFileRoute } from "@tanstack/react-router" -import { pageHead } from "#/lib/page-head" - -export const Route = createFileRoute("/_main/blog/$slug")({ - loader: async ({ params }) => { - const res = await fetch(`/api/@vitnode/blog/content/blog/${params.slug}`) - return await res.json() - }, - head: ({ loaderData }) => - pageHead({ - title: loaderData?.title, - description: loaderData?.summary, - }), - component: ArticlePage, -}) - -function ArticlePage() { - const article = Route.useLoaderData() - - return ( - <main className="container mx-auto max-w-3xl py-8 flex flex-col gap-4"> - <h1 className="text-4xl font-bold">{article.title}</h1> - <div - className="prose dark:prose-invert" - dangerouslySetInnerHTML={{ __html: article.content }} - /> - </main> - ) -} -``` - ---- - -## What the Plugin Provides Automatically - -| Feature | Details | -| :--- | :--- | -| **PostgreSQL Models** | `blog_articles` and `blog_categories` tables via Drizzle | -| **AdminCP Screens** | Full CRUD, multi-author picker, category relation, image uploads | -| **Editorial Workflow** | Draft/published states, scheduled publication dates, and 20 revision histories | -| **Public API Routes** | `GET /api/@vitnode/blog/content/blog` and `/{slug}` | -| **Global Search** | Automatic index sync to `core_search_index` for site-wide search | - -## Learn More +Build the public article page in a plugin too, then use the Content Engine +delivery guide for loaders and SEO. That keeps the data model and its public URL +together. <Cards> <Card - title="Defining a Content Type" - description="Build your own custom content types with Content Engine" - href="/docs/dev/content-engine/defining-a-content-type" + icon={<RouteIcon />} + title="Build your first plugin" + description="Create the plugin that will own your site’s public blog route." + href="/docs/guides/first-plugin" /> <Card - title="Content Delivery & SEO" - description="Query caching, slug redirects, and Open Graph previews" + icon={<CompassIcon />} + title="Content delivery and SEO" + description="Add plugin routes, redirects, canonical metadata, and sitemaps." href="/docs/dev/content-engine/content-delivery-and-seo" /> + <Card + icon={<LayoutDashboardIcon />} + title="Admin Control Panel" + description="Extend the staff experience with pages, navigation, and widgets." + href="/docs/dev/plugins/admin" + /> </Cards> diff --git a/apps/web/content/docs/guides/first-plugin.mdx b/apps/web/content/docs/guides/first-plugin.mdx new file mode 100644 index 000000000..ee61b9958 --- /dev/null +++ b/apps/web/content/docs/guides/first-plugin.mdx @@ -0,0 +1,166 @@ +--- +title: Build Your First Plugin +description: Create a VitNode plugin step by step, add a TanStack Start route, register it, and open a working page. +icon: PackagePlus +--- + +import { LayoutDashboardIcon, NetworkIcon, RouteIcon } from 'lucide-react' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +This tutorial builds `@acme/site-notes`, a tiny plugin with a page at +`/site-notes`. Start from a VitNode workspace with Turborepo enabled; it gives +the plugin generator a shared home. + +<Steps> + <Step> + +### Generate `@acme/site-notes` + +Run the generator from the workspace root and enter `@acme/site-notes` when it +asks for a name: + +<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Generate the plugin"> + +```bash tab="bun" +bun create vitnode-app@canary --plugin +``` + +```bash tab="pnpm" +pnpm create vitnode-app@canary --plugin +``` + +```bash tab="npm" +npm create vitnode-app@canary -- --plugin +``` + +</Tabs> + +The result has `routes/manifest.ts`, `routes/home-page.tsx`, `locales/en.json`, +and `config.tsx`. The CLI adds a workspace dependency but leaves activation to +you, which makes installed plugins predictable. + + </Step> + <Step> + +### Claim the page URL + +The manifest belongs to the plugin and is all the host needs to discover a +route: + +```ts title="plugins/site-notes/src/routes/manifest.ts" +import type { PluginRouteDefinition } from '@vitnode/core/routing' + +export const routes: PluginRouteDefinition[] = [ + // [!code ++:5] + { + entry: 'routes/home-page', + id: 'home', + path: '/site-notes', + }, +] +``` + + </Step> + <Step> + +### Render a translated page + +Edit the generated route module. It stays framework-neutral and is lazily +loaded by the TanStack Start host: + +```tsx title="plugins/site-notes/src/routes/home-page.tsx" +import { useTranslations } from 'use-intl' + +const HomePage = () => { + const t = useTranslations('@acme/site-notes') // [!code ++] + + return ( + <div className="container mx-auto flex max-w-2xl flex-col gap-4 p-4"> + <h2 className="text-2xl font-semibold">{t('home.title')}</h2> + <p className="text-muted-foreground">{t('home.desc')}</p> + </div> + ) +} + +export default HomePage +``` + +```json title="plugins/site-notes/src/locales/en.json" +{ + "@acme/site-notes": { + "home": { + "title": "Site notes", + "desc": "This page ships from a plugin. Neat, right?" + } + } +} +``` + + </Step> + <Step> + +### Enable it in the host + +The package must be in the host's `plugins` array. This is the only composition +step; routes and pages remain inside `plugins/site-notes`: + +```ts title="apps/web/src/vitnode.config.ts" +import { siteNotesPlugin } from '@acme/site-notes/config' // [!code ++] + +export const vitNodeConfig = buildConfig({ + plugins: [ + siteNotesPlugin(), // [!code ++] + ], +}) +``` + + </Step> + <Step> + +### Run and inspect the result + +<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Run the plugin"> + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + +</Tabs> + +Visit `http://localhost:3000/site-notes`. + +{/* Image prompt: Tutorial verification screenshot for a VitNode app at /site-notes. Show a simple “Site notes” page, browser address bar, and a compact visual hint that it is loaded from a plugin package. Dark theme, 1440x900. */} + + </Step> +</Steps> + +## Grow the same plugin + +<Cards> + <Card + icon={<RouteIcon />} + title="Add route behavior" + description="Use loaders, metadata, breadcrumbs, and parameters in the plugin route." + href="/docs/dev/routing" + /> + <Card + icon={<NetworkIcon />} + title="Add an API module" + description="Keep typed Hono routes beside the page that needs them." + href="/docs/dev/plugins/api/modules" + /> + <Card + icon={<LayoutDashboardIcon />} + title="Add AdminCP tools" + description="Give staff a plugin-owned screen or dashboard widget." + href="/docs/dev/plugins/admin" + /> +</Cards> diff --git a/apps/web/content/docs/guides/index.mdx b/apps/web/content/docs/guides/index.mdx index 6ffcb4974..87d900d1f 100644 --- a/apps/web/content/docs/guides/index.mdx +++ b/apps/web/content/docs/guides/index.mdx @@ -1,66 +1,31 @@ --- title: Guides -description: End-to-end walkthroughs that build something real with VitNode - install a plugin, publish content in the AdminCP, and serve it from your own pages. +description: Follow concise VitNode tutorials that build real plugin-owned features from the first command to a working result. icon: Compass --- -A guide takes you from nothing to a working feature in one sitting. You follow it -top to bottom, you type what it says, and at the end something works that did not -work before. +import { NotebookPenIcon, PackagePlusIcon } from 'lucide-react' -## Guides vs reference - -The two halves of these docs answer different questions, and knowing which one -you are in saves a lot of scrolling. - -| | Guides | [Development](/docs/dev) | -| ------------------ | --------------------------------- | ------------------------------------- | -| **Answers** | "How do I build X?" | "What does this option do?" | -| **Shape** | One ordered path, start to finish | Tables, type references, every branch | -| **You bring** | An empty app and half an hour | A specific question | -| **You leave with** | A running feature | The exact signature you needed | - -Both are true of the same codebase. A guide picks one honest route through it and -leaves the alternatives to the reference pages it links. - -## The guides +Guides are the shortest trustworthy path from an empty workspace to a feature +you can click. They start in a plugin on purpose: reusable code deserves a +proper home before it acquires a spare drawer in the host app. <Cards> <Card - href="/docs/guides/blog" - title="Build a blog" - description="Install @vitnode/blog, publish an article in the AdminCP, and render it on your own /blog page." - /> -</Cards> - -Every guide assumes you already have an app running - if you do not, -[Getting started](/docs/dev/setup) is seven steps from an empty folder to an -AdminCP you are signed in to. - -<Callout type="info" title="More guides are on the way"> - One guide today, and it is the one worth having first: the blog plugin is the - Content Engine's reference implementation, so building it teaches most of what - a second plugin would repeat. Guides for search, file storage and a - from-scratch plugin are queued behind it. Until then, the - [Development](/docs/dev) section covers every one of those in reference form. -</Callout> - -## Next - -<Cards> - <Card - href="/docs/dev/architecture" - title="Architecture" - description="Where a VitNode app keeps its routes, its API and its plugins - the map behind every guide." + icon={<PackagePlusIcon />} + href="/docs/guides/first-plugin" + title="Build your first plugin" + description="Generate a plugin, add a page, register it, and visit its route." /> <Card - href="/docs/dev/plugins/create" - title="Create a plugin" - description="Scaffold your own plugin, register it, and open the page it serves." - /> - <Card - href="/docs/dev/content-engine" - title="Content Engine" - description="Declare a content type once and get a table, an API, permissions and AdminCP screens." + icon={<NotebookPenIcon />} + href="/docs/guides/blog" + title="Install the Blog plugin" + description="Enable @vitnode/blog, publish an article in AdminCP, and deliver it from a plugin." /> </Cards> + +Already have an app? Great. If not, [Getting started](/docs/dev/setup) prepares +the database and your first AdminCP account. Need a precise option instead of a +walkthrough? The [development reference](/docs/dev) is close by, wearing its +tiny lab coat. diff --git a/apps/web/content/docs/guides/meta.json b/apps/web/content/docs/guides/meta.json index 5514af59a..5d02740da 100644 --- a/apps/web/content/docs/guides/meta.json +++ b/apps/web/content/docs/guides/meta.json @@ -3,5 +3,5 @@ "description": "End-to-end walkthroughs that build a real feature on VitNode, one ordered step at a time.", "icon": "BookOpenText", "root": true, - "pages": ["index", "...", "---Plugins by VitNode---", "blog"] + "pages": ["index", "first-plugin", "---Plugins by VitNode---", "blog", "..."] } diff --git a/apps/web/content/docs/ui/alert.mdx b/apps/web/content/docs/ui/alert.mdx index d891c48fd..9777d19bd 100644 --- a/apps/web/content/docs/ui/alert.mdx +++ b/apps/web/content/docs/ui/alert.mdx @@ -1,6 +1,6 @@ --- title: Alert -description: Display a short, important message to users. +description: Display an important status message with a clear action or next step. icon: AlertTriangle --- diff --git a/apps/web/content/docs/ui/checkbox.mdx b/apps/web/content/docs/ui/checkbox.mdx index da3f60f64..de4094481 100644 --- a/apps/web/content/docs/ui/checkbox.mdx +++ b/apps/web/content/docs/ui/checkbox.mdx @@ -1,6 +1,6 @@ --- title: Checkbox -description: Toggle between checked and unchecked states. +description: Let people select one or more options with accessible checked states. icon: CheckSquare --- diff --git a/apps/web/content/docs/ui/data-table.mdx b/apps/web/content/docs/ui/data-table.mdx index 1618e1ce9..62fafc759 100644 --- a/apps/web/content/docs/ui/data-table.mdx +++ b/apps/web/content/docs/ui/data-table.mdx @@ -4,7 +4,7 @@ description: A sortable, searchable, filterable data table for TanStack Router w icon: Table --- -import { TypeTable } from "fumadocs-ui/components/type-table" +import { TypeTable } from 'fumadocs-ui/components/type-table' `ContentDataTable` is the core data table component powering VitNode AdminCP lists. Every user interaction - page navigation, column sorting, search queries, and filters - writes directly to the URL query string, making table state fully bookmarkable and shareable. @@ -18,11 +18,10 @@ import { TypeTable } from "fumadocs-ui/components/type-table" Render the table inside a `DataTableNavigationProvider`: -```tsx title="src/features/members/members-table.tsx" -import type { ColumnDef } from "@vitnode/core/components/table/data-table-content" -import { ContentDataTable } from "@vitnode/core/components/table/content" -import { DataTableNavigationProvider } from "@vitnode/core/components/table/provider" -import { Route } from "#/routes/_admin/admin.members" +```tsx title="plugins/members/src/views/members-table.tsx" +import type { ColumnDef } from '@vitnode/core/components/table/data-table-content' +import { ContentDataTable } from '@vitnode/core/components/table/content' +import { DataTableNavigationProvider } from '@vitnode/core/components/table/provider' interface Member { id: number @@ -32,15 +31,22 @@ interface Member { } const columns: ColumnDef<Member>[] = [ - { accessorKey: "name", header: "Name", enableSorting: true }, - { accessorKey: "email", header: "Email" }, - { accessorKey: "role", header: "Role" }, + { accessorKey: 'name', header: 'Name', enableSorting: true }, + { accessorKey: 'email', header: 'Email' }, + { accessorKey: 'role', header: 'Role' }, ] -export const MembersTable = ({ data }: { data: any }) => { - const navigate = Route.useNavigate() - const search = Route.useSearch() +interface MembersSearch { + cursor?: string +} + +interface MembersTableProps { + data: Member[] + navigate: (options: { search: MembersSearch }) => Promise<void> + search: MembersSearch +} +export const MembersTable = ({ data, navigate, search }: MembersTableProps) => { return ( // [!code ++:13] <DataTableNavigationProvider navigate={navigate} search={search}> @@ -49,9 +55,9 @@ export const MembersTable = ({ data }: { data: any }) => { columns={columns} data={data} order={{ - defaultOrder: { column: "name", order: "asc" }, + defaultOrder: { column: 'name', order: 'asc' }, }} - search={{ placeholder: "Search members..." }} + search={{ placeholder: 'Search members...' }} /> </DataTableNavigationProvider> ) @@ -60,28 +66,40 @@ export const MembersTable = ({ data }: { data: any }) => { --- -## Route Integration +## Plugin Route Integration -Wire the table into a TanStack Router route with `zodPaginationQuery`: +Give an AdminCP route to the plugin, then pass its typed `search` and `navigate` +props into the table. The table remains reusable and the host stays out of it. -```tsx title="apps/web/src/routes/_admin/admin.members.tsx" -import { createFileRoute } from "@tanstack/react-router" -import { zodPaginationQuery } from "@vitnode/core/api/lib/with-pagination" -import { membersQuery } from "#/features/members/query" -import { MembersTable } from "#/features/members/members-table" +```tsx title="plugins/members/src/routes/admin-members-page.tsx" +import type { PluginRoutePageProps } from '@vitnode/core/routing' +import { definePluginRoute } from '@vitnode/core/routing' -export const Route = createFileRoute("/_admin/admin/members")({ - validateSearch: (search) => zodPaginationQuery.parse(search), - loaderDeps: ({ search }) => search, - loader: async ({ context, deps }) => - await context.queryClient.ensureQueryData(membersQuery(deps)), - component: MembersRoute, -}) +import { MembersTable } from '../views/members-table' -function MembersRoute() { - const { data } = useSuspenseQuery(membersQuery(Route.useSearch())) - return <MembersTable data={data} /> +interface MembersSearch { + cursor?: string } + +export const route = definePluginRoute({ + parseSearch: (input) => { + const search = input as Record<string, unknown> + return { + cursor: typeof search.cursor === 'string' ? search.cursor : undefined, + } + }, + load: async ({ search }) => await fetchMembers(search), +}) + +const AdminMembersPage = ({ + loaderData, + navigate, + search, +}: PluginRoutePageProps<Member[], MembersSearch>) => ( + <MembersTable data={loaderData} navigate={navigate} search={search} /> +) + +export default AdminMembersPage ``` --- @@ -98,11 +116,11 @@ Add static or asynchronous dropdown filters to the table toolbar: // [!code ++:12] filters={[ { - id: "role", - title: "Role", + id: 'role', + title: 'Role', options: [ - { label: "Admin", value: "admin" }, - { label: "Member", value: "member" }, + { label: 'Admin', value: 'admin' }, + { label: 'Member', value: 'member' }, ], }, ]} @@ -116,7 +134,7 @@ Add static or asynchronous dropdown filters to the table toolbar: Enable row checkboxes and execute bulk operations with `useDataTableSelection`: ```tsx -import { useDataTableSelection } from "@vitnode/core/components/table/hooks/use-data-table-selection" +import { useDataTableSelection } from '@vitnode/core/components/table/hooks/use-data-table-selection' export const BulkActionsToolbar = () => { const { selectedRows, resetSelection } = useDataTableSelection() @@ -139,36 +157,36 @@ export const BulkActionsToolbar = () => { <TypeTable type={{ id: { - description: "Unique identifier used to isolate table state.", + description: 'Unique identifier used to isolate table state.', required: true, - type: "string", + type: 'string', }, columns: { - description: "TanStack Table column definitions.", + description: 'TanStack Table column definitions.', required: true, - type: "ColumnDef<TData, TValue>[]", + type: 'ColumnDef<TData, TValue>[]', }, data: { - description: "Paginated payload containing edges and pageInfo.", + description: 'Paginated payload containing edges and pageInfo.', required: true, - type: "{ edges: TData[]; pageInfo: PageInfo }", + type: '{ edges: TData[]; pageInfo: PageInfo }', }, search: { - description: "Search input configuration ({ placeholder }).", - type: "{ placeholder?: string }", + description: 'Search input configuration ({ placeholder }).', + type: '{ placeholder?: string }', }, filters: { - description: "Array of toolbar filter descriptors.", - type: "DataTableFilter[]", + description: 'Array of toolbar filter descriptors.', + type: 'DataTableFilter[]', }, order: { - description: "Default sort column and direction.", + description: 'Default sort column and direction.', type: "{ defaultOrder?: { column: string; order: 'asc' | 'desc' } }", }, defaultPageSize: { - default: "10", - description: "Rows per page when not specified in URL.", - type: "number", + default: '10', + description: 'Rows per page when not specified in URL.', + type: 'number', }, }} /> diff --git a/apps/web/content/docs/ui/hooks/meta.json b/apps/web/content/docs/ui/hooks/meta.json index 6db9be2a0..99f3c3e09 100644 --- a/apps/web/content/docs/ui/hooks/meta.json +++ b/apps/web/content/docs/ui/hooks/meta.json @@ -1,5 +1,6 @@ { "title": "Hooks", + "description": "Small React hooks for common VitNode interface behavior", "icon": "Webhook", "pages": ["..."] } diff --git a/apps/web/content/docs/ui/input.mdx b/apps/web/content/docs/ui/input.mdx index 25951ae2c..0ae06c683 100644 --- a/apps/web/content/docs/ui/input.mdx +++ b/apps/web/content/docs/ui/input.mdx @@ -1,6 +1,6 @@ --- title: Input -description: Component used for collecting data from users +description: Collect a short text value with labels, validation and accessible feedback. icon: TextCursorInput --- diff --git a/apps/web/content/docs/ui/select.mdx b/apps/web/content/docs/ui/select.mdx index c6da328bc..f59a2ea7b 100644 --- a/apps/web/content/docs/ui/select.mdx +++ b/apps/web/content/docs/ui/select.mdx @@ -1,6 +1,6 @@ --- title: Select -description: Choose an option from a list of options. +description: Let people choose one accessible option from a defined list. icon: ListFilter --- diff --git a/apps/web/content/docs/ui/separator.mdx b/apps/web/content/docs/ui/separator.mdx index 0f040a455..6181ce68d 100644 --- a/apps/web/content/docs/ui/separator.mdx +++ b/apps/web/content/docs/ui/separator.mdx @@ -1,6 +1,6 @@ --- title: Separator -description: A simple horizontal line to separate content. +description: Separate related interface sections without adding visual noise. icon: Minus --- diff --git a/apps/web/content/docs/ui/skeleton.mdx b/apps/web/content/docs/ui/skeleton.mdx index 1266083f5..56bf70a5e 100644 --- a/apps/web/content/docs/ui/skeleton.mdx +++ b/apps/web/content/docs/ui/skeleton.mdx @@ -1,6 +1,6 @@ --- title: Skeleton -description: A placeholder component for loading states. +description: Reserve layout space while data or an interface section is loading. icon: Loader --- diff --git a/apps/web/content/docs/ui/switch.mdx b/apps/web/content/docs/ui/switch.mdx index 770d5afc6..bc6097dbe 100644 --- a/apps/web/content/docs/ui/switch.mdx +++ b/apps/web/content/docs/ui/switch.mdx @@ -1,6 +1,6 @@ --- title: Switch -description: Toggle between checked and unchecked states. +description: Toggle a single preference or setting on and off accessibly. icon: ToggleRight --- diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index d93444233..3248c6ea6 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -22,6 +22,7 @@ export default [ "src/plugin-route-manifest.gen.ts", "src/admin-nav.gen.ts", "src/content-registry.gen.ts", + "scripts/**", "prettier.config.js", ], }, diff --git a/apps/web/package.json b/apps/web/package.json index c742e70ea..29e4accdc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "start": "node .output/server/index.mjs", "typecheck": "tsc --noEmit", "lint:fix": "eslint . --fix", + "docs:check": "node scripts/check-docs.mjs", "postinstall": "fumadocs-mdx" }, "dependencies": { diff --git a/apps/web/scripts/check-docs.mjs b/apps/web/scripts/check-docs.mjs new file mode 100644 index 000000000..6be02df37 --- /dev/null +++ b/apps/web/scripts/check-docs.mjs @@ -0,0 +1,142 @@ +import { readdir, readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const docsRoot = join( + dirname(fileURLToPath(import.meta.url)), + '../content/docs', +) +const errors = [] + +const walk = async (directory) => { + const entries = await readdir(directory, { withFileTypes: true }) + const files = await Promise.all( + entries.map(async (entry) => { + const path = join(directory, entry.name) + return entry.isDirectory() ? await walk(path) : [path] + }), + ) + + return files.flat() +} + +const frontmatterValue = (frontmatter, field) => { + const match = frontmatter.match(new RegExp(`^${field}:\\s*(.+)$`, 'm')) + return match?.[1]?.trim().replace(/^['"]|['"]$/g, '') +} + +const checkFrontmatter = (file, source) => { + const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/) + + if (!match) { + errors.push(`${file}: missing frontmatter`) + return + } + + const title = frontmatterValue(match[1], 'title') + const description = frontmatterValue(match[1], 'description') + const icon = frontmatterValue(match[1], 'icon') + + for (const [field, value] of [ + ['title', title], + ['description', description], + ['icon', icon], + ]) { + if (!value) errors.push(`${file}: missing ${field}`) + } + + if (description && (description.length < 50 || description.length > 170)) { + errors.push( + `${file}: description should be 50–170 characters for search snippets`, + ) + } +} + +const checkCategory = (file, source) => { + let category + + try { + category = JSON.parse(source) + } catch { + errors.push(`${file}: invalid JSON`) + return + } + + for (const field of ['title', 'description', 'icon']) { + if (!category[field]) errors.push(`${file}: missing ${field}`) + } + + if ( + category.description && + (category.description.length < 35 || category.description.length > 170) + ) { + errors.push( + `${file}: description should be 35–170 characters for navigation and search`, + ) + } +} + +const checkPackageManagerTabs = (file, source) => { + const tabs = [...source.matchAll(/<Tabs\b[\s\S]*?<\/Tabs>/g)] + const blocks = source.matchAll( + /^```(?:bash|sh|shell|zsh)([^\n]*)\n([\s\S]*?)^```/gm, + ) + const checkedTabs = new Set() + + for (const block of blocks) { + const [, _info, body] = block + if (!/\b(?:bun|pnpm|npm)\b/.test(body)) continue + + const start = block.index ?? -1 + const container = tabs.find((tab) => { + const tabStart = tab.index ?? -1 + return start >= tabStart && start < tabStart + tab[0].length + }) + + if (!container) { + errors.push( + `${file}: package-manager command needs Bun, pnpm, and npm tabs`, + ) + continue + } + + const key = container.index ?? -1 + if (checkedTabs.has(key)) continue + checkedTabs.add(key) + + const labels = new Set( + [...container[0].matchAll(/```[^\n]*\btab=["']([^"']+)["']/g)].map( + (tab) => tab[1], + ), + ) + const missing = ['bun', 'pnpm', 'npm'].filter((label) => !labels.has(label)) + + if (missing.length > 0) { + errors.push( + `${file}: package-manager tabs are missing ${missing.join(', ')}`, + ) + } + } +} + +const files = await walk(docsRoot) + +for (const file of files) { + if (!file.endsWith('.mdx') && !file.endsWith('meta.json')) continue + + const source = await readFile(file, 'utf8') + if (file.endsWith('.mdx')) { + checkFrontmatter(file, source) + checkPackageManagerTabs(file, source) + } else { + checkCategory(file, source) + } +} + +if (errors.length > 0) { + console.error(`Docs check found ${errors.length} issue(s):`) + for (const error of errors) console.error(`- ${error}`) + process.exitCode = 1 +} else { + console.log(`Docs check passed for ${files.length} files.`) +} diff --git a/apps/web/src/docs/article.tsx b/apps/web/src/docs/article.tsx index 454558118..1624d6400 100644 --- a/apps/web/src/docs/article.tsx +++ b/apps/web/src/docs/article.tsx @@ -46,28 +46,30 @@ export const DocsArticle = ({ }: DocsArticleMeta & { children: React.ReactNode toc: TOCItemType[] -}) => ( - <DocsPage - full={full} - tableOfContent={{ single: false, style: 'clerk' }} - toc={toc} - > - <div className="flex flex-col gap-2"> - <div className="flex flex-wrap items-center justify-between gap-4"> - <h1 className="text-foreground text-3xl font-bold text-balance sm:text-4xl"> - {title} - </h1> +}) => { + return ( + <DocsPage + full={full} + tableOfContent={{ single: false, style: 'clerk' }} + toc={toc} + > + <div className="flex flex-col gap-2"> + <div className="flex flex-wrap items-center justify-between gap-4"> + <h1 className="text-foreground text-3xl font-bold text-balance sm:text-4xl"> + {title} + </h1> - <ViewOptions githubUrl={githubUrl} markdownUrl={url} /> - </div> + <ViewOptions githubUrl={githubUrl} markdownUrl={url} /> + </div> - {description ? ( - <p className="text-muted-foreground text-lg leading-relaxed text-pretty"> - {description} - </p> - ) : null} - </div> + {description ? ( + <p className="text-muted-foreground text-lg leading-relaxed text-pretty"> + {description} + </p> + ) : null} + </div> - <DocsBody>{children}</DocsBody> - </DocsPage> -) + <DocsBody>{children}</DocsBody> + </DocsPage> + ) +} diff --git a/packages/create-vitnode-app/README.md b/packages/create-vitnode-app/README.md index 1cfb09022..22ceee989 100644 --- a/packages/create-vitnode-app/README.md +++ b/packages/create-vitnode-app/README.md @@ -1,58 +1,78 @@ -# (VitNode) Create App +# Create VitNode App -This package is a CLI tool to create a new VitNode app quickly. - -Script based on [Create Next App](https://nextjs.org/). +`create-vitnode-app` scaffolds a TanStack Start and Hono VitNode application, +or an installable plugin for an existing VitNode workspace. <p align="center"> - <br> <a href="https://vitnode.com/" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/VitNode/vitnode/canary/assets/logo/vitnode_logo_dark.svg"> <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/VitNode/vitnode/canary/assets/logo/vitnode_logo_light.svg"> - <img alt="VitNode Logo" src="https://raw.githubusercontent.com/VitNode/vitnode/canary/assets/logo/vitnode_logo_light.svg" width="400"> + <img alt="VitNode" src="https://raw.githubusercontent.com/VitNode/vitnode/canary/assets/logo/vitnode_logo_light.svg" width="400"> </picture> </a> - <br> - <br> </p> -## Usage +## Create an app + +### Bun ```bash -npx create-vitnode-app@latest +bun create vitnode-app@latest ``` -or +### pnpm ```bash pnpm create vitnode-app@latest ``` -or +### npm ```bash -bun create vitnode-app@latest +npm create vitnode-app@latest ``` -## Options +Choose Turborepo during setup if you will build plugins. It gives your project a +workspace root and a `plugins/*` home. -| Option | Description | -| ------------------- | --------------------------------------------------------------------------------- | -| `--package-manager` | Specify the package manager to use. Support `npm`, `pnpm`. | -| `--eslint` | Initialize with ESLint & Prettier config. | -| `--skip-install` | Skip installing packages after initializing the project. | -| `--mode` | Specify the type of app to create. Support `singleApp`, `apiMonorepo`, `onlyApi`. | -| `--monorepo` | Create project with monorepo structure. | -| `--docker` | Initialize with Docker support. | -| `--plugin` | Create a VitNode plugin project. | +## Create a plugin + +Run this from an existing VitNode workspace, then enter the plugin package name +when prompted: + +### Bun + +```bash +bun create vitnode-app@latest --plugin +``` -## Create Plugin +### pnpm -Use the `--plugin` flag to create a VitNode plugin project. +```bash +pnpm create vitnode-app@latest --plugin +``` + +### npm + +```bash +npm create vitnode-app@latest -- --plugin +``` + +The generator creates the package and adds its workspace dependency. Register it +in the host’s `vitnode.config.ts` to enable the feature. + +## Options -### Options +| Option | Description | +| --- | --- | +| `--package-manager` | Choose `npm` or `pnpm` for the generated project. | +| `--eslint` | Include ESLint and Prettier configuration. | +| `--skip-install` | Skip dependency installation after scaffolding. | +| `--mode` | Choose `singleApp`, `apiMonorepo`, or `onlyApi`. | +| `--monorepo` | Create a workspace layout for plugins and multiple applications. | +| `--docker` | Include local Docker services. | +| `--plugin` | Create a VitNode plugin package. | -| Option | Description | -| ---------------- | -------------------------------------------------------- | -| `--skip-install` | Skip installing packages after initializing the project. | +Read the [VitNode documentation](https://vitnode.com/docs/dev) for setup, +plugins, deployment, and AdminCP guides.