From 8933ac9849800ac5ac4ad028115e177873b58655 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sun, 13 Sep 2026 21:20:34 +0200 Subject: [PATCH 01/10] feat(plugin): Add API support and enhance plugin registration process --- apps/web/content/docs/dev/fetcher.mdx | 21 +- .../content/docs/dev/plugins/api/modules.mdx | 10 + apps/web/content/docs/dev/plugins/create.mdx | 79 +++- .../src/routes/_admin/admin.core.index.tsx | 4 +- .../create/add-plugin-to-config.test.ts | 342 +++++++++++++++++ .../src/plugin/create/add-plugin-to-config.ts | 361 ++++++++++++++++++ .../plugin/create/create-plugin-vitnode.ts | 51 ++- .../src/plugin/create/route-templates.test.ts | 170 ++++++++- .../src/plugin/create/route-templates.ts | 142 +++++-- packages/vitnode/src/lib/fetcher/types.ts | 4 +- .../vitnode/src/tanstack/fetcher/index.ts | 2 +- 11 files changed, 1128 insertions(+), 58 deletions(-) create mode 100644 packages/create-vitnode-app/src/plugin/create/add-plugin-to-config.test.ts create mode 100644 packages/create-vitnode-app/src/plugin/create/add-plugin-to-config.ts diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx index c6bb2c722..ae520bf85 100644 --- a/apps/web/content/docs/dev/fetcher.mdx +++ b/apps/web/content/docs/dev/fetcher.mdx @@ -24,16 +24,31 @@ choose a transport. ### Define it in your plugin Keep this in one plugin file. Features import `notesApi`; they never set up a -module reference themselves. +module reference themselves. `create-vitnode-app --plugin` writes this file for +you—the shape below is what it generates. ```ts title="plugins/site-notes/src/api/client.ts" -import type { notesModule } from "../api/notes.module" +import type { ApiClient } from "@vitnode/core/tanstack/fetcher" import { createApiClient } from "@vitnode/core/tanstack/fetcher" -export const notesApi = createApiClient("@acme/site-notes") +import type { notesModule } from "./modules/notes/notes.module" + +export const notesApi: ApiClient = + createApiClient("@acme/site-notes") ``` + + `import type` is what keeps Hono and your handlers out of the browser bundle—a + value import would ship the whole API to every visitor, and nothing would fail + to compile. + + The `ApiClient` annotation is what keeps your plugin's `.d.ts` small. Without + it, declaration emit resolves the client's type in full and writes every route + the module serves into your build output: 200KB for a single route, and every + app that installs the plugin type-checks it. + + diff --git a/apps/web/content/docs/dev/plugins/api/modules.mdx b/apps/web/content/docs/dev/plugins/api/modules.mdx index 8563c15cb..3d3329966 100644 --- a/apps/web/content/docs/dev/plugins/api/modules.mdx +++ b/apps/web/content/docs/dev/plugins/api/modules.mdx @@ -10,6 +10,12 @@ 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. + + `create-vitnode-app --plugin` writes a `hello` module, its `config.api.ts` and + a page that calls it. Read on for what each piece does—then rename them, or add + a second module beside them. + + {/* 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. */} @@ -71,6 +77,10 @@ export const siteNotesApiPlugin = () => }) ``` +This file, and not `config.tsx`: the API config reaches your handlers, database +and secrets, while `config.tsx` is read by the browser build. A module +registered in the wrong one is shipped to every visitor. + diff --git a/apps/web/content/docs/dev/plugins/create.mdx b/apps/web/content/docs/dev/plugins/create.mdx index 61bfdff00..81e43c102 100644 --- a/apps/web/content/docs/dev/plugins/create.mdx +++ b/apps/web/content/docs/dev/plugins/create.mdx @@ -4,7 +4,7 @@ description: Scaffold a VitNode plugin, register its package in your host app, a icon: PackagePlus --- -import { DatabaseIcon, LayoutDashboardIcon, RouteIcon } from 'lucide-react' +import { BoxIcon, DatabaseIcon, LayoutDashboardIcon, RouteIcon } from 'lucide-react' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' A plugin is the starting point for a VitNode feature. It keeps routes, API @@ -41,9 +41,24 @@ npm create vitnode-app@canary -- --plugin -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. +The CLI creates `plugins/site-notes` with a working slice of a feature, end to +end: + +| File | What it is | +| ---- | ---------- | +| `src/const.ts` | The plugin's id, written once and read by everything below | +| `src/routes.ts` | The URLs the plugin owns | +| `src/pages/home-page.tsx` | The page, and the loader that calls the API | +| `src/api/modules/hello/hello.route.ts` | One `GET` endpoint, described with Zod | +| `src/api/modules/hello/hello.module.ts` | The endpoint's URL prefix | +| `src/api/client.ts` | The typed fetcher the page calls it through | +| `src/config.tsx` | What the app registers | +| `src/config.api.ts` | What the app's **API** registers | +| `src/locales/` | The strings the page renders | + +It also registers the plugin for you: as a workspace dependency of every package +that depends on `@vitnode/core`, and in each of their VitNode configs. Check what +it wrote—that is the next step. @@ -67,26 +82,49 @@ export const routes = definePluginRoutes([ -### Register the plugin with the host +### Check the two registrations -Import the plugin factory in the host config and add it to `plugins`: +A plugin has two halves, registered separately because different builds read +them: `config.tsx` goes into the browser bundle, while `config.api.ts` reaches +your handlers, database and secrets. The generator adds both, and prints each +file it touched: ```ts title="apps/web/src/vitnode.config.ts" import { siteNotesPlugin } from '@acme/site-notes/config' // [!code ++] import { buildConfig } from '@vitnode/core/vitnode.config' export const vitNodeConfig = buildConfig({ - plugins: [ - siteNotesPlugin(), // [!code ++] - ], + plugins: [siteNotesPlugin()], // [!code ++] }) ``` -That is the only composition step - the factory carries the plugin's routes, -content types and AdminCP navigation, and the feature stays in its package. Your -build reads this list and generates one literal import per plugin for each of -those, so a page or an editing screen loads with the route that needs it rather -than with the config. See [Configuration](/docs/dev/configuration). +```ts title="apps/web/src/vitnode.api.config.ts" +import { siteNotesApiPlugin } from '@acme/site-notes/config.api' // [!code ++] +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [siteNotesApiPlugin()], // [!code ++] +}) +``` + +The first line is the whole composition step for the UI: the factory carries the +plugin's routes, content types and AdminCP navigation, and the feature stays in +its package. Your build reads that list and generates one literal import per +plugin for each of those, so a page or an editing screen loads with the route +that needs it rather than with the config. See +[Configuration](/docs/dev/configuration). + +The second gives you `GET /api/@acme/site-notes/hello`, and puts it in OpenAPI +without another line. Remove it and the page still renders—its loader just gets a +404 instead of a greeting. + + + It edits the `vitnode.config.ts` and `vitnode.api.config.ts` of every package + that depends on `@vitnode/core`, and skips anything else. A config somewhere + unusual—or a `plugins` array the generator could not find—is reported rather + than guessed at: add the two lines above by hand. Running the generator again + never duplicates a registration. + Its translations need one more line, in `src/locales/packages.ts` - see [Languages & Localization](/docs/dev/i18n). @@ -113,9 +151,12 @@ npm run dev Open `http://localhost:3000/site-notes`. The page comes from the plugin, gets -its own chunk, and never moves house. Tiny victory dance optional. +its own chunk, and greets you with whatever its own endpoint answered—rendered +on the server for this first visit, fetched in the browser on the next +navigation, from the one `helloApi.fetch` call in its loader. Tiny victory dance +optional. -{/* Image prompt: Split-screen developer tutorial image. Left shows a plugin folder with routes.ts, locale, and pages files. Right shows the resulting /site-notes page in a VitNode app. Dark theme, precise code-like labels, 1600x900. */} +{/* Image prompt: Split-screen developer tutorial image. Left shows a plugin folder with routes.ts, config.api.ts, an api/modules/hello folder, locale, and pages files. Right shows the resulting /site-notes page in a VitNode app, rendering a greeting returned by the plugin's own endpoint. Dark theme, precise code-like labels, 1600x900. */} @@ -129,6 +170,12 @@ its own chunk, and never moves house. Tiny victory dance optional. description="Add nested layouts, dynamic URLs, loaders, metadata, and guards." href="/docs/dev/plugins/routes" /> + } + title="API modules" + description="Add another endpoint beside the generated one, with Zod-validated inputs." + href="/docs/dev/plugins/api/modules" + /> } title="Database models" diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx index cb2801fad..04480e1f0 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { AdminBreadcrumb } from "@vitnode/core/tanstack/admin"; +import { adminBreadcrumb } from "@vitnode/core/tanstack/admin"; import { AdminDashboardRouteContent, loadAdminDashboardRoute, @@ -11,7 +11,7 @@ export const Route = createFileRoute("/_admin/admin/core/")({ component: AdminDashboardRoute, staticData: { - breadcrumb: , + breadcrumb: adminBreadcrumb({ segments: ["core"] }), }, }); diff --git a/packages/create-vitnode-app/src/plugin/create/add-plugin-to-config.test.ts b/packages/create-vitnode-app/src/plugin/create/add-plugin-to-config.test.ts new file mode 100644 index 000000000..f020b7b42 --- /dev/null +++ b/packages/create-vitnode-app/src/plugin/create/add-plugin-to-config.test.ts @@ -0,0 +1,342 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + addPluginToConfig, + registerPluginInSource, +} from "./add-plugin-to-config.js"; + +const APP_CONFIG = `import { buildConfig } from "@vitnode/core/vitnode.config"; + +export const vitNodeConfig = buildConfig({ + debug: false, + metadata: { + shortTitle: "VitNode", + title: "VitNode", + }, + plugins: [], + theme: { + defaultTheme: "system", + }, +}); +`; + +const API_CONFIG = `import { buildApiConfig } from "@vitnode/core/vitnode.config"; +import { coreRelations } from "@vitnode/core/database/relations"; +import { drizzle } from "drizzle-orm/postgres-js"; + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [], + dbProvider: drizzle({ + connection: POSTGRES_URL, + relations: coreRelations, + }), +}); +`; + +const app = (source: string) => + registerPluginInSource(source, { + builder: "buildConfig", + factory: "siteNotesPlugin", + module: "@acme/site-notes/config", + }); + +describe("registerPluginInSource", () => { + it("imports the factory and calls it in the empty plugins array", () => { + const { source, status } = app(APP_CONFIG); + + expect(status).toBe("registered"); + expect(source).toContain( + 'import { siteNotesPlugin } from "@acme/site-notes/config";', + ); + expect(source).toContain("plugins: [siteNotesPlugin()],"); + }); + + it("leaves the rest of the config untouched", () => { + const { source } = app(APP_CONFIG); + + expect(source).toContain('defaultTheme: "system",'); + expect(source).toContain("debug: false,"); + expect(source.split("buildConfig(")).toHaveLength(2); + }); + + it("appends to a plugins array that already has entries", () => { + const { source } = app( + APP_CONFIG.replace("plugins: []", "plugins: [blogPlugin()]"), + ); + + expect(source).toContain("plugins: [blogPlugin(), siteNotesPlugin()],"); + }); + + it("keeps a multi-line plugins array multi-line, at its own indent", () => { + const { source } = app( + APP_CONFIG.replace( + "plugins: [],", + "plugins: [\n blogPlugin(),\n examplePlugin(),\n ],", + ), + ); + + expect(source).toContain( + "plugins: [\n blogPlugin(),\n examplePlugin(),\n siteNotesPlugin(),\n ],", + ); + }); + + it("adds the comma a single-entry multi-line array was missing", () => { + const { source } = app( + APP_CONFIG.replace("plugins: [],", "plugins: [\n blogPlugin()\n ],"), + ); + + expect(source).toContain( + "plugins: [\n blogPlugin(),\n siteNotesPlugin(),\n ],", + ); + }); + + it("does nothing the second time it runs", () => { + const once = app(APP_CONFIG); + const twice = app(once.source); + + expect(twice.status).toBe("already-registered"); + expect(twice.source).toBe(once.source); + }); + + it("adds only the half that is missing", () => { + const importOnly = `import { siteNotesPlugin } from "@acme/site-notes/config";\n${APP_CONFIG}`; + const { source, status } = app(importOnly); + + expect(status).toBe("registered"); + expect(source.match(/import \{ siteNotesPlugin \}/g)).toHaveLength(1); + expect(source).toContain("plugins: [siteNotesPlugin()],"); + }); + + it("sorts the import in, which is what the lint rule expects", () => { + const { source } = app(APP_CONFIG); + const specifiers = [...source.matchAll(/from "([^"]+)"/g)].map( + match => match[1], + ); + + expect(specifiers).toEqual([ + "@acme/site-notes/config", + "@vitnode/core/vitnode.config", + ]); + }); + + it("falls back to the end of the block when nothing sorts after it", () => { + const { source } = registerPluginInSource(APP_CONFIG, { + builder: "buildConfig", + factory: "zzPlugin", + module: "zz-plugin/config", + }); + const specifiers = [...source.matchAll(/from "([^"]+)"/g)].map( + match => match[1], + ); + + expect(specifiers).toEqual([ + "@vitnode/core/vitnode.config", + "zz-plugin/config", + ]); + }); + + it("never sorts a value import above a type import", () => { + const withType = `import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types";\n\n${APP_CONFIG}`; + const { source } = app(withType); + + expect(source.indexOf("import type")).toBeLessThan( + source.indexOf("import { siteNotesPlugin }"), + ); + }); + + it("matches the file's quote and semicolon style", () => { + const prettierless = APP_CONFIG.replace( + 'import { buildConfig } from "@vitnode/core/vitnode.config";', + "import { buildConfig } from '@vitnode/core/vitnode.config'", + ); + const { source } = app(prettierless); + + expect(source).toContain( + "import { siteNotesPlugin } from '@acme/site-notes/config'", + ); + expect(source).not.toContain("'@acme/site-notes/config';"); + }); + + it("registers the API factory in the API config", () => { + const { source, status } = registerPluginInSource(API_CONFIG, { + builder: "buildApiConfig", + factory: "siteNotesApiPlugin", + module: "@acme/site-notes/config.api", + }); + + expect(status).toBe("registered"); + expect(source).toContain( + 'import { siteNotesApiPlugin } from "@acme/site-notes/config.api";', + ); + expect(source).toContain("plugins: [siteNotesApiPlugin()],"); + }); + + it("ignores a plugins array that is not the config's", () => { + const vite = `import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], +}); +`; + + expect(app(vite).status).toBe("no-config-call"); + expect(app(vite).source).toBe(vite); + }); + + it("reports a config it cannot find a plugins array in", () => { + const noPlugins = APP_CONFIG.replace(" plugins: [],\n", ""); + const { source, status } = app(noPlugins); + + expect(status).toBe("no-plugins-array"); + expect(source).toBe(noPlugins); + }); + + it("skips a plugins array inside a nested call", () => { + const nested = APP_CONFIG.replace( + "plugins: [],", + 'editor: { emojis: [{ label: "]" }] },\n plugins: [],', + ); + + expect(app(nested).source).toContain("plugins: [siteNotesPlugin()],"); + }); +}); + +describe("addPluginToConfig", () => { + let root = ""; + + const write = async (file: string, contents: string) => { + await mkdir(join(root, file, ".."), { recursive: true }); + await writeFile(join(root, file), contents, "utf-8"); + }; + + const read = async (file: string) => + await readFile(join(root, file), "utf-8"); + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "create-vitnode-config-")); + await write("turbo.json", "{}\n"); + await write( + "apps/web/package.json", + JSON.stringify({ dependencies: { "@vitnode/core": "*" }, name: "web" }), + ); + await write("apps/web/src/vitnode.config.ts", APP_CONFIG); + await write("apps/web/src/vitnode.api.config.ts", API_CONFIG); + }); + + afterEach(async () => { + await rm(root, { force: true, recursive: true }); + }); + + const run = async () => + await addPluginToConfig({ + pluginName: "@acme/site-notes", + pluginPath: join(root, "plugins/site-notes"), + rootPath: root, + }); + + it("registers the plugin in both of the app's configs", async () => { + const registrations = await run(); + + expect(registrations.map(entry => entry.status)).toEqual([ + "registered", + "registered", + ]); + expect(await read("apps/web/src/vitnode.config.ts")).toContain( + "plugins: [siteNotesPlugin()],", + ); + expect(await read("apps/web/src/vitnode.api.config.ts")).toContain( + "plugins: [siteNotesApiPlugin()],", + ); + }); + + it("imports each half from the subpath that serves it", async () => { + await run(); + + expect(await read("apps/web/src/vitnode.config.ts")).toContain( + '"@acme/site-notes/config"', + ); + expect(await read("apps/web/src/vitnode.api.config.ts")).toContain( + '"@acme/site-notes/config.api"', + ); + }); + + it("leaves a package that does not depend on VitNode alone", async () => { + await write("apps/marketing/package.json", JSON.stringify({ name: "mkt" })); + await write("apps/marketing/src/vitnode.config.ts", APP_CONFIG); + + await run(); + + expect(await read("apps/marketing/src/vitnode.config.ts")).toBe(APP_CONFIG); + }); + + it("does not walk into node_modules or dist", async () => { + await write( + "node_modules/@acme/other/package.json", + JSON.stringify({ dependencies: { "@vitnode/core": "*" }, name: "other" }), + ); + await write("node_modules/@acme/other/vitnode.config.ts", APP_CONFIG); + await write("apps/web/dist/vitnode.config.ts", APP_CONFIG); + + const registrations = await run(); + + expect(registrations).toHaveLength(2); + expect(await read("node_modules/@acme/other/vitnode.config.ts")).toBe( + APP_CONFIG, + ); + expect(await read("apps/web/dist/vitnode.config.ts")).toBe(APP_CONFIG); + }); + + it("skips the plugin's own directory", async () => { + await write( + "plugins/site-notes/package.json", + JSON.stringify({ + dependencies: { "@vitnode/core": "*" }, + name: "@acme/site-notes", + }), + ); + await write("plugins/site-notes/src/vitnode.config.ts", APP_CONFIG); + + await run(); + + expect(await read("plugins/site-notes/src/vitnode.config.ts")).toBe( + APP_CONFIG, + ); + }); + + it("is safe to run twice", async () => { + await run(); + const after = await read("apps/web/src/vitnode.config.ts"); + const registrations = await run(); + + expect(registrations.map(entry => entry.status)).toEqual([ + "already-registered", + "already-registered", + ]); + expect(await read("apps/web/src/vitnode.config.ts")).toBe(after); + }); + + it("reports every config it found, so the CLI can say what it did", async () => { + await write( + "apps/api/package.json", + JSON.stringify({ dependencies: { "@vitnode/core": "*" }, name: "api" }), + ); + await write("apps/api/src/vitnode.api.config.ts", API_CONFIG); + + const registrations = await run(); + + expect(registrations.map(entry => entry.file.replace(root, ""))).toEqual([ + join("/apps/api/src/vitnode.api.config.ts"), + join("/apps/web/src/vitnode.api.config.ts"), + join("/apps/web/src/vitnode.config.ts"), + ]); + }); + + it("returns nothing when the workspace has no config to edit", async () => { + await rm(join(root, "apps"), { force: true, recursive: true }); + + await expect(run()).resolves.toEqual([]); + }); +}); diff --git a/packages/create-vitnode-app/src/plugin/create/add-plugin-to-config.ts b/packages/create-vitnode-app/src/plugin/create/add-plugin-to-config.ts new file mode 100644 index 000000000..0e9900998 --- /dev/null +++ b/packages/create-vitnode-app/src/plugin/create/add-plugin-to-config.ts @@ -0,0 +1,361 @@ +import type { Dirent } from "fs"; + +import { readdir, readFile, writeFile } from "fs/promises"; +import { basename, dirname, join } from "path"; + +import type { PackageJSON } from "../../helpers/packages-json.js"; + +import { + pluginApiVariableName, + pluginVariableName, +} from "./route-templates.js"; + +export type RegisterPluginStatus = + "already-registered" | "no-config-call" | "no-plugins-array" | "registered"; + +export interface RegisterPluginResult { + source: string; + status: RegisterPluginStatus; +} + +interface RegisterPluginArgs { + builder: string; + factory: string; + module: string; +} + +interface ImportStatement { + end: number; + isType: boolean; + specifier: string; + start: number; + text: string; +} + +const SKIPPED_DIRECTORIES = new Set([ + ".git", + ".next", + ".nitro", + ".output", + ".turbo", + ".vercel", + "build", + "coverage", + "dist", + "node_modules", +]); + +const CONFIG_BUILDERS: Record = { + "vitnode.api.config.ts": { + builder: "buildApiConfig", + subpath: "config.api", + }, + "vitnode.config.ts": { builder: "buildConfig", subpath: "config" }, +}; + +const endOfStringLiteral = (source: string, start: number): number => { + const quote = source[start]; + let index = start + 1; + + while (index < source.length) { + const char = source[index]; + + if (char === "\\") { + index += 2; + continue; + } + + if (char === quote) return index + 1; + + index += 1; + } + + return source.length; +}; + +const closingBracketOf = (source: string, open: number): number => { + let depth = 0; + let index = open; + + while (index < source.length) { + const char = source[index]; + + if (char === "/" && source[index + 1] === "/") { + const newline = source.indexOf("\n", index); + index = newline === -1 ? source.length : newline; + continue; + } + + if (char === "/" && source[index + 1] === "*") { + const end = source.indexOf("*/", index + 2); + index = end === -1 ? source.length : end + 2; + continue; + } + + if (char === '"' || char === "'" || char === "`") { + index = endOfStringLiteral(source, index); + continue; + } + + if (char === "[" || char === "(" || char === "{") { + depth += 1; + } else if (char === "]" || char === ")" || char === "}") { + depth -= 1; + + if (depth === 0) return char === "]" ? index : -1; + } + + index += 1; + } + + return -1; +}; + +const findPluginsArray = ( + source: string, + from: number, +): null | { close: number; open: number } => { + const key = /\bplugins\s*:\s*\[/g; + key.lastIndex = from; + + const match = key.exec(source); + if (!match) return null; + + const open = match.index + match[0].length - 1; + const close = closingBracketOf(source, open); + + return close === -1 ? null : { close, open }; +}; + +const readImports = (source: string): ImportStatement[] => { + const statements: ImportStatement[] = []; + let offset = 0; + let open: null | { isType: boolean; start: number } = null; + + for (const line of source.split("\n")) { + const lineStart = offset; + offset += line.length + 1; + + open ??= /^import\b/.test(line) + ? { isType: /^import\s+type\b/.test(line), start: lineStart } + : null; + + if (!open) continue; + + const specifier = + /\bfrom\s+['"]([^'"]+)['"]/.exec(line)?.[1] ?? + /^import\s+['"]([^'"]+)['"]/.exec(line)?.[1]; + + if (specifier === undefined) continue; + + const end = lineStart + line.length; + + statements.push({ + end, + isType: open.isType, + specifier, + start: open.start, + text: source.slice(open.start, end), + }); + open = null; + } + + return statements; +}; + +const importStyleOf = ( + source: string, +): { quote: string; semicolon: string } => { + const match = /\bfrom\s+(['"])[^'"\n]+\1(;?)/.exec(source); + + return { quote: match?.[1] ?? '"', semicolon: match?.[2] ?? ";" }; +}; + +const withImport = ( + source: string, + { factory, module }: { factory: string; module: string }, +): string => { + const { quote, semicolon } = importStyleOf(source); + const statement = `import { ${factory} } from ${quote}${module}${quote}${semicolon}`; + const imports = readImports(source); + + if (imports.length === 0) { + return `${statement}\n\n${source}`; + } + + const before = imports.find( + entry => !entry.isType && entry.specifier > module, + ); + + if (before) { + return `${source.slice(0, before.start)}${statement}\n${source.slice(before.start)}`; + } + + const last = imports[imports.length - 1]; + + return `${source.slice(0, last.end)}\n${statement}${source.slice(last.end)}`; +}; + +const withEntry = ( + source: string, + { close, open }: { close: number; open: number }, + call: string, +): string => { + const body = source.slice(open + 1, close); + const head = source.slice(0, open + 1); + const tail = source.slice(close); + + if (body.trim() === "") { + if (!body.includes("\n")) return `${head}${call}${tail}`; + + const indent = /\n([ \t]*)$/.exec(body)?.[1] ?? ""; + + return `${head}\n${indent} ${call},${body}${tail}`; + } + + if (!body.includes("\n")) { + const separator = body.trimEnd().endsWith(",") ? " " : ", "; + + return `${head}${body}${separator}${call}${tail}`; + } + + const entries = body.trimEnd(); + const trailing = body.slice(entries.length); + const indent = + /^[ \t]*(?=\S)/m.exec(body.slice(body.indexOf("\n") + 1))?.[0] ?? " "; + const comma = entries.endsWith(",") ? "" : ","; + + return `${head}${entries}${comma}\n${indent}${call},${trailing}${tail}`; +}; + +export const registerPluginInSource = ( + source: string, + { builder, factory, module }: RegisterPluginArgs, +): RegisterPluginResult => { + const builderAt = source.indexOf(`${builder}(`); + if (builderAt === -1) return { source, status: "no-config-call" }; + + const range = findPluginsArray(source, builderAt); + if (!range) return { source, status: "no-plugins-array" }; + + const called = new RegExp(`\\b${factory}\\s*\\(`); + const hasEntry = called.test(source.slice(range.open + 1, range.close)); + const hasImport = readImports(source).some( + entry => + entry.specifier === module && + new RegExp(`\\b${factory}\\b`).test(entry.text), + ); + + if (hasEntry && hasImport) return { source, status: "already-registered" }; + + const withCall = hasEntry ? source : withEntry(source, range, `${factory}()`); + + return { + source: hasImport ? withCall : withImport(withCall, { factory, module }), + status: "registered", + }; +}; + +export interface PluginConfigRegistration { + file: string; + status: RegisterPluginStatus; +} + +const readEntries = async (dir: string): Promise => { + try { + return await readdir(dir, { withFileTypes: true }); + } catch { + return []; + } +}; + +const findConfigFiles = async ( + dir: string, + skipDir: string, + results: string[] = [], +): Promise => { + for (const entry of await readEntries(dir)) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + if (SKIPPED_DIRECTORIES.has(entry.name) || fullPath === skipDir) continue; + + await findConfigFiles(fullPath, skipDir, results); + continue; + } + + if (entry.isFile() && CONFIG_BUILDERS[entry.name]) { + results.push(fullPath); + } + } + + return results; +}; + +const ownedByVitNodePackage = async ( + file: string, + rootPath: string, +): Promise => { + let current = dirname(file); + + while (current.startsWith(rootPath)) { + try { + const pkg: PackageJSON = JSON.parse( + await readFile(join(current, "package.json"), "utf-8"), + ); + + return Boolean( + pkg.dependencies?.["@vitnode/core"] ?? + pkg.devDependencies?.["@vitnode/core"], + ); + } catch { + const parent = dirname(current); + + if (parent === current) return false; + current = parent; + } + } + + return false; +}; + +export const addPluginToConfig = async ({ + pluginName, + pluginPath, + rootPath, +}: { + pluginName: string; + pluginPath: string; + rootPath: string; +}): Promise => { + const files = await findConfigFiles(rootPath, pluginPath); + const registrations: PluginConfigRegistration[] = []; + + for (const file of files.sort()) { + if (!(await ownedByVitNodePackage(file, rootPath))) continue; + + const target = CONFIG_BUILDERS[basename(file)]; + const isApi = target.subpath === "config.api"; + + try { + const source = await readFile(file, "utf-8"); + const { source: next, status } = registerPluginInSource(source, { + builder: target.builder, + factory: isApi + ? pluginApiVariableName(pluginName) + : pluginVariableName(pluginName), + module: `${pluginName}/${target.subpath}`, + }); + + if (status === "registered") { + await writeFile(file, next, "utf-8"); + } + + registrations.push({ file, status }); + } catch { + continue; + } + } + + return registrations; +}; diff --git a/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts b/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts index 2b77fa53b..a7de3bd7a 100644 --- a/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts +++ b/packages/create-vitnode-app/src/plugin/create/create-plugin-vitnode.ts @@ -1,15 +1,17 @@ import { existsSync } from "fs"; import { cp, mkdir, rename, writeFile } from "fs/promises"; import ora from "ora"; -import { dirname, join } from "path"; +import { dirname, join, relative } from "path"; import color from "picocolors"; import { fileURLToPath } from "url"; import type { CreatePluginCliReturn } from "../questions.js"; +import type { PluginConfigRegistration } from "./add-plugin-to-config.js"; import { getPackageManagerFromRoot } from "../../helpers/get-package-manager-from-root.js"; import { installDependencies } from "../../helpers/install-dependencies.js"; import { isFolderEmpty } from "../../helpers/is-folder-empty.js"; +import { addPluginToConfig } from "./add-plugin-to-config.js"; import { addPluginToWorkspace } from "./add-plugin-to-workspace.js"; import { createPluginPackageJSON } from "./create-package-json.js"; import { pluginRouteScaffold } from "./route-templates.js"; @@ -36,6 +38,42 @@ const writePluginRouteScaffold = async ({ ); }; +const reportConfigRegistrations = ({ + pluginName, + registrations, + rootPath, +}: { + pluginName: string; + registrations: PluginConfigRegistration[]; + rootPath: string; +}) => { + const registered = registrations.filter( + ({ status }) => status === "registered", + ); + + registered.forEach(({ file }) => { + console.log( + ` ${color.green("+")} Registered in ${color.cyan(relative(rootPath, file))}`, + ); + }); + + const unusable = registrations.filter( + ({ status }) => status === "no-plugins-array", + ); + + unusable.forEach(({ file }) => { + console.log( + ` ${color.yellow("!")} ${color.cyan(relative(rootPath, file))} has no \`plugins\` array - add ${color.cyan(pluginName)} to it by hand.`, + ); + }); + + if (registered.length === 0 && unusable.length === 0) { + console.log( + ` ${color.yellow("!")} No VitNode config found. Add ${color.cyan(`${pluginName}/config`)} to your app's \`vitnode.config.ts\` and ${color.cyan(`${pluginName}/config.api`)} to its \`vitnode.api.config.ts\`.`, + ); + } +}; + export const createPluginVitNode = async ({ pluginPath, pluginName, @@ -84,7 +122,7 @@ export const createPluginVitNode = async ({ await rename(npmIgnoreTemplatePath, dotNpmIgnorePath); } - spinner.text = "Writing the plugin's first route..."; + spinner.text = "Writing the plugin's first route and API..."; await writePluginRouteScaffold({ pluginName, pluginPath }); spinner.text = "Creating package.json..."; @@ -137,6 +175,13 @@ export const createPluginVitNode = async ({ rootPath, }); + spinner.text = "Registering the plugin with the apps that can serve it..."; + const registrations = await addPluginToConfig({ + pluginName, + pluginPath, + rootPath, + }); + if (install) { spinner.text = "Installing dependencies..."; await installDependencies({ @@ -148,4 +193,6 @@ export const createPluginVitNode = async ({ spinner.succeed( `${color.green("Success!")} Created ${color.cyan(pluginName)} at ${color.cyan(pluginPath)}`, ); + + reportConfigRegistrations({ pluginName, registrations, rootPath }); }; diff --git a/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts b/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts index 25531e470..c5f6440e3 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vitest"; import { + pluginApiClientTemplate, + pluginApiConfigTemplate, + pluginApiModuleTemplate, + pluginApiRouteTemplate, + pluginApiVariableName, pluginConfigTemplate, + pluginConstTemplate, pluginMessagesTemplate, pluginPackageExports, pluginRouteModuleTemplate, @@ -46,6 +52,24 @@ describe("pluginVariableName", () => { }); }); +describe("pluginApiVariableName", () => { + it("names the API factory after the UI one", () => { + expect(pluginApiVariableName("@acme/my-blog")).toBe("myBlogApiPlugin"); + }); + + it("does not say Plugin twice either", () => { + expect(pluginApiVariableName("my-vitnode-plugin")).toBe( + "myVitnodeApiPlugin", + ); + }); + + it("stays distinct from the UI factory, which an app imports beside it", () => { + expect(pluginApiVariableName("@acme/blog")).not.toBe( + pluginVariableName("@acme/blog"), + ); + }); +}); + describe("the generated route tree", () => { it("declares one page, in the canonical shape", () => { const routes = pluginRoutesTemplate("@acme/blog"); @@ -105,7 +129,27 @@ describe("the generated route module", () => { // A route module is compiled into the plugin's `dist` and imported by // whichever app installed it, so a router import is a way of making the // plugin installable into exactly one kind of app. - expect(imports).toEqual(["use-intl"]); + expect(imports).toEqual([ + "@vitnode/core/routing", + "@vitnode/core/routing", + "use-intl", + "@/api/client", + ]); + expect(imports).not.toContain("@tanstack/react-router"); + }); + + it("renders what the plugin's own endpoint answered", () => { + const module = pluginRouteModuleTemplate("blog"); + + expect(module).toContain("export const route = definePluginRoute"); + expect(module).toContain('module: "hello",'); + expect(module).toContain("{loaderData.message}"); + }); + + it("declares the loader's shape, so the schema and the page check each other", () => { + expect(pluginRouteModuleTemplate("blog")).toContain( + "definePluginRoute", + ); }); it("renders no
, which the application shell owns", () => { @@ -133,7 +177,11 @@ describe("the generated messages", () => { { home: Record } >; - expect(Object.keys(messages.blog.home).sort()).toEqual(["desc", "title"]); + expect(Object.keys(messages.blog.home).sort()).toEqual([ + "api", + "desc", + "title", + ]); }); }); @@ -145,10 +193,12 @@ describe("the generated config", () => { expect(config).toContain("routes,"); }); - it("names the plugin by its package name", () => { - expect(pluginConfigTemplate("@acme/blog")).toContain( - 'pluginId: "@acme/blog",', - ); + it("names the plugin through the one constant that holds its id", () => { + const config = pluginConfigTemplate("@acme/blog"); + + expect(config).toContain('import { CONFIG_PLUGIN } from "@/const";'); + expect(config).toContain("pluginId: CONFIG_PLUGIN.pluginId,"); + expect(config).not.toContain('"@acme/blog"'); }); it("exports a factory whose name is a legal identifier", () => { @@ -158,6 +208,100 @@ describe("the generated config", () => { }); }); +describe("the generated constant", () => { + it("holds the plugin's id as a literal, which the fetcher infers from", () => { + const constants = pluginConstTemplate("@acme/blog"); + + expect(constants).toContain('pluginId: "@acme/blog" as const,'); + }); + + it("is the only generated file that spells the plugin's id out", () => { + const files = pluginRouteScaffold("@acme/blog"); + + Object.entries(files) + .filter( + ([file]) => + file !== "src/const.ts" && + file !== "src/locales/en.json" && + file !== "src/pages/home-page.tsx" && + file !== "src/routes.ts", + ) + .forEach(([, contents]) => { + expect(contents).not.toContain('"@acme/blog"'); + }); + }); +}); + +describe("the generated API route", () => { + it("describes its response, which is what the page's types come from", () => { + const route = pluginApiRouteTemplate(); + + expect(route).toContain('method: "get",'); + expect(route).toContain('path: "/",'); + expect(route).toContain("schema: z.object({ message: z.string() }),"); + }); + + it("declares only a status that carries content", () => { + const declared = [ + ...pluginApiRouteTemplate().matchAll(/^\s{6}(\d{3}): \{$/gm), + ].map(match => match[1]); + + expect(declared).toEqual(["200"]); + }); +}); + +describe("the generated API module", () => { + it("mounts the route under the name the page asks the fetcher for", () => { + expect(pluginApiModuleTemplate()).toContain('name: "hello",'); + expect(pluginRouteModuleTemplate("blog")).toContain('module: "hello",'); + }); + + it("registers the route's own export, not a second copy", () => { + const module = pluginApiModuleTemplate(); + + expect(module).toContain('import { helloRoute } from "./hello.route";'); + expect(module).toContain("routes: [helloRoute],"); + }); +}); + +describe("the generated API client", () => { + it("names the module as a type, which is what keeps Hono out of the browser", () => { + const client = pluginApiClientTemplate(); + + expect(client).toContain( + 'import type { helloModule } from "@/api/modules/hello/hello.module";', + ); + expect(client).not.toMatch(/^import \{[^}]*helloModule/m); + }); + + it("annotates the client, which keeps the plugin's declarations small", () => { + expect(pluginApiClientTemplate()).toContain( + "export const helloApi: ApiClient =", + ); + }); +}); + +describe("the generated API config", () => { + it("registers the module", () => { + const config = pluginApiConfigTemplate("@acme/blog"); + + expect(config).toContain( + 'import { helloModule } from "./api/modules/hello/hello.module";', + ); + expect(config).toContain("modules: [helloModule],"); + }); + + it("exports a factory an app can import beside the UI one", () => { + expect(pluginApiConfigTemplate("@acme/my-blog")).toContain( + "export const myBlogApiPlugin = () =>", + ); + }); + + it("stays out of the config the browser build reads", () => { + expect(pluginConfigTemplate("@acme/blog")).not.toContain("helloModule"); + }); +}); + describe("the generated package exports", () => { /** What Node would resolve `/` to, given this export map. */ const resolve = (subpath: string): string => { @@ -221,6 +365,20 @@ describe("the scaffold as a whole", () => { }); }); + it("writes a file for every module the API config and client name", () => { + const files = pluginRouteScaffold("@acme/blog"); + + expect(Object.keys(files)).toContain( + "src/api/modules/hello/hello.module.ts", + ); + expect(Object.keys(files)).toContain( + "src/api/modules/hello/hello.route.ts", + ); + expect(Object.keys(files)).toContain("src/api/client.ts"); + expect(Object.keys(files)).toContain("src/config.api.ts"); + expect(Object.keys(files)).toContain("src/const.ts"); + }); + it("writes the messages barrel the config registers", () => { const files = pluginRouteScaffold("blog"); diff --git a/packages/create-vitnode-app/src/plugin/create/route-templates.ts b/packages/create-vitnode-app/src/plugin/create/route-templates.ts index ca04ff779..792c0b6e4 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.ts @@ -3,28 +3,17 @@ export const routeSlugFor = (pluginName: string): string => ? pluginName.slice(pluginName.indexOf("/") + 1) : pluginName; +export const pluginConstTemplate = (pluginName: string): string => + `export const CONFIG_PLUGIN = { + pluginId: "${pluginName}" as const, +}; +`; + export const pluginRoutesTemplate = (pluginName: string): string => { const slug = routeSlugFor(pluginName); return `import { definePluginRoutes, lazy, page } from "@vitnode/core/routing"; -/** - * The routes this plugin contributes to whatever app installs it. - * - * Browser-safe data: a path, and the module that renders it. \`lazy\` keeps that - * \`import()\` a literal the bundler can follow *without running it*, so your page - * gets a chunk of its own and is fetched when somebody navigates to it - not - * before. Never import a page into this file: a component named here is in the - * initial bundle of every page on the site, which is why VitNode refuses one. - * - * Your page is never copied into the application either. The app holds one static - * import of this tree, and nothing else. - * - * Add a route by adding a \`page()\`. \`path\` is the public URL, written in - * VitNode's own spelling: a dynamic segment is \`:id\`, never Next's \`[id]\` and - * never TanStack's \`$id\`. To nest pages inside a shared frame, wrap them in a - * \`layout()\` and give each child a path relative to it. - */ export const routes = definePluginRoutes([ page("/${slug}", { component: lazy(() => import("./pages/home-page")), @@ -36,16 +25,35 @@ export const routes = definePluginRoutes([ /** * `src/pages/home-page.tsx` - the page itself. * - * Deliberately the *minimum* module: a default export and nothing else. A route - * module may also export a `route` for its loader, metadata and breadcrumb, and - * the comment says where to read about that rather than scaffolding an empty one - * - a generated `route = definePluginRoute({})` would be a thing to delete. + * A default export and a `route` whose loader calls the plugin's own endpoint + * through the universal fetcher: rendered on the server for the first visit, + * fetched in the browser on a navigation, from one call. */ export const pluginRouteModuleTemplate = (pluginName: string): string => - `import { useTranslations } from "use-intl"; + `import type { PluginRoutePageProps } from "@vitnode/core/routing"; + +import { definePluginRoute } from "@vitnode/core/routing"; +import { useTranslations } from "use-intl"; +import { helloApi } from "@/api/client"; -const HomePage = () => { +interface HelloMessage { + message: string; +} + +export const route = definePluginRoute({ + load: async () => { + const response = await helloApi.fetch({ + method: "get", + module: "hello", + path: "/", + }); + + return await response.json(); + }, +}); + +const HomePage = ({ loaderData }: PluginRoutePageProps) => { const t = useTranslations("${pluginName}"); return ( @@ -57,6 +65,11 @@ const HomePage = () => {

{t("home.desc")}

+ +
+ {t("home.api")} + {loaderData.message} +
); }; @@ -76,6 +89,7 @@ export const pluginMessagesTemplate = (pluginName: string): string => { [pluginName]: { home: { + api: "Your plugin's API answered:", desc: "This page ships inside the plugin and is served by the app that installed it.", title: "Hello from your plugin", }, @@ -94,7 +108,6 @@ export const pluginMessagesTemplate = (pluginName: string): string => export const pluginMessagesBarrelTemplate = (): string => `import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types"; - const messages: LocaleMessagesMap = { en: async () => await import("./en.json", { with: { type: "json" } }), }; @@ -141,18 +154,90 @@ export const pluginVariableName = (pluginName: string): string => { export const pluginConfigTemplate = (pluginName: string): string => `import { buildPlugin } from "@vitnode/core/lib/plugin"; +import { CONFIG_PLUGIN } from "@/const"; + import messages from "./locales"; import { routes } from "./routes"; - export const ${pluginVariableName(pluginName)} = () => buildPlugin({ - pluginId: "${pluginName}", + pluginId: CONFIG_PLUGIN.pluginId, messages, routes, }); `; +export const pluginApiVariableName = (pluginName: string): string => + pluginVariableName(pluginName).replace(/Plugin$/, "ApiPlugin"); + +export const pluginApiRouteTemplate = (): string => + `import { z } from "@hono/zod-openapi"; +import { buildRoute } from "@vitnode/core/api/lib/route"; + +import { CONFIG_PLUGIN } from "@/const"; + +export const helloRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + route: { + method: "get", + path: "/", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ message: z.string() }), + }, + }, + description: "A greeting from the plugin.", + }, + }, + }, + handler: c => c.json({ message: \`Hello from \${CONFIG_PLUGIN.pluginId}!\` }), +}); +`; + +export const pluginApiModuleTemplate = (): string => + `import { buildModule } from "@vitnode/core/api/lib/module"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { helloRoute } from "./hello.route"; + +export const helloModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "hello", + routes: [helloRoute], +}); +`; + +export const pluginApiClientTemplate = (): string => + `import type { ApiClient } from "@vitnode/core/tanstack/fetcher"; + +import { createApiClient } from "@vitnode/core/tanstack/fetcher"; + +import type { helloModule } from "@/api/modules/hello/hello.module"; + +import { CONFIG_PLUGIN } from "@/const"; + +export const helloApi: ApiClient = createApiClient< + typeof helloModule +>(CONFIG_PLUGIN.pluginId); +`; + +export const pluginApiConfigTemplate = (pluginName: string): string => + `import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; + +import { CONFIG_PLUGIN } from "@/const"; + +import { helloModule } from "./api/modules/hello/hello.module"; + +export const ${pluginApiVariableName(pluginName)} = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + modules: [helloModule], + }); +`; + /** * What an app may import from this plugin. * @@ -184,7 +269,12 @@ export const pluginPackageExports = (): Record< export const pluginRouteScaffold = ( pluginName: string, ): Record => ({ + "src/api/client.ts": pluginApiClientTemplate(), + "src/api/modules/hello/hello.module.ts": pluginApiModuleTemplate(), + "src/api/modules/hello/hello.route.ts": pluginApiRouteTemplate(), + "src/config.api.ts": pluginApiConfigTemplate(pluginName), "src/config.tsx": pluginConfigTemplate(pluginName), + "src/const.ts": pluginConstTemplate(pluginName), "src/locales/en.json": pluginMessagesTemplate(pluginName), "src/locales/index.ts": pluginMessagesBarrelTemplate(), "src/pages/home-page.tsx": pluginRouteModuleTemplate(pluginName), diff --git a/packages/vitnode/src/lib/fetcher/types.ts b/packages/vitnode/src/lib/fetcher/types.ts index c0b68d25c..a543fdaf3 100644 --- a/packages/vitnode/src/lib/fetcher/types.ts +++ b/packages/vitnode/src/lib/fetcher/types.ts @@ -47,7 +47,7 @@ interface RouteShape { }; } -interface ModuleSpec { +export interface ModuleSpec { readonly modules?: readonly ModuleSpec[]; readonly name: string; readonly routes: readonly RouteShape[]; @@ -146,7 +146,7 @@ type InferStatusCode = K extends `${infer N extends number}` ? K : never; -interface BaseFetcherParams< +export interface BaseFetcherParams< M extends string, Routes extends Route[], Modules extends BaseBuildModuleReturn[], diff --git a/packages/vitnode/src/tanstack/fetcher/index.ts b/packages/vitnode/src/tanstack/fetcher/index.ts index d15129b66..ef293a2bb 100644 --- a/packages/vitnode/src/tanstack/fetcher/index.ts +++ b/packages/vitnode/src/tanstack/fetcher/index.ts @@ -40,7 +40,7 @@ export const rawFetcher = createIsomorphicFn() .server(serverRawFetcher) .client(rawFetcherClient) as UniversalRawFetcher; -type ApiClient = +export type ApiClient = T extends BuildModuleReturn< string, infer MainModule extends string, From 1b7020088bfc32d816f11320ff2dc656e2f781e5 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sun, 13 Sep 2026 19:23:22 +0000 Subject: [PATCH 02/10] ci: version bump to v2.0.0-canary.10 --- apps/api/package.json | 2 +- apps/web/package.json | 2 +- packages/config/package.json | 2 +- packages/create-vitnode-app/package.json | 2 +- packages/elasticsearch/package.json | 2 +- packages/node-cron/package.json | 2 +- packages/nodemailer/package.json | 2 +- packages/resend/package.json | 2 +- packages/s3/package.json | 2 +- packages/supabase-storage/package.json | 2 +- packages/vitnode/package.json | 2 +- packages/vitnode/src/config.ts | 2 +- plugins/blog/package.json | 2 +- plugins/example/package.json | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index d3f92660e..33b6e0637 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "api", - "version": "2.0.0-canary.9", + "version": "2.0.0-canary.10", "private": true, "type": "module", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index 4044491ca..59d089b3a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "2.0.0-canary.9", + "version": "2.0.0-canary.10", "private": true, "type": "module", "scripts": { diff --git a/packages/config/package.json b/packages/config/package.json index 656a6c66e..61c31fbc1 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/config", - "version": "2.0.0-canary.9", + "version": "2.0.0-canary.10", "description": "ESLint, Prettier, TypeScript (TSConfig) config for VitNode", "author": "VitNode Team", "license": "MIT", diff --git a/packages/create-vitnode-app/package.json b/packages/create-vitnode-app/package.json index da345fd82..7402488ac 100644 --- a/packages/create-vitnode-app/package.json +++ b/packages/create-vitnode-app/package.json @@ -1,6 +1,6 @@ { "name": "create-vitnode-app", - "version": "2.0.0-canary.9", + "version": "2.0.0-canary.10", "description": "Create a new VitNode app in seconds.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/elasticsearch/package.json b/packages/elasticsearch/package.json index 71831b619..0b290051f 100644 --- a/packages/elasticsearch/package.json +++ b/packages/elasticsearch/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/elasticsearch", - "version": "2.0.0-canary.9", + "version": "2.0.0-canary.10", "description": "Elasticsearch search engine adapter for VitNode content discovery.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/node-cron/package.json b/packages/node-cron/package.json index d22bf0413..d0c0f7c65 100644 --- a/packages/node-cron/package.json +++ b/packages/node-cron/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/node-cron", - "version": "2.0.0-canary.9", + "version": "2.0.0-canary.10", "description": "Node-cron adapter for VitNode, enabling cron job scheduling and management.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/nodemailer/package.json b/packages/nodemailer/package.json index 9a4ab3a08..c70c40e44 100644 --- a/packages/nodemailer/package.json +++ b/packages/nodemailer/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/nodemailer", - "version": "2.0.0-canary.9", + "version": "2.0.0-canary.10", "description": "Nodemailer integration package for VitNode, enabling email functionalities.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/resend/package.json b/packages/resend/package.json index f70691320..b318bf6a8 100644 --- a/packages/resend/package.json +++ b/packages/resend/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/resend", - "version": "2.0.0-canary.9", + "version": "2.0.0-canary.10", "description": "Resend adapter for VitNode, enabling email sending capabilities through the Resend service.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/s3/package.json b/packages/s3/package.json index 2151256cd..e9ccd5e99 100644 --- a/packages/s3/package.json +++ b/packages/s3/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/s3", - "version": "2.0.0-canary.9", + "version": "2.0.0-canary.10", "description": "AWS S3 and Cloudflare R2 storage adapter for VitNode file uploads.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/supabase-storage/package.json b/packages/supabase-storage/package.json index ae56c5fd4..d62208ba4 100644 --- a/packages/supabase-storage/package.json +++ b/packages/supabase-storage/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/supabase-storage", - "version": "2.0.0-canary.9", + "version": "2.0.0-canary.10", "description": "Supabase Storage adapter for VitNode file uploads.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index bc954166e..6668a9152 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/core", - "version": "2.0.0-canary.9", + "version": "2.0.0-canary.10", "description": "Core package for VitNode, providing essential functionalities and configurations.", "author": "VitNode Team", "license": "MIT", diff --git a/packages/vitnode/src/config.ts b/packages/vitnode/src/config.ts index e95093966..ae39f0f47 100644 --- a/packages/vitnode/src/config.ts +++ b/packages/vitnode/src/config.ts @@ -1,4 +1,4 @@ export const CONFIG_PLUGIN = { pluginId: "@vitnode/core" as const, - version: "2.0.0-canary.9", + version: "2.0.0-canary.10", }; diff --git a/plugins/blog/package.json b/plugins/blog/package.json index 0787dc59e..a88968d31 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/blog", - "version": "2.0.0-canary.9", + "version": "2.0.0-canary.10", "description": "Blog plugin for VitNode, providing a blogging platform on Hono.js.", "author": "VitNode Team", "license": "MIT", diff --git a/plugins/example/package.json b/plugins/example/package.json index 14cb2ab7c..0c72b3cde 100644 --- a/plugins/example/package.json +++ b/plugins/example/package.json @@ -1,6 +1,6 @@ { "name": "@vitnode/example", - "version": "2.0.0-canary.9", + "version": "2.0.0-canary.10", "description": "Reference plugin exercising the VitNode Content Engine end to end.", "license": "MIT", "private": true, From 8890e8dc51c9d1c39626dde2d9751dfff80f7a28 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Sun, 13 Sep 2026 21:39:00 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(auto-form):=20=E2=9C=A8=20enhance=20?= =?UTF-8?q?form=20handling=20with=20typed=20controls=20and=20new=20compone?= =?UTF-8?q?nts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/content/docs/ui/auto-form.mdx | 36 +++++++++++ .../src/components/form/auto-form.test.tsx | 64 +++++++++++++++++++ .../vitnode/src/components/form/auto-form.tsx | 6 +- .../src/components/form/fields/array.tsx | 35 +++++++++- .../src/components/form/fields/multi-lang.tsx | 6 +- .../vitnode/src/components/ui/form.test-d.ts | 34 ++++++++++ packages/vitnode/src/components/ui/form.tsx | 20 +++--- 7 files changed, 186 insertions(+), 15 deletions(-) create mode 100644 packages/vitnode/src/components/ui/form.test-d.ts diff --git a/apps/web/content/docs/ui/auto-form.mdx b/apps/web/content/docs/ui/auto-form.mdx index af45d86fa..2e5d7a3d4 100644 --- a/apps/web/content/docs/ui/auto-form.mdx +++ b/apps/web/content/docs/ui/auto-form.mdx @@ -421,6 +421,42 @@ const formSchema = z.object({ /> ``` +### Typing the value + +`AutoForm` picks the control from your Zod schema while the page runs, so the props it hands your `component` function cannot know what the value holds - that is why the example above casts `props.field.value`. + +Move the control into its own component and it can say what it expects. `FormFieldApi` types the value you read **and** the change you send back, so a wrong shape is a build error instead of a validation message: + +```tsx +import type { FormFieldApi } from "@vitnode/core/components/form/auto-form"; + +const ColorPicker = ({ + field, +}: { + field: FormFieldApi; +}) => ( + +); + + , + }, + ]} + onSubmit={values => console.log(values)} +/>; +``` + +`field.onChange` still takes either the value itself or the DOM change event - it unwraps the event for you, so `onChange={field.onChange}` keeps working. + ## Tabs Group fields into tabs by passing the `tabs` prop and tagging each field with a diff --git a/packages/vitnode/src/components/form/auto-form.test.tsx b/packages/vitnode/src/components/form/auto-form.test.tsx index 37eb415df..3b899cf4a 100644 --- a/packages/vitnode/src/components/form/auto-form.test.tsx +++ b/packages/vitnode/src/components/form/auto-form.test.tsx @@ -13,6 +13,7 @@ import { setFormFieldError } from "../ui/form"; import { AutoForm } from "./auto-form"; import { AutoFormArray } from "./fields/array"; import { AutoFormInput } from "./fields/input"; +import { AutoFormNumber } from "./fields/number"; const settled = async (interaction: () => void) => { await act(async () => { @@ -240,4 +241,67 @@ describe("AutoFormArray", () => { expect(rows()).toHaveLength(1); expect(rows()[0].value).toBe("second"); }); + + const amountsSchema = z.object({ + amounts: z + .array(z.object({ value: z.number().nullable().default(null) })) + .default([]), + }); + + const renderAmounts = () => { + render( + + ( + ( + + ), + }, + ]} + label="Amounts" + /> + ), + }, + ]} + formSchema={amountsSchema} + onSubmit={vi.fn()} + /> + , + ); + }; + + const amounts = () => + Array.from( + document.querySelectorAll('input[name^="amounts["]'), + ); + + it("keeps the state a row's control owns when an earlier row is removed", async () => { + renderAmounts(); + + const add = screen.getByRole("button", { name: "Add amount" }); + for (const _ of [0, 1, 2]) { + await settled(() => { + add.click(); + }); + } + + await type(amounts()[0], "1"); + await type(amounts()[1], "2"); + await type(amounts()[2], "3"); + + await settled(() => { + screen.getAllByRole("button", { name: "Remove" })[1].click(); + }); + + expect(amounts().map(input => input.value)).toEqual(["1", "3"]); + }); }); diff --git a/packages/vitnode/src/components/form/auto-form.tsx b/packages/vitnode/src/components/form/auto-form.tsx index 1fa24118e..550bada8b 100644 --- a/packages/vitnode/src/components/form/auto-form.tsx +++ b/packages/vitnode/src/components/form/auto-form.tsx @@ -12,7 +12,7 @@ import { useTranslations } from "use-intl"; import z from "zod"; import type { routeMiddlewareSchema } from "../../api/modules/middleware/route"; -import type { FormFieldApi, FormMode, FormSubmitMeta } from "../ui/form"; +import type { AnyFormFieldApi, FormMode, FormSubmitMeta } from "../ui/form"; import { useCaptcha } from "../../hooks/use-captcha"; import { @@ -34,7 +34,7 @@ import { TabsTrigger, } from "../ui/tabs"; -export type { FormFieldApi } from "../ui/form"; +export type { AnyFormFieldApi, FormFieldApi } from "../ui/form"; export { setFormFieldError } from "../ui/form"; interface ItemAutoFormSharedProps> { @@ -67,7 +67,7 @@ export interface AutoFormTab { export interface ItemAutoFormComponentProps { children?: React.ReactNode; description?: React.ReactNode; - field: FormFieldApi; + field: AnyFormFieldApi; itemParams?: InputParams; label?: React.ReactNode; labelRight?: React.ReactNode; diff --git a/packages/vitnode/src/components/form/fields/array.tsx b/packages/vitnode/src/components/form/fields/array.tsx index 66fe16854..c8ded0687 100644 --- a/packages/vitnode/src/components/form/fields/array.tsx +++ b/packages/vitnode/src/components/form/fields/array.tsx @@ -25,6 +25,22 @@ import { useFormField, } from "../../ui/form"; +const reconcileRowKeys = (previous: number[], length: number): number[] => { + if (previous.length >= length) { + return previous.slice(0, length); + } + + const nextKey = previous.length > 0 ? Math.max(...previous) + 1 : 0; + + return [ + ...previous, + ...Array.from( + { length: length - previous.length }, + (_, at) => nextKey + at, + ), + ]; +}; + export interface AutoFormArrayField { className?: string; component: (props: ItemAutoFormComponentProps) => React.ReactNode; @@ -65,6 +81,18 @@ export const AutoFormArray = ({ return Array.isArray(rows) ? rows.length : 0; }); + const [storedRowKeys, setStoredRowKeys] = React.useState(() => + reconcileRowKeys([], length), + ); + const rowKeys = + storedRowKeys.length === length + ? storedRowKeys + : reconcileRowKeys(storedRowKeys, length); + + if (rowKeys !== storedRowKeys) { + setStoredRowKeys(rowKeys); + } + const maxItems = maxItemsProp ?? otherProps.maxItems; const minItems = minItemsProp ?? otherProps.minItems ?? 0; @@ -77,10 +105,10 @@ export const AutoFormArray = ({ {!!description && {description}} - {Array.from({ length }, (_, index) => ( + {rowKeys.map((rowKey, index) => ( {fieldDefinitions.map(fieldDef => { @@ -164,6 +192,9 @@ export const AutoFormArray = ({ -) - -export default ProductsPage -``` - -TypeScript checks the two halves against each other: the schema has to return -what the page says it reads, even though the page itself is lazy. - - - `search` is a function, so it lives in `routes.ts`—which the app imports - statically. Everything that file imports is in the initial bundle with it, so - keep the schema module small: no React, no component, no import of the page it - belongs to. - - Declare it only for a screen whose URL *is* its state—a paginated list whose - `?page=999` has to be clamped, a filter whose links must be typed. For a page - that merely reads a parameter, use the module's own lazy `parseSearch` - instead; it normalises in the loader and adds nothing to the initial bundle. - - -## Dynamic breadcrumbs - -Every matched route contributes **one crumb**, parent to child, and VitNode owns -the separators, the accessibility semantics, and the locale-aware links. A crumb -returns a label: - -```tsx title="plugins/catalog/src/pages/product-page.tsx" -import type { - PluginRouteBreadcrumbProps, - PluginRoutePageProps, -} from '@vitnode/core/routing' -import { definePluginRoute } from '@vitnode/core/routing' - -interface Product { - description: string - name: string -} - -function ProductBreadcrumb({ loaderData }: PluginRouteBreadcrumbProps) { - return loaderData.name -} - -export const route = definePluginRoute({ - load: async ({ params }) => - await fetchProduct({ - categorySlug: params.categorySlug, - productId: params.productId, - }), - - head: ({ loaderData }) => ({ - description: loaderData?.description, - title: loaderData?.name, - }), - - breadcrumb: ProductBreadcrumb, -}) - -export default function ProductPage({ - loaderData, -}: PluginRoutePageProps) { - return ( -
-

{loaderData.name}

-

{loaderData.description}

-
- ) -} -``` - -With the catalog tree above, that renders `Catalog / Products / Laptops / -MacBook Pro`—each crumb from the route that owns it. See -[breadcrumbs](/docs/dev/plugins/breadcrumbs) for static crumbs, `breadcrumb: -false`, and how the trail is assembled. - - - 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. - - -## How the app picks this up - -The `vitnode:plugin-routes` Vite plugin reads the plugins in -`src/vitnode.config.ts`, imports each one's `routes` module in Node, validates -and flattens every tree, refuses two routes that claim one URL—including one of -the app's own—and writes a single `src/plugin-routes.gen.ts`: - -```ts title="apps/web/src/plugin-routes.gen.ts" -import { routes as pluginRoutes0 } from '@acme/catalog/routes' - -export const pluginRouteSources = [ - { pluginId: '@acme/catalog', routes: pluginRoutes0 }, -] as const satisfies readonly PluginRouteDeclarationSource[] -``` - -That is the only generated file, it names no page module, and it is committed -like any other generated artefact. Your pages stay in your package's own -`dist`, one chunk each. - - - } - title="Load data" - description="Use plugin loaders with cache-aware data and query state." - href="/docs/dev/data-loading" - /> - } - title="Breadcrumbs" - description="Contribute one crumb per route, translated or read from a loader." - href="/docs/dev/plugins/breadcrumbs" - /> - } - title="AdminCP pages" - description="Mount a plugin screen in the staff-only Admin Control Panel." - href="/docs/dev/plugins/admin" - /> - diff --git a/apps/web/content/docs/dev/routing/breadcrumbs.mdx b/apps/web/content/docs/dev/routing/breadcrumbs.mdx new file mode 100644 index 000000000..eedbcae2d --- /dev/null +++ b/apps/web/content/docs/dev/routing/breadcrumbs.mdx @@ -0,0 +1,168 @@ +--- +title: Breadcrumbs +description: Contribute one localized crumb per plugin route and let VitNode assemble the trail for public pages and AdminCP screens. +icon: Milestone +--- + +import { DatabaseIcon, LayoutDashboardIcon, MapIcon } from 'lucide-react' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' + +VitNode renders breadcrumb trails automatically in both the public site header and the AdminCP shell. **A route joins the trail by declaring a crumb**, and the trail reads parent to child: + +```text +Home / Notes / Getting Started +``` + +A route declares only what its own crumb should display. VitNode manages the rest: accessible `