From 5fb4bbb344c88d7c96225e7b8a0fa999e960131d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 14:34:15 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(admin):=20=E2=9C=A8=20add=20System=20?= =?UTF-8?q?=E2=86=92=20Plugins=20catalog=20of=20installed=20plugins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generate plugin metadata (title, description, version, icon) from each plugin's package.json during `vitnode build` and `vitnode dev`, register it with buildPlugin/buildApiPlugin, and list every configured plugin once in AdminCP → System → Plugins with the core version shown separately. - src/plugin-metadata.gen.ts and src/plugin-icon.gen.ts per plugin, icons validated at build time and inlined as data URLs - web build writes src/plugin-inventory.gen.ts, passed to the API as buildApiConfig({ frontendPlugins }) for frontend-only plugins and frontend/API version mismatch warnings - GET /api/@vitnode/core/admin/plugins guarded by plugins.can_view - Blog, Forum, Example and the plugin scaffold adopt the metadata - docs: dev/plugins/catalog Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqJtNifFvW63sCoYVKgJ7m --- apps/web/content/docs/dev/configuration.mdx | 4 +- .../dev/plugins/admin-plugins-catalog.webp | Bin 0 -> 64390 bytes apps/web/content/docs/dev/plugins/build.mdx | 4 + apps/web/content/docs/dev/plugins/catalog.mdx | 158 ++++++++++++ apps/web/content/docs/dev/plugins/create.mdx | 5 +- apps/web/content/docs/dev/plugins/meta.json | 1 + apps/web/src/locales/@vitnode/core/pl.json | 26 ++ apps/web/src/page-cache.gen.ts | 1 + apps/web/src/plugin-inventory.gen.ts | 47 ++++ apps/web/src/vitnode.api.config.ts | 4 + .../api-single-app/src/vitnode.api.config.ts | 2 + .../root/assets/icon.svg | 4 + .../src/helpers/packages-json.ts | 3 + .../src/plugin/create/create-package-json.ts | 11 +- .../src/plugin/create/route-templates.ts | 33 ++- .../scripts/cli/builder/compiler-build.ts | 21 +- .../vitnode/scripts/cli/commands/build.ts | 2 + packages/vitnode/scripts/cli/commands/dev.ts | 53 +++- .../scripts/watch-plugin-metadata.test.ts | 115 +++++++++ .../scripts/write-plugin-metadata.test.ts | 152 +++++++++++ .../vitnode/scripts/write-plugin-metadata.ts | 191 ++++++++++++++ packages/vitnode/src/api/config.ts | 1 + .../src/api/lib/installed-plugins.test.ts | 161 ++++++++++++ .../vitnode/src/api/lib/installed-plugins.ts | 126 +++++++++ packages/vitnode/src/api/lib/plugin.test.ts | 18 ++ packages/vitnode/src/api/lib/plugin.ts | 13 + .../src/api/middlewares/global.middleware.ts | 11 + .../src/api/modules/admin/admin.module.ts | 2 + .../admin/plugins/plugins.admin.module.ts | 10 + .../admin/plugins/routes/list.route.test.ts | 80 ++++++ .../admin/plugins/routes/list.route.ts | 59 +++++ packages/vitnode/src/api/plugin.ts | 1 + .../plugin-inventory/generate.test.ts | 86 +++++++ .../framework/plugin-inventory/generate.ts | 118 +++++++++ .../src/framework/plugin-inventory/index.ts | 5 + .../plugin-metadata/generate.test.ts | 189 ++++++++++++++ .../src/framework/plugin-metadata/generate.ts | 239 ++++++++++++++++++ .../src/framework/plugin-metadata/index.ts | 12 + .../src/framework/vite/plugin-routes.ts | 36 ++- packages/vitnode/src/lib/plugin-metadata.ts | 60 +++++ packages/vitnode/src/lib/plugin.test.ts | 21 ++ packages/vitnode/src/lib/plugin.ts | 18 ++ packages/vitnode/src/locales/en.json | 28 +- .../src/pages/admin/system/plugins.tsx | 24 ++ packages/vitnode/src/routes.tsx | 8 + .../src/tanstack/admin/plugins/index.ts | 11 + .../src/tanstack/admin/plugins/route.tsx | 47 ++++ .../src/tanstack/admin/plugins/screen.tsx | 40 +++ .../layouts/sidebar/nav/nav-model.test.ts | 1 + .../admin/layouts/sidebar/nav/nav-model.tsx | 5 + .../views/core/system/plugins/plugin-icon.tsx | 59 +++++ .../core/system/plugins/plugin-search.test.ts | 30 +++ .../core/system/plugins/plugin-search.ts | 14 + .../system/plugins/plugins-content.test.tsx | 164 ++++++++++++ .../core/system/plugins/plugins-content.tsx | 193 ++++++++++++++ .../core/system/plugins/plugins-query.ts | 56 ++++ packages/vitnode/src/vitnode.config.ts | 14 + packages/vitnode/vitest.config.ts | 1 + plugins/blog/assets/icon.svg | 6 + plugins/blog/package.json | 4 + plugins/blog/src/config.api.ts | 2 + plugins/blog/src/config.tsx | 3 + plugins/blog/src/plugin-icon.gen.ts | 13 + plugins/blog/src/plugin-metadata.gen.ts | 16 ++ plugins/example/assets/icon.svg | 5 + plugins/example/package.json | 4 + plugins/example/src/config.api.ts | 2 + plugins/example/src/config.tsx | 3 + plugins/example/src/plugin-icon.gen.ts | 13 + plugins/example/src/plugin-metadata.gen.ts | 16 ++ plugins/forum/assets/icon.svg | 5 + plugins/forum/package.json | 4 + plugins/forum/src/config.api.ts | 2 + plugins/forum/src/config.tsx | 3 + plugins/forum/src/plugin-icon.gen.ts | 13 + plugins/forum/src/plugin-metadata.gen.ts | 16 ++ 76 files changed, 2909 insertions(+), 19 deletions(-) create mode 100644 apps/web/content/docs/dev/plugins/admin-plugins-catalog.webp create mode 100644 apps/web/content/docs/dev/plugins/catalog.mdx create mode 100644 apps/web/src/plugin-inventory.gen.ts create mode 100644 packages/create-vitnode-app/copy-of-vitnode-plugin/root/assets/icon.svg create mode 100644 packages/vitnode/scripts/watch-plugin-metadata.test.ts create mode 100644 packages/vitnode/scripts/write-plugin-metadata.test.ts create mode 100644 packages/vitnode/scripts/write-plugin-metadata.ts create mode 100644 packages/vitnode/src/api/lib/installed-plugins.test.ts create mode 100644 packages/vitnode/src/api/lib/installed-plugins.ts create mode 100644 packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts create mode 100644 packages/vitnode/src/api/modules/admin/plugins/routes/list.route.test.ts create mode 100644 packages/vitnode/src/api/modules/admin/plugins/routes/list.route.ts create mode 100644 packages/vitnode/src/framework/plugin-inventory/generate.test.ts create mode 100644 packages/vitnode/src/framework/plugin-inventory/generate.ts create mode 100644 packages/vitnode/src/framework/plugin-inventory/index.ts create mode 100644 packages/vitnode/src/framework/plugin-metadata/generate.test.ts create mode 100644 packages/vitnode/src/framework/plugin-metadata/generate.ts create mode 100644 packages/vitnode/src/framework/plugin-metadata/index.ts create mode 100644 packages/vitnode/src/lib/plugin-metadata.ts create mode 100644 packages/vitnode/src/lib/plugin.test.ts create mode 100644 packages/vitnode/src/pages/admin/system/plugins.tsx create mode 100644 packages/vitnode/src/tanstack/admin/plugins/index.ts create mode 100644 packages/vitnode/src/tanstack/admin/plugins/route.tsx create mode 100644 packages/vitnode/src/tanstack/admin/plugins/screen.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/plugins/plugin-icon.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/plugins/plugin-search.test.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/plugins/plugin-search.ts create mode 100644 packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.test.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx create mode 100644 packages/vitnode/src/views/admin/views/core/system/plugins/plugins-query.ts create mode 100644 plugins/blog/assets/icon.svg create mode 100644 plugins/blog/src/plugin-icon.gen.ts create mode 100644 plugins/blog/src/plugin-metadata.gen.ts create mode 100644 plugins/example/assets/icon.svg create mode 100644 plugins/example/src/plugin-icon.gen.ts create mode 100644 plugins/example/src/plugin-metadata.gen.ts create mode 100644 plugins/forum/assets/icon.svg create mode 100644 plugins/forum/src/plugin-icon.gen.ts create mode 100644 plugins/forum/src/plugin-metadata.gen.ts diff --git a/apps/web/content/docs/dev/configuration.mdx b/apps/web/content/docs/dev/configuration.mdx index 492e492bad..c4570d061a 100644 --- a/apps/web/content/docs/dev/configuration.mdx +++ b/apps/web/content/docs/dev/configuration.mdx @@ -137,6 +137,7 @@ of those modules, and that is the part worth knowing: | `src/content-registry.gen.ts` | the plugin's `admin/content` | behind a dynamic `import()` in `src/router.tsx` | | `src/package-messages.gen.ts` | the factory's `localeFiles` | per request, one locale at a time | | `src/api-registry.gen.ts` | the plugin's `config.api` | never - type-only, it teaches the fetcher which plugins exist | +| `src/plugin-inventory.gen.ts` | the factory's `metadata` | by the API only, through `buildApiConfig({ frontendPlugins })` | One literal import per configured plugin, written at build time. So a content type's editing screen - a Tiptap field, a form layout, a table cell - arrives @@ -144,7 +145,8 @@ with the route that renders it, not with the config that the document shell imports. Spreading `admin/content` into the factory undoes exactly that: the whole editing stack rides along with every public page. See [Plugin routes](/docs/dev/routing) and -[Plugin frontend modules](/docs/content-engine/plugin-files). +[Plugin frontend modules](/docs/content-engine/plugin-files). The inventory is +what [the AdminCP plugin catalog](/docs/dev/plugins/catalog) lists. The `messages` a factory carries is the plugin's own locale barrel, which loads diff --git a/apps/web/content/docs/dev/plugins/admin-plugins-catalog.webp b/apps/web/content/docs/dev/plugins/admin-plugins-catalog.webp new file mode 100644 index 0000000000000000000000000000000000000000..1f3b8581edc1918d63ea07fc4f77388e1d072d0a GIT binary patch literal 64390 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
}Gsrke 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 E0HLE^00000 literal 0 HcmV?d00001 diff --git a/apps/web/content/docs/dev/plugins/build.mdx b/apps/web/content/docs/dev/plugins/build.mdx index f71cabf0c3..90883d7d7a 100644 --- a/apps/web/content/docs/dev/plugins/build.mdx +++ b/apps/web/content/docs/dev/plugins/build.mdx @@ -76,6 +76,10 @@ Declarations are written from every file `tsconfig.build.json` includes. That ma You don't need `isolatedDeclarations` or explicit return types. Inferred types come out the same as `tsc` would write them. +## Generated metadata + +Before compiling, `vitnode build` also writes `src/plugin-metadata.gen.ts` and `src/plugin-icon.gen.ts` from the plugin's `package.json`: its name, version, description, and the `vitnode.title` and `vitnode.icon` fields. `vitnode dev` writes them on start and again whenever `package.json` or the icon changes. They are what the [AdminCP plugin catalog](/docs/dev/plugins/catalog) shows, so commit them with the rest of `src`. + ## What a plugin package needs | File | Why | diff --git a/apps/web/content/docs/dev/plugins/catalog.mdx b/apps/web/content/docs/dev/plugins/catalog.mdx new file mode 100644 index 0000000000..6f12464421 --- /dev/null +++ b/apps/web/content/docs/dev/plugins/catalog.mdx @@ -0,0 +1,158 @@ +--- +title: Show a plugin in the AdminCP catalog +description: Add a title, description and icon to package.json so AdminCP → System → Plugins lists your plugin with the version that is actually installed. +icon: Puzzle +--- + +import { ImgDocs } from '@/components/fumadocs/img' + +import catalog from './admin-plugins-catalog.webp' + +**AdminCP → System → Plugins** lists every plugin the running application is configured with: its icon, title, package ID, description and installed version, with the VitNode core version shown separately above the list. Everything on that page comes from each plugin's own `package.json`, read at build time, so nobody types a version number by hand. + + + +The catalog only reports what is installed. It does not check for updates, install or remove plugins, or switch them on and off. One thing at a time. + +## Add your plugin to the catalog + + + + +### Describe the plugin in package.json + +Add a `description` and a `vitnode` field next to the `name` and `version` your package already has: + +```json title="plugins/site-notes/package.json" +{ + "name": "@acme/site-notes", + "version": "1.2.0", + "description": "Pin short notes to any page of your community.", + "vitnode": { + "title": "Site Notes", + "icon": "./assets/icon.svg" + } +} +``` + +Put the icon file at `plugins/site-notes/assets/icon.svg`. Plugins created with the generator already have all of this. + + + + +### Register the generated metadata + +`vitnode build` writes `src/plugin-metadata.gen.ts` and `src/plugin-icon.gen.ts` from that `package.json`. Pass them to both registrations: + +```tsx title="src/config.tsx" +import { buildPlugin } from '@vitnode/core/lib/plugin' + +import { CONFIG_PLUGIN } from '@/const' + +import { pluginMetadata } from './plugin-metadata.gen' +import { routes } from './routes' + +export const siteNotesPlugin = () => + buildPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + metadata: pluginMetadata, + icon: async () => await import('./plugin-icon.gen'), + routes, + }) +``` + +```ts title="src/config.api.ts" +import { buildApiPlugin } from '@vitnode/core/api/lib/plugin' + +import { CONFIG_PLUGIN } from '@/const' +import { pluginMetadata } from '@/plugin-metadata.gen' + +export const siteNotesApiPlugin = () => + buildApiPlugin({ + pluginId: CONFIG_PLUGIN.pluginId, + metadata: pluginMetadata, + modules: [], + }) +``` + +The icon is a lazy `import()`, so its bytes reach the browser only on the catalog page, never on your public pages. + + + + +### Build the plugin + +Run the build in the plugin's folder, or keep `vitnode dev` running: + +```bash +vitnode build +``` + +Both commands generate the two files before TypeScript and tsdown run. Commit them, like `types/api-registry.gen.d.ts`. + + + + +### Check the result + +Restart your app, sign in to the AdminCP and open **System → Plugins**. Your plugin shows its icon, title, `@acme/site-notes`, the description and `1.2.0`. Search matches the title, the package ID and the description. + +## package.json fields + +| Name | Type | Required | Default | Description | +| --------------- | ------ | -------- | ----------- | ----------------------------------------------------------------- | +| `name` | string | Yes | - | The plugin ID. It must equal the `pluginId` you register. | +| `version` | string | No | Unknown | The installed version, exactly as published. | +| `description` | string | No | - | Plain text shown under the title. | +| `vitnode.title` | string | No | `name` | Human-readable title, shown as written in every language. | +| `vitnode.icon` | string | No | Puzzle icon | Path to an image inside the package, relative to `package.json`. | + +The build stops with a clear error when `vitnode.icon` is a URL, an absolute path, a path outside the package (including a symlink that leads outside), a missing file, a format other than SVG, PNG, WebP or JPEG, a file whose bytes don't match its extension, or a file over 32 KB. Icons are never fetched from the internet. + +The icon is inlined into `src/plugin-icon.gen.ts` as a `data:` URL, so it ships inside your compiled package and works the same in a workspace and from `node_modules`. The AdminCP draws it with an `` tag, never as inline SVG markup, so an SVG icon cannot run scripts. A square icon around 48 × 48 looks best. + +## When the metadata is regenerated + +- `vitnode build` regenerates both files before every build, so a published package always carries its real version. +- `vitnode dev` regenerates them whenever `package.json` or the icon changes. A file is only rewritten when its content changes, so adding a dependency rebuilds nothing. +- A broken edit, such as a missing icon, is reported in the terminal and the last good files stay in place. + +The app picks up a new plugin, a removed one or a new version after it is rebuilt and restarted. Nothing is stored in the database. + +## Frontend and API inventory + +A plugin can be registered in `vitnode.config.ts` (frontend), in `vitnode.api.config.ts` (API), or both. The catalog lists each plugin ID once. The API cannot see the frontend config, so the web build hands it over: the Vite plugin writes `src/plugin-inventory.gen.ts`, plain metadata with no plugin code, and you pass it to the API config: + +```ts title="src/vitnode.api.config.ts" +import { buildApiConfig } from '@vitnode/core/vitnode.config' + +import { frontendPlugins } from './plugin-inventory.gen' + +export const vitNodeApiConfig = buildApiConfig({ + plugins: [siteNotesApiPlugin()], + frontendPlugins, + // ...the rest of your API config +}) +``` + +New apps that serve their API from the web app already do this. If your API runs as a separate deployment without that web build, leave `frontendPlugins` out. The catalog then lists the API's plugins only and says so with a notice, instead of guessing what the browser runs. + +## Version mismatch warnings + +When the web build and the API both report a version for the same plugin and the versions differ, the row shows a warning such as _"Version mismatch: the web app runs 1.1.0, the API runs 1.2.0."_ It usually means one side was redeployed without the other. Rebuild and redeploy both. + +A side that reports no version is unknown, not a mismatch. + +## Older plugins + +Plugins registered without `metadata` keep working. The catalog lists them by plugin ID, with the default icon, "Unknown version" and "No description provided." Add the metadata when convenient. + +Passing the metadata of another package throws at startup: `buildPlugin()` and `buildApiPlugin()` check that `metadata.id` equals `pluginId`. + +## Who can see the catalog + +The page and its endpoint, `GET /api/@vitnode/core/admin/plugins`, require the **Plugins → View installed plugins and their versions** permission (`plugins.can_view` in the `@vitnode/core` module). Staff without it don't see the link, get a not-found page on a direct visit, and a `403` from the API. Root administrators have it automatically. diff --git a/apps/web/content/docs/dev/plugins/create.mdx b/apps/web/content/docs/dev/plugins/create.mdx index 0fbcc466fc..a3bd790adc 100644 --- a/apps/web/content/docs/dev/plugins/create.mdx +++ b/apps/web/content/docs/dev/plugins/create.mdx @@ -91,6 +91,9 @@ import generatedPageImage from './generated-page-image.png' + + + @@ -116,7 +119,7 @@ import generatedPageImage from './generated-page-image.png' -One page at `/site-notes` whose loader calls the plugin's own `hello` endpoint, its strings, and a Vitest test for the endpoint (`pnpm test` in the plugin). The generator also registers the plugin in your apps' `vitnode.config.ts` and `vitnode.api.config.ts`, installs it and builds it. +One page at `/site-notes` whose loader calls the plugin's own `hello` endpoint, its strings, and a Vitest test for the endpoint (`pnpm test` in the plugin). `package.json` gets a `description` and a `vitnode` field with the plugin's title and `assets/icon.svg`, which is how it appears in [the AdminCP plugin catalog](/docs/dev/plugins/catalog). The generator also registers the plugin in your apps' `vitnode.config.ts` and `vitnode.api.config.ts`, installs it and builds it. The plugin name must be a valid npm name and must not be one of core's routes: `admin`, `api`, `core`, `discover`, `files`, `login`, `notifications`, `register`, `search`, `users` or `vitnode`. diff --git a/apps/web/content/docs/dev/plugins/meta.json b/apps/web/content/docs/dev/plugins/meta.json index bb71ded73a..3acabd884f 100644 --- a/apps/web/content/docs/dev/plugins/meta.json +++ b/apps/web/content/docs/dev/plugins/meta.json @@ -6,6 +6,7 @@ "pages": [ "create", "build", + "catalog", "api", "admin", "navigation", diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index b03b3518a0..1249d36a33 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -27,6 +27,8 @@ "@vitnode/core:ai": "AI", "@vitnode/core:ai:can_view": "Wyświetlanie użycia, ustawień i historii AI", "@vitnode/core:ai:can_manage": "Zarządzanie ustawieniami, modelami, cenami i dostępem AI", + "@vitnode/core:plugins": "Wtyczki", + "@vitnode/core:plugins:can_view": "Przeglądanie zainstalowanych wtyczek i ich wersji", "@vitnode/core:queue": "Zadania w kolejce", "@vitnode/core:queue:can_view": "Wyświetlanie zadań w kolejce", "@vitnode/core:roles": "Role", @@ -310,6 +312,7 @@ "files": "Pliki", "integrations": "Integracje", "navigation": "Nawigacja", + "plugins": "Wtyczki", "title": "System" }, "user_bar": { @@ -850,6 +853,29 @@ "desc": "Aktualizacje na żywo i powiadomienia wysyłane do połączonych klientów w czasie rzeczywistym.", "title": "WebSocket" } + }, + "plugins": { + "core": "Rdzeń VitNode", + "count_label": "Zainstalowane wtyczki", + "desc": "Wszystkie wtyczki skonfigurowane w tej aplikacji i wersje, których używa.", + "empty": { + "desc": "Dodaj wtyczkę do vitnode.config.ts lub vitnode.api.config.ts, a pojawi się tutaj po następnym buildzie.", + "title": "Brak zainstalowanych wtyczek" + }, + "frontend_unknown": { + "desc": "To API nie otrzymało inwentarza wtyczek z buildu aplikacji webowej, więc wtyczki zarejestrowane tylko w vitnode.config.ts nie są widoczne, a wersji nie da się porównać. Przekaż frontendPlugins do buildApiConfig, aby je uwzględnić.", + "title": "Widoczne są tylko wtyczki API" + }, + "mismatch": "Niezgodne wersje: aplikacja webowa używa {frontend}, a API {api}. Przebuduj i wdróż oba tak, aby były zgodne.", + "no_description": "Brak opisu.", + "no_results": { + "clear": "Wyczyść wyszukiwanie", + "desc": "Spróbuj nazwy wtyczki, jej ID pakietu lub słowa z opisu.", + "title": "Żadna wtyczka nie pasuje do wyszukiwania" + }, + "search": "Szukaj wtyczek...", + "title": "Wtyczki", + "version_unknown": "Nieznana wersja" } }, "user": { diff --git a/apps/web/src/page-cache.gen.ts b/apps/web/src/page-cache.gen.ts index c48778d544..ac0163d4b3 100644 --- a/apps/web/src/page-cache.gen.ts +++ b/apps/web/src/page-cache.gen.ts @@ -34,6 +34,7 @@ export const pageCacheRoutes: PageCacheRouteTable = [ { path: '/admin/core/system/integrations' }, { path: '/admin/core/system/navigation' }, { path: '/admin/core/system/notifications' }, + { path: '/admin/core/system/plugins' }, { path: '/admin/core/users/roles' }, { path: '/admin/core/users/:id' }, { path: '/admin/core/ai' }, diff --git a/apps/web/src/plugin-inventory.gen.ts b/apps/web/src/plugin-inventory.gen.ts new file mode 100644 index 0000000000..51ba4109c2 --- /dev/null +++ b/apps/web/src/plugin-inventory.gen.ts @@ -0,0 +1,47 @@ +/* oxlint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by the `vitnode:plugin-routes` Vite plugin on every +// `vite dev` and `vite build`, from the plugins configured in +// `src/vitnode.config.ts` and the package metadata each one registers. +// +// Plain data and nothing else: no plugin code is imported, so the API can read +// it without loading the frontend. Pass it to +// `buildApiConfig({ frontendPlugins })` so AdminCP → System → Plugins lists +// what this web build runs, frontend-only plugins included. +// +// Same plugin configuration in, same bytes out: the entries are sorted by +// plugin id. + +import type { FrontendPluginInventory } from '@vitnode/core/lib/plugin-metadata' + +export const frontendPlugins: FrontendPluginInventory = [ + { + id: "@vitnode/blog", + metadata: { + id: "@vitnode/blog", + title: "Blog", + description: "Blog plugin for VitNode, providing a blogging platform on Hono.js.", + version: "2.0.0-canary.13", + }, + }, + { + id: "@vitnode/example", + metadata: { + id: "@vitnode/example", + title: "Example", + description: "Reference plugin exercising the VitNode Content Engine end to end.", + version: "2.0.0-canary.13", + }, + }, + { + id: "@vitnode/forum", + metadata: { + id: "@vitnode/forum", + title: "Forum", + description: "Community forum for VitNode: structured forums and nested threads, built on the Content Engine.", + version: "2.0.0-canary.13", + }, + }, +] diff --git a/apps/web/src/vitnode.api.config.ts b/apps/web/src/vitnode.api.config.ts index e6f55309f9..04df7e365b 100644 --- a/apps/web/src/vitnode.api.config.ts +++ b/apps/web/src/vitnode.api.config.ts @@ -9,6 +9,7 @@ import { drizzle } from 'drizzle-orm/postgres-js' import { contentModules } from './content-modules.gen' import { appMessages } from './locales/app' +import { frontendPlugins } from './plugin-inventory.gen' import { vitNodeConfig } from './vitnode.config' config({ quiet: true }) @@ -29,6 +30,9 @@ export const POSTGRES_URL = export const vitNodeApiConfig = buildApiConfig({ plugins: [blogApiPlugin(), exampleApiPlugin(), forumApiPlugin()], contentModules, + // What this web build registers in `vitnode.config.ts`, so AdminCP → + // System → Plugins lists frontend-only plugins and version mismatches. + frontendPlugins, storage: { image: { quality: 85, diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts index 5add4731ae..888d2c3158 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts +++ b/packages/create-vitnode-app/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts @@ -3,6 +3,7 @@ import { buildApiConfig } from "@vitnode/core/vitnode.config"; import { drizzle } from "drizzle-orm/postgres-js"; import { contentModules } from "./content-modules.gen"; +import { frontendPlugins } from "./plugin-inventory.gen"; import { vitNodeConfig } from "./vitnode.config"; export const POSTGRES_URL = @@ -15,6 +16,7 @@ export const vitNodeApiConfig = buildApiConfig({ }, plugins: [], contentModules, + frontendPlugins, i18n: vitNodeConfig.i18n, dbProvider: drizzle({ diff --git a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/assets/icon.svg b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/assets/icon.svg new file mode 100644 index 0000000000..0aec6c4673 --- /dev/null +++ b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/assets/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/create-vitnode-app/src/helpers/packages-json.ts b/packages/create-vitnode-app/src/helpers/packages-json.ts index 42eb943416..6c9003796d 100644 --- a/packages/create-vitnode-app/src/helpers/packages-json.ts +++ b/packages/create-vitnode-app/src/helpers/packages-json.ts @@ -1,5 +1,6 @@ export interface PackageJSON { dependencies?: Record; + description?: string; devDependencies?: Record; engines?: Record; @@ -12,5 +13,7 @@ export interface PackageJSON { scripts?: Record; type?: string; version?: string; + /** What AdminCP → System → Plugins shows for a plugin package. */ + vitnode?: { icon?: string; title?: string }; workspaces?: string[]; } diff --git a/packages/create-vitnode-app/src/plugin/create/create-package-json.ts b/packages/create-vitnode-app/src/plugin/create/create-package-json.ts index 4950e1e07a..b2bfd3632f 100644 --- a/packages/create-vitnode-app/src/plugin/create/create-package-json.ts +++ b/packages/create-vitnode-app/src/plugin/create/create-package-json.ts @@ -6,7 +6,11 @@ import { versionsPackageJson } from "../../create/package-versions.js"; import { getVitnodePackageVersion } from "../../helpers/get-vitnode-package-version.js"; import { supportedNodeRange } from "../../helpers/node-version.js"; import { withIf } from "../../helpers/with-If.js"; -import { pluginPackageExports } from "./route-templates.js"; +import { + PLUGIN_ICON_PATH, + pluginPackageExports, + pluginTitleFor, +} from "./route-templates.js"; const writeJson = async (path: string, data: unknown) => writeFile(path, JSON.stringify(data, null, 2)); @@ -36,6 +40,11 @@ export const createPluginPackageJSON = async ({ const pluginPkg: PackageJSON = { name: pluginName, version: "0.1.0", + description: "A VitNode plugin.", + vitnode: { + title: pluginTitleFor(pluginName), + icon: `./${PLUGIN_ICON_PATH}`, + }, private: true, type: "module", engines: { node: supportedNodeRange }, 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 4c986aa30d..ced1e9704b 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.ts @@ -3,6 +3,20 @@ export const routeSlugFor = (pluginName: string): string => ? pluginName.slice(pluginName.indexOf("/") + 1) : pluginName; +/** The plugin's AdminCP title - `@acme/my-blog` becomes `My Blog`. */ +export const pluginTitleFor = (pluginName: string): string => + routeSlugFor(pluginName) + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map(part => `${part[0].toUpperCase()}${part.slice(1)}`) + .join(" ") || pluginName; + +/** + * The icon `package.json` names as `vitnode.icon`, copied with the rest of + * `copy-of-vitnode-plugin/root`. + */ +export const PLUGIN_ICON_PATH = "assets/icon.svg"; + export const pluginConstTemplate = (pluginName: string): string => `export const CONFIG_PLUGIN = { pluginId: "${pluginName}" as const, @@ -151,11 +165,15 @@ export const pluginVariableName = (pluginName: string): string => { /** * `src/config.tsx` - what an application registers. * - * The routes, the locale files and the messages, and nothing else. `routes` is - * the same tree `routes.ts` exports, handed on unchanged: an app on Vite reads - * that file directly at build time and an app that registers the plugin the - * ordinary way reads it through here, so the two paths cannot describe - * different routes. + * The routes, the locale files, the messages and the package metadata, and + * nothing else. `plugin-metadata.gen.ts` and `plugin-icon.gen.ts` are written + * from `package.json` by `vitnode build` and `vitnode dev`, so the version the + * AdminCP shows is never typed by hand. + * + * `routes` is the same tree `routes.ts` exports, handed on unchanged: an app on + * Vite reads that file directly at build time and an app that registers the + * plugin the ordinary way reads it through here, so the two paths cannot + * describe different routes. * * `localeFiles` is the same list as the barrel above it, spelled as specifiers * rather than as loaders: an app's build writes its own loaders from it, and a @@ -167,11 +185,14 @@ export const pluginConfigTemplate = (pluginName: string): string => import { CONFIG_PLUGIN } from "@/const"; import messages from "./locales"; +import { pluginMetadata } from "./plugin-metadata.gen"; import { routes } from "./routes"; export const ${pluginVariableName(pluginName)} = () => buildPlugin({ pluginId: CONFIG_PLUGIN.pluginId, + metadata: pluginMetadata, + icon: async () => await import("./plugin-icon.gen"), localeFiles: { en: "${pluginName}/locales/en.json", }, @@ -229,12 +250,14 @@ export const pluginApiConfigTemplate = (pluginName: string): string => import { buildApiPlugin } from "@vitnode/core/api/lib/plugin"; import { CONFIG_PLUGIN } from "@/const"; +import { pluginMetadata } from "@/plugin-metadata.gen"; import { helloModule } from "./api/modules/hello/hello.module"; export const ${pluginApiVariableName(pluginName)} = () => buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, + metadata: pluginMetadata, modules: [helloModule], }); diff --git a/packages/vitnode/scripts/cli/builder/compiler-build.ts b/packages/vitnode/scripts/cli/builder/compiler-build.ts index 837e13e3b9..f99adbfaa4 100644 --- a/packages/vitnode/scripts/cli/builder/compiler-build.ts +++ b/packages/vitnode/scripts/cli/builder/compiler-build.ts @@ -7,7 +7,11 @@ import type { MeasuredFile } from "./output-files"; import type { TsdownApi } from "./package-build"; import { writePluginApiRegistry } from "../../write-plugin-api-registry"; -import { errorMessage, RuntimeError } from "../errors"; +import { + definesPlugin, + writePluginMetadata, +} from "../../write-plugin-metadata"; +import { ConfigError, errorMessage, RuntimeError } from "../errors"; import { importFromProject, resolveBin } from "../project/packages"; import { runProcess } from "../project/processes"; import { captureProcessOutput } from "../ui/capture-output"; @@ -199,6 +203,21 @@ export const prepareRegistryStep = (project: Project, ui: Ui) => { } }; +/** + * Writes `src/plugin-metadata.gen.ts` and `src/plugin-icon.gen.ts` from the + * plugin's `package.json`, before tsdown and `tsc` resolve the imports of them. + */ +export const prepareMetadataStep = (project: Project, ui: Ui) => { + if (!definesPlugin(project.root)) return; + + try { + writePluginMetadata(project.root); + } catch (error) { + throw new ConfigError(errorMessage(error), { cause: error }); + } + ui.success("Plugin metadata generated"); +}; + /** Every file under `dir`, as measured output, for the compiler builds. */ export const listOutput = (project: Project, dir: string): MeasuredFile[] => { const root = join(project.root, dir); diff --git a/packages/vitnode/scripts/cli/commands/build.ts b/packages/vitnode/scripts/cli/commands/build.ts index cf6e9c830f..a2c902b560 100644 --- a/packages/vitnode/scripts/cli/commands/build.ts +++ b/packages/vitnode/scripts/cli/commands/build.ts @@ -11,6 +11,7 @@ import { runAppBuild } from "../builder/app-build"; import { apiBuildSteps, listOutput, + prepareMetadataStep, prepareRegistryStep, runCompilerSteps, runPackageBuild, @@ -153,6 +154,7 @@ export const runBuildCommand = async ( if (project.kind === "package") { ui.header(`Building ${project.name}`); if (isPluginPackage(project.root)) prepareRegistryStep(project, ui); + prepareMetadataStep(project, ui); await runPackageBuild({ loadTsdown: deps.loadTsdown, project, diff --git a/packages/vitnode/scripts/cli/commands/dev.ts b/packages/vitnode/scripts/cli/commands/dev.ts index 50bfd5539f..753f5f5b64 100644 --- a/packages/vitnode/scripts/cli/commands/dev.ts +++ b/packages/vitnode/scripts/cli/commands/dev.ts @@ -6,6 +6,11 @@ import type { ProcessGroup } from "../project/processes"; import type { Project } from "../project/project"; import { writePluginApiRegistry } from "../../write-plugin-api-registry"; +import { + definesPlugin, + watchPluginMetadata, + writePluginMetadata, +} from "../../write-plugin-metadata"; import { createDatabaseServices } from "../db/database"; import { prepareDevelopmentDatabase } from "../db/prepare"; import { openUrl } from "../dev/open-url"; @@ -16,6 +21,7 @@ import { runWatchers, toProcess, } from "../dev/watchers"; +import { ConfigError, errorMessage } from "../errors"; import { loadConfiguredPluginIds } from "../plugins/discover"; import { resolveFromProject } from "../project/packages"; import { detectProject } from "../project/project"; @@ -43,6 +49,32 @@ const apiHost = (host: DevOptions["host"]): string | undefined => const ownsDatabase = (project: Project) => project.drizzleConfig !== null && project.hasApi; +/** + * Writes the plugin's metadata modules, then keeps them in step with + * `package.json` and the icon. A broken edit is reported and the last good + * files stay, so the watchers keep running while it is fixed. + */ +const watchMetadata = (root: string, ui: CommandContext["ui"]) => { + let inputs: string[]; + try { + inputs = writePluginMetadata(root).inputs; + } catch (error) { + throw new ConfigError(errorMessage(error), { cause: error }); + } + ui.success("Plugin metadata generated"); + + return watchPluginMetadata({ + inputs, + onChange: () => { + ui.success("Plugin metadata regenerated from package.json"); + }, + onError: error => { + ui.error(errorMessage(error)); + }, + root, + }); +}; + /** * `vitnode dev` - the development environment for whatever this folder is. * @@ -66,17 +98,26 @@ export const runDevCommand = async ( resolveFromProject(project.root, "tsdown"); if (writePluginApiRegistry(project.root)) ui.success("API registry generated"); + const metadata = definesPlugin(project.root) + ? watchMetadata(project.root, ui) + : null; ui.info( "Watching sources - apps using this plugin reload as dist/ changes", ); ui.line(); - return runWatchers({ - group: deps.group, - processes: packageWatchers().map(watcher => toProcess(project, watcher)), - signals, - ui, - }); + try { + return await runWatchers({ + group: deps.group, + processes: packageWatchers().map(watcher => + toProcess(project, watcher), + ), + signals, + ui, + }); + } finally { + metadata?.close(); + } } ui.header("Development"); diff --git a/packages/vitnode/scripts/watch-plugin-metadata.test.ts b/packages/vitnode/scripts/watch-plugin-metadata.test.ts new file mode 100644 index 0000000000..cb5c7fc670 --- /dev/null +++ b/packages/vitnode/scripts/watch-plugin-metadata.test.ts @@ -0,0 +1,115 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + PLUGIN_ICON_PATH, + PLUGIN_METADATA_PATH, +} from "../src/framework/plugin-metadata/generate.js"; +import { + watchPluginMetadata, + writePluginMetadata, +} from "./write-plugin-metadata.js"; + +// Real `fs.watchFile` polling (every 300ms), so this lives in INTEGRATION. + +const SVG = ''; +const BLOG = { name: "@acme/blog", version: "1.0.0" }; + +let root: string; +let close: () => void = () => undefined; + +const write = (path: string, content: string) => { + writeFileSync(join(root, path), content); +}; + +const read = (path: string) => readFileSync(join(root, path), "utf8"); + +/** Lets the poller take its first snapshot, so the next edit is a change. */ +const settle = async () => + await new Promise(resolve => setTimeout(resolve, 400)); + +const watch = async () => { + const onChange = vi.fn(); + const onError = vi.fn(); + const { inputs } = writePluginMetadata(root); + ({ close } = watchPluginMetadata({ inputs, onChange, onError, root })); + await settle(); + + return { onChange, onError }; +}; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-plugin-metadata-watch-")); + mkdirSync(join(root, "src")); + mkdirSync(join(root, "assets")); + write("package.json", JSON.stringify(BLOG)); +}); + +afterEach(() => { + close(); + rmSync(root, { force: true, recursive: true }); +}); + +describe("watchPluginMetadata", () => { + it("regenerates when package.json changes, then follows a newly declared icon", async () => { + const { onChange, onError } = await watch(); + + write("assets/icon.svg", SVG); + write( + "package.json", + JSON.stringify({ ...BLOG, vitnode: { icon: "./assets/icon.svg" } }), + ); + await vi.waitFor( + () => { + expect(read(PLUGIN_ICON_PATH)).toContain("data:image/svg+xml"); + }, + { timeout: 5000 }, + ); + expect(onChange).toHaveBeenCalled(); + await settle(); + + write("assets/icon.svg", `${SVG}\n`); + await vi.waitFor( + () => { + expect(read(PLUGIN_ICON_PATH)).toContain( + Buffer.from(`${SVG}\n`).toString("base64"), + ); + }, + { timeout: 5000 }, + ); + expect(onError).not.toHaveBeenCalled(); + }); + + it("reports a broken edit, keeps the last files, and recovers", async () => { + const { onChange, onError } = await watch(); + const metadata = read(PLUGIN_METADATA_PATH); + + write("package.json", "{ broken"); + await vi.waitFor( + () => { + expect(onError).toHaveBeenCalled(); + }, + { timeout: 5000 }, + ); + expect(read(PLUGIN_METADATA_PATH)).toBe(metadata); + expect(onChange).not.toHaveBeenCalled(); + await settle(); + + write("package.json", JSON.stringify({ ...BLOG, version: "2.0.0" })); + await vi.waitFor( + () => { + expect(read(PLUGIN_METADATA_PATH)).toContain('"2.0.0"'); + }, + { timeout: 5000 }, + ); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/vitnode/scripts/write-plugin-metadata.test.ts b/packages/vitnode/scripts/write-plugin-metadata.test.ts new file mode 100644 index 0000000000..c735ac757e --- /dev/null +++ b/packages/vitnode/scripts/write-plugin-metadata.test.ts @@ -0,0 +1,152 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + PLUGIN_ICON_PATH, + PLUGIN_METADATA_PATH, +} from "../src/framework/plugin-metadata/generate.js"; +import { writePluginMetadata } from "./write-plugin-metadata.js"; + +const SVG = ''; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { force: true, recursive: true }); + } +}); + +const tempDir = () => { + const dir = mkdtempSync(join(tmpdir(), "vitnode-plugin-metadata-")); + dirs.push(dir); + + return dir; +}; + +const write = (root: string, path: string, content: string) => { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), content); +}; + +const writeManifest = (root: string, manifest: Record) => { + write(root, "package.json", JSON.stringify(manifest, null, 2)); +}; + +const pluginAt = (manifest: Record) => { + const root = tempDir(); + mkdirSync(join(root, "src")); + writeManifest(root, manifest); + + return root; +}; + +const read = (root: string, path: string) => + existsSync(join(root, path)) ? readFileSync(join(root, path), "utf8") : null; + +const BLOG = { + description: "Posts", + name: "@acme/blog", + version: "1.0.0", + vitnode: { icon: "./assets/icon.svg", title: "Blog" }, +}; + +describe("writePluginMetadata", () => { + it("writes the metadata and the inlined icon from package.json", () => { + const root = pluginAt(BLOG); + write(root, "assets/icon.svg", SVG); + + expect(writePluginMetadata(root)).toEqual({ + changed: true, + inputs: [join(root, "package.json"), join(root, "assets/icon.svg")], + }); + expect(read(root, PLUGIN_METADATA_PATH)).toContain('"@acme/blog"'); + expect(read(root, PLUGIN_ICON_PATH)).toContain( + `data:image/svg+xml;base64,${Buffer.from(SVG).toString("base64")}`, + ); + }); + + it("writes a null icon and watches only package.json without one", () => { + const root = pluginAt({ name: "@acme/blog" }); + + expect(writePluginMetadata(root).inputs).toEqual([ + join(root, "package.json"), + ]); + expect(read(root, PLUGIN_ICON_PATH)).toContain("= null"); + }); + + it("leaves the files alone when nothing they show changed", () => { + const root = pluginAt(BLOG); + write(root, "assets/icon.svg", SVG); + writePluginMetadata(root); + + writeManifest(root, { ...BLOG, dependencies: { zod: "^4.0.0" } }); + + expect(writePluginMetadata(root).changed).toBe(false); + }); + + it.each([ + ["the version", { ...BLOG, version: "1.1.0" }, () => undefined], + [ + "the icon", + BLOG, + (root: string) => { + write(root, "assets/icon.svg", `${SVG}\n`); + }, + ], + ])("rewrites when %s changes", (_, manifest, edit) => { + const root = pluginAt(BLOG); + write(root, "assets/icon.svg", SVG); + writePluginMetadata(root); + + writeManifest(root, manifest); + edit(root); + + expect(writePluginMetadata(root).changed).toBe(true); + }); + + it.each([ + [ + "an icon path leaving the package", + (root: string) => { + writeManifest(root, { ...BLOG, vitnode: { icon: "../icon.svg" } }); + }, + /points outside the package/, + ], + ["a missing icon file", () => undefined, /there is no file there/], + [ + "an icon linking outside the package", + (root: string) => { + const outside = tempDir(); + write(outside, "icon.svg", SVG); + mkdirSync(join(root, "assets")); + symlinkSync(join(outside, "icon.svg"), join(root, "assets/icon.svg")); + }, + /a link to a file outside the package/, + ], + [ + "an icon that is not what its extension says", + (root: string) => { + write(root, "assets/icon.svg", "plain text"); + }, + /is not a valid SVG file/, + ], + ])("refuses %s and writes neither file", (_, setup, error) => { + const root = pluginAt(BLOG); + setup(root); + + expect(() => writePluginMetadata(root)).toThrow(error); + expect(read(root, PLUGIN_METADATA_PATH)).toBeNull(); + expect(read(root, PLUGIN_ICON_PATH)).toBeNull(); + }); +}); diff --git a/packages/vitnode/scripts/write-plugin-metadata.ts b/packages/vitnode/scripts/write-plugin-metadata.ts new file mode 100644 index 0000000000..745c9c92e5 --- /dev/null +++ b/packages/vitnode/scripts/write-plugin-metadata.ts @@ -0,0 +1,191 @@ +import { + existsSync, + readFileSync, + realpathSync, + statSync, + unwatchFile, + watchFile, + writeFileSync, +} from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; + +import { + generatePluginIconSource, + generatePluginMetadataSource, + PLUGIN_ICON_PATH, + PLUGIN_METADATA_PATH, + pluginIconDataUrl, + pluginMetadataFromManifest, +} from "../src/framework/plugin-metadata/generate.js"; +import { CORE_PLUGIN_ID } from "../src/framework/plugin-routes/core.js"; +import { PLUGIN_METADATA_ERROR_PREFIX as ERROR_PREFIX } from "../src/lib/plugin-metadata.js"; + +export interface PluginMetadataWrite { + /** Whether either generated file changed on disk. */ + changed: boolean; + /** The files the output depends on: `package.json`, and the icon if any. */ + inputs: string[]; +} + +/** The modules that import the generated metadata - a plugin's definition. */ +const PLUGIN_DEFINITIONS = ["src/config.tsx", "src/config.ts"]; + +/** + * Whether a package defines a plugin and so needs its metadata modules: it has + * the module that imports them, and it is not core, whose `src/config.ts` is + * its own constants. Decided by the files alone, so a plugin builds the same + * whether `@vitnode/core` is a dependency, a peer or hoisted from a workspace. + */ +export const definesPlugin = (root: string): boolean => { + const manifest = join(root, "package.json"); + if (!existsSync(manifest)) return false; + + const { name } = JSON.parse(readFileSync(manifest, "utf8")) as { + name?: unknown; + }; + + return ( + name !== CORE_PLUGIN_ID && + PLUGIN_DEFINITIONS.some(file => existsSync(join(root, file))) + ); +}; + +const isInside = (root: string, path: string): boolean => { + const rel = relative(root, path); + + return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); +}; + +/** + * The declared icon, read only once it is proven to live inside the package - + * by its path *and* by where its symlinks lead, since a published package + * carries the file and nothing outside it. + */ +const readIcon = (root: string, declared: string, source: string) => { + const path = resolve(root, declared); + + if (!isInside(root, path)) { + throw new Error( + `${ERROR_PREFIX} \`vitnode.icon\` in ${source} is ${JSON.stringify(declared)}, which points outside the package. Keep the icon inside it, such as "./assets/icon.svg".`, + ); + } + + if (!existsSync(path) || !statSync(path).isFile()) { + throw new Error( + `${ERROR_PREFIX} \`vitnode.icon\` in ${source} is ${JSON.stringify(declared)}, and there is no file there.`, + ); + } + + if (!isInside(realpathSync(root), realpathSync(path))) { + throw new Error( + `${ERROR_PREFIX} \`vitnode.icon\` in ${source} is ${JSON.stringify(declared)}, a link to a file outside the package. Copy the icon into the package instead.`, + ); + } + + return { bytes: readFileSync(path), path }; +}; + +const writeIfChanged = (path: string, source: string): boolean => { + if (existsSync(path) && readFileSync(path, "utf8") === source) return false; + + writeFileSync(path, source, "utf8"); + + return true; +}; + +/** + * `src/plugin-metadata.gen.ts` and `src/plugin-icon.gen.ts`, from the plugin's + * own `package.json`. + * + * Synchronous and before the compilers, like the API registry: `tsc` and tsdown + * must find both modules on their first pass. A file is only rewritten when its + * bytes change, so an edit to an unrelated `package.json` field - a new + * dependency - rebuilds nothing. + */ +export const writePluginMetadata = ( + root: string = process.cwd(), +): PluginMetadataWrite => { + const manifestPath = join(root, "package.json"); + const source = relative(process.cwd(), manifestPath) || "package.json"; + const { icon, metadata } = pluginMetadataFromManifest( + JSON.parse(readFileSync(manifestPath, "utf8")) as unknown, + source, + ); + const iconFile = icon === null ? null : readIcon(root, icon, source); + // Both checked before either file is written, so a broken icon never leaves + // new metadata next to a stale icon. + const iconSource = generatePluginIconSource( + icon === null || iconFile === null + ? null + : pluginIconDataUrl(icon, iconFile.bytes, source), + ); + + const changedMetadata = writeIfChanged( + join(root, PLUGIN_METADATA_PATH), + generatePluginMetadataSource(metadata), + ); + const changedIcon = writeIfChanged(join(root, PLUGIN_ICON_PATH), iconSource); + + return { + changed: changedMetadata || changedIcon, + inputs: [manifestPath, ...(iconFile === null ? [] : [iconFile.path])], + }; +}; + +const POLL_MS = 300; + +/** + * Regenerates the metadata while `vitnode dev` runs, whenever `package.json` + * or the declared icon changes. + * + * Polled rather than `fs.watch`ed: editors and package managers replace + * `package.json` instead of writing into it, which ends a native watcher. Only + * those inputs are watched, and the outputs live under `src` - which tsdown + * watches and this does not - so a write can never trigger another one. + */ +export const watchPluginMetadata = ({ + inputs, + onChange, + onError, + root, +}: { + inputs: readonly string[]; + onChange: () => void; + onError: (error: unknown) => void; + root: string; +}): { close: () => void } => { + let watched: string[] = []; + + const listener = () => { + try { + const result = writePluginMetadata(root); + watch(result.inputs); + if (result.changed) onChange(); + } catch (error) { + onError(error); + } + }; + + const watch = (next: readonly string[]) => { + for (const file of watched) { + if (!next.includes(file)) unwatchFile(file, listener); + } + for (const file of next) { + if (!watched.includes(file)) { + watchFile(file, { interval: POLL_MS }, listener); + } + } + watched = [...next]; + }; + + watch(inputs); + + return { + close: () => { + watched.forEach(file => { + unwatchFile(file, listener); + }); + watched = []; + }, + }; +}; diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index cebd41abe5..db7eb9ecdd 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -195,6 +195,7 @@ export function VitNodeAPI({ content: vitNodeApiConfig.content, cron: vitNodeApiConfig.cron, events: vitNodeApiConfig.events, + frontendPlugins: vitNodeApiConfig.frontendPlugins, notifications: vitNodeApiConfig.notifications, search: vitNodeApiConfig.search, storage: vitNodeApiConfig.storage, diff --git a/packages/vitnode/src/api/lib/installed-plugins.test.ts b/packages/vitnode/src/api/lib/installed-plugins.test.ts new file mode 100644 index 0000000000..6b3dc54193 --- /dev/null +++ b/packages/vitnode/src/api/lib/installed-plugins.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; + +import type { PluginMetadata } from "@/lib/plugin-metadata"; + +import { buildInstalledPluginRegistry } from "./installed-plugins"; + +const metadata = ( + id: string, + overrides: Partial = {}, +): PluginMetadata => ({ + description: `${id} description`, + id, + title: `${id} title`, + version: "1.0.0", + ...overrides, +}); + +describe("buildInstalledPluginRegistry", () => { + it("lists each plugin once, sorted by id, without core", () => { + const registry = buildInstalledPluginRegistry({ + api: [ + { metadata: metadata("@acme/shop"), pluginId: "@acme/shop" }, + { pluginId: "@vitnode/core" }, + { metadata: metadata("@acme/blog"), pluginId: "@acme/blog" }, + ], + frontend: [ + { id: "@vitnode/core", metadata: null }, + { id: "@acme/blog", metadata: metadata("@acme/blog") }, + { id: "@acme/analytics", metadata: metadata("@acme/analytics") }, + ], + }); + + expect(registry.plugins.map(plugin => plugin.id)).toEqual([ + "@acme/analytics", + "@acme/blog", + "@acme/shop", + ]); + }); + + it("merges both registrations of a plugin", () => { + const [plugin] = buildInstalledPluginRegistry({ + api: [{ metadata: metadata("@acme/blog"), pluginId: "@acme/blog" }], + frontend: [{ id: "@acme/blog", metadata: metadata("@acme/blog") }], + }).plugins; + + expect(plugin).toEqual({ + description: "@acme/blog description", + id: "@acme/blog", + registrations: { + api: { version: "1.0.0" }, + frontend: { version: "1.0.0" }, + }, + title: "@acme/blog title", + version: "1.0.0", + versionMismatch: false, + }); + }); + + it.each([ + ["the frontend inventory is given", [], true], + ["no frontend inventory is given", undefined, false], + ])("reports whether %s", (_, frontend, expected) => { + expect( + buildInstalledPluginRegistry({ api: [], frontend }).frontendInventory, + ).toBe(expected); + }); + + it.each([ + ["only the API knows it", "2.0.0", null, "2.0.0", false], + ["only the frontend knows it", null, "3.0.0", "3.0.0", false], + ["both agree", "2.0.0", "2.0.0", "2.0.0", false], + ["they differ, preferring the API's", "2.0.0", "3.0.0", "2.0.0", true], + ["neither knows it", null, null, null, false], + ])( + "resolves the version when %s", + (_, apiVersion, frontendVersion, version, versionMismatch) => { + const [plugin] = buildInstalledPluginRegistry({ + api: [ + { + metadata: metadata("@acme/blog", { version: apiVersion }), + pluginId: "@acme/blog", + }, + ], + frontend: [ + { + id: "@acme/blog", + metadata: metadata("@acme/blog", { version: frontendVersion }), + }, + ], + }).plugins; + + expect({ + version: plugin?.version, + versionMismatch: plugin?.versionMismatch, + }).toEqual({ version, versionMismatch }); + }, + ); + + it("treats a side without metadata as unknown, not a mismatch", () => { + const [plugin] = buildInstalledPluginRegistry({ + api: [{ pluginId: "@acme/blog" }], + frontend: [{ id: "@acme/blog", metadata: metadata("@acme/blog") }], + }).plugins; + + expect(plugin?.registrations).toEqual({ + api: { version: null }, + frontend: { version: "1.0.0" }, + }); + expect(plugin?.version).toBe("1.0.0"); + expect(plugin?.versionMismatch).toBe(false); + }); + + it("lists a plugin with no metadata anywhere by its id", () => { + const [plugin] = buildInstalledPluginRegistry({ + api: [], + frontend: [{ id: "@acme/blog", metadata: null }], + }).plugins; + + expect(plugin).toEqual({ + description: null, + id: "@acme/blog", + registrations: { api: null, frontend: { version: null } }, + title: "@acme/blog", + version: null, + versionMismatch: false, + }); + }); + + it.each([ + [ + "the API", + { + api: [{ pluginId: "@acme/blog" }, { pluginId: "@acme/blog" }], + frontend: undefined, + }, + ], + [ + "the frontend", + { + api: [], + frontend: [ + { id: "@acme/blog", metadata: null }, + { id: "@acme/blog", metadata: null }, + ], + }, + ], + ])("refuses an id registered twice by %s", (_, input) => { + expect(() => buildInstalledPluginRegistry(input)).toThrow( + /claim the same id: "@acme\/blog"/, + ); + }); + + it("refuses metadata registered under another plugin's id", () => { + expect(() => + buildInstalledPluginRegistry({ + api: [], + frontend: [{ id: "@acme/shop", metadata: metadata("@acme/blog") }], + }), + ).toThrow(/\[VitNode plugin metadata\]/); + }); +}); diff --git a/packages/vitnode/src/api/lib/installed-plugins.ts b/packages/vitnode/src/api/lib/installed-plugins.ts new file mode 100644 index 0000000000..28feba9b82 --- /dev/null +++ b/packages/vitnode/src/api/lib/installed-plugins.ts @@ -0,0 +1,126 @@ +import type { + FrontendPluginInventory, + PluginMetadata, +} from "@/lib/plugin-metadata"; + +import { CONFIG_PLUGIN } from "@/config"; +import { assertPluginMetadataId } from "@/lib/plugin-metadata"; + +/** One side's registration of a plugin, as far as the catalog cares. */ +export interface InstalledPluginRegistration { + /** `null` when that registration carries no metadata, or no version. */ + version: null | string; +} + +export interface InstalledPlugin { + description: null | string; + id: string; + registrations: { + api: InstalledPluginRegistration | null; + /** `null` also when the API was not given the frontend inventory. */ + frontend: InstalledPluginRegistration | null; + }; + title: string; + /** The known version, the API's first. `null` when neither side knows it. */ + version: null | string; + /** Both sides report a version, and not the same one. */ + versionMismatch: boolean; +} + +export interface InstalledPluginRegistry { + /** Whether the frontend registrations are known at all. */ + frontendInventory: boolean; + plugins: InstalledPlugin[]; +} + +interface Registration { + metadata: null | PluginMetadata; +} + +const ERROR_PREFIX = "[VitNode plugins]"; + +const byId = ( + entries: readonly { id: string; metadata: null | PluginMetadata }[], + side: string, +): Map => { + const map = new Map(); + + for (const { id, metadata } of entries) { + // Core is shown on its own, above the list, and is not a plugin to count. + if (id === CONFIG_PLUGIN.pluginId) continue; + + if (map.has(id)) { + throw new Error( + `${ERROR_PREFIX} Two ${side} plugins claim the same id: ${JSON.stringify(id)}. A plugin id is the package name, so an app cannot configure one twice.`, + ); + } + + assertPluginMetadataId(id, metadata ?? undefined, `The ${side} config`); + map.set(id, { metadata }); + } + + return map; +}; + +const registrationOf = ( + registration: Registration | undefined, +): InstalledPluginRegistration | null => + registration === undefined + ? null + : { version: registration.metadata?.version ?? null }; + +/** + * Every configured plugin once, keyed by id and sorted by it: the API's own + * registrations merged with the frontend inventory the web build generated. + * + * Built once at boot - the configured plugins cannot change without a restart - + * and from nothing but those two lists, so `node_modules` is never scanned and + * a package that is installed but not configured is not listed. + * + * A side without metadata is an unknown version, never a mismatch: only two + * known, different versions mean the web build and the API run different code. + */ +export const buildInstalledPluginRegistry = ({ + api, + frontend, +}: { + api: readonly { metadata?: PluginMetadata; pluginId: string }[]; + frontend: FrontendPluginInventory | undefined; +}): InstalledPluginRegistry => { + const apiPlugins = byId( + api.map(plugin => ({ + id: plugin.pluginId, + metadata: plugin.metadata ?? null, + })), + "API", + ); + const frontendPlugins = byId(frontend ?? [], "frontend"); + const ids = [...new Set([...apiPlugins.keys(), ...frontendPlugins.keys()])]; + ids.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + + return { + frontendInventory: frontend !== undefined, + plugins: ids.map(id => { + const fromApi = apiPlugins.get(id); + const fromFrontend = frontendPlugins.get(id); + const apiVersion = fromApi?.metadata?.version ?? null; + const frontendVersion = fromFrontend?.metadata?.version ?? null; + const metadata = fromApi?.metadata ?? fromFrontend?.metadata ?? null; + + return { + description: metadata?.description ?? null, + id, + registrations: { + api: registrationOf(fromApi), + frontend: registrationOf(fromFrontend), + }, + title: metadata?.title ?? id, + version: apiVersion ?? frontendVersion, + versionMismatch: + apiVersion !== null && + frontendVersion !== null && + apiVersion !== frontendVersion, + }; + }), + }; +}; diff --git a/packages/vitnode/src/api/lib/plugin.test.ts b/packages/vitnode/src/api/lib/plugin.test.ts index 379de31d82..c23dc7d663 100644 --- a/packages/vitnode/src/api/lib/plugin.test.ts +++ b/packages/vitnode/src/api/lib/plugin.test.ts @@ -255,3 +255,21 @@ describe("buildApiPlugin public content URLs", () => { ).toEqual([]); }); }); + +describe("buildApiPlugin metadata", () => { + it("refuses metadata generated for another package", () => { + expect(() => + buildApiPlugin({ + metadata: { + description: null, + id: "@vitnode/blog", + title: "Blog", + version: "1.0.0", + }, + pluginId: "@vitnode/example", + }), + ).toThrow( + /^\[VitNode plugin metadata\] buildApiPlugin\(\) registers the plugin "@vitnode\/example" with the metadata of "@vitnode\/blog"/, + ); + }); +}); diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index a1b441f015..be6647e8c7 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -9,6 +9,7 @@ import type { AnyContentTypeDefinition } from "@/content/types"; import type { ApiPluginContract } from "@/lib/fetcher/contract"; import type { LocaleMessagesMap } from "@/lib/i18n/types"; import type { NavigationPresetDeclaration } from "@/lib/navigation"; +import type { PluginMetadata } from "@/lib/plugin-metadata"; import { BlockError } from "@/blocks/errors"; import { @@ -24,6 +25,7 @@ import { } from "@/content/registry"; import { contentDefinitionOf } from "@/content/server/model"; import { contentReportTarget } from "@/content/server/report-target"; +import { assertPluginMetadataId } from "@/lib/plugin-metadata"; import type { SearchIndexer } from "../models/search"; import type { AnyAiActionDefinition } from "./ai/action"; @@ -64,6 +66,8 @@ export interface BuildPluginApiReturn< events?: Omit[]; hono: OpenAPIHono; messages?: LocaleMessagesMap; + /** What the AdminCP plugin catalog lists - see `buildApiPlugin({ metadata })`. */ + metadata?: PluginMetadata; moderation?: Required; modules: Modules; navigation?: NavigationPresetDeclaration[]; @@ -91,6 +95,7 @@ export function buildApiPlugin< editablePages, pluginId, messages, + metadata, moderation, modules = [] as unknown as Modules, navigation, @@ -102,6 +107,12 @@ export function buildApiPlugin< blocks?: BlockPluginSource; editablePages?: AnyEditablePageDefinition[]; messages?: LocaleMessagesMap; + /** + * `pluginMetadata` from the plugin's generated `src/plugin-metadata.gen.ts`: + * the title, description and installed version AdminCP → System → Plugins + * lists. Without it the plugin is listed by id with an unknown version. + */ + metadata?: PluginMetadata; moderation?: ModerationPluginConfig; modules?: Modules; navigation?: NavigationPresetDeclaration[]; @@ -112,6 +123,7 @@ export function buildApiPlugin< }): BuildPluginApiReturn { // Run for checking if the plugin is valid checkPluginId(pluginId); + assertPluginMetadataId(pluginId, metadata, "buildApiPlugin()"); if (blocks && blocks.pluginId !== pluginId) { throw new BlockError( @@ -222,6 +234,7 @@ export function buildApiPlugin< blocks, editablePages: registeredPages.map(entry => entry.page), messages, + metadata, modules, navigation, hono, diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 95a3c372da..6b623a62d3 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -50,6 +50,7 @@ import type { AiActionRegistry } from "../lib/ai/registry"; import type { BuildCronReturn } from "../lib/cron"; import type { RegisteredEditablePage } from "../lib/editable-pages"; import type { EventListenerConfig } from "../lib/events"; +import type { InstalledPluginRegistry } from "../lib/installed-plugins"; import type { ModerationRegistry } from "../lib/moderation/registry"; import type { NotificationWorkerSettings } from "../lib/notifications/preferences"; import type { NotificationRegistry } from "../lib/notifications/registry"; @@ -73,6 +74,7 @@ import { registerEditablePage, validateEditablePages, } from "../lib/editable-pages"; +import { buildInstalledPluginRegistry } from "../lib/installed-plugins"; import { loggerMiddleware, type LoggerMiddlewareType, @@ -162,6 +164,8 @@ export interface EnvVariablesVitNode { locales: LocaleConfig[]; sources: MessagesSource[]; }; + /** Every configured plugin once, for AdminCP → System → Plugins. */ + installedPlugins: InstalledPluginRegistry; metadata: { shortTitle?: string; title: string; @@ -228,6 +232,7 @@ export const globalMiddleware = ({ captcha, cron, events, + frontendPlugins, plugins, i18n, notifications, @@ -245,6 +250,7 @@ export const globalMiddleware = ({ | "dbProvider" | "email" | "events" + | "frontendPlugins" | "i18n" | "notifications" | "plugins" @@ -259,6 +265,10 @@ export const globalMiddleware = ({ const pluginsMetadata = plugins.map(plugin => ({ id: plugin.pluginId, })); + const installedPlugins = buildInstalledPluginRegistry({ + api: plugins, + frontend: frontendPlugins, + }); // Resolved once at boot: the packages installed here can't change per // request. With no `i18n` block the locale list is whatever the installed @@ -523,6 +533,7 @@ export const globalMiddleware = ({ cronSecret: CONFIG.cronJobSecret, hasCronAdapter: !!cron, plugins: pluginsMetadata, + installedPlugins, cron: cronMetadata, editablePages: editablePagesMetadata, queue: queueMetadata, diff --git a/packages/vitnode/src/api/modules/admin/admin.module.ts b/packages/vitnode/src/api/modules/admin/admin.module.ts index b89d203a6c..166d8bffbf 100644 --- a/packages/vitnode/src/api/modules/admin/admin.module.ts +++ b/packages/vitnode/src/api/modules/admin/admin.module.ts @@ -8,6 +8,7 @@ import { debugAdminModule } from "./debug/debug.admin.module"; import { filesAdminModule } from "./files/files.admin.module"; import { navigationAdminModule } from "./navigation/navigation.admin.module"; import { notificationsAdminModule } from "./notifications/notifications.admin.module"; +import { pluginsAdminModule } from "./plugins/plugins.admin.module"; import { rolesAdminModule } from "./roles/roles.admin.module"; import { sendNotificationRoute } from "./routes/notifications.route"; import { sessionAdminRoute } from "./routes/session.route"; @@ -29,6 +30,7 @@ export const adminModule = buildModule({ dashboardAdminModule, navigationAdminModule, notificationsAdminModule, + pluginsAdminModule, ], cronJobs: [], }); diff --git a/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts b/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts new file mode 100644 index 0000000000..d766e69aee --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/plugins/plugins.admin.module.ts @@ -0,0 +1,10 @@ +import { buildModule } from "@/api/lib/module"; +import { CONFIG_PLUGIN } from "@/config"; + +import { listPluginsAdminRoute } from "./routes/list.route"; + +export const pluginsAdminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "plugins", + routes: [listPluginsAdminRoute], +}); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/list.route.test.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/list.route.test.ts new file mode 100644 index 0000000000..8201762cd0 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/list.route.test.ts @@ -0,0 +1,80 @@ +import type { Context } from "hono"; + +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it } from "vitest"; + +import type { PermissionsStaffArgs } from "@/api/lib/permission-staff"; + +import { buildInstalledPluginRegistry } from "@/api/lib/installed-plugins"; +import { CONFIG_PLUGIN } from "@/config"; +import { createTestCache } from "@/tests/cache"; +import { grantStaffPermissions } from "@/tests/staff-permissions"; + +import { listPluginsAdminRoute } from "./list.route"; + +const ADMIN = { id: 1, roleId: 1 }; + +const installedPlugins = buildInstalledPluginRegistry({ + api: [ + { + metadata: { + description: "Posts and categories", + id: "@acme/blog", + title: "Blog", + version: "2.0.0", + }, + pluginId: "@acme/blog", + }, + ], + frontend: [ + { + id: "@acme/blog", + metadata: { + description: "Posts and categories", + id: "@acme/blog", + title: "Blog", + version: "1.0.0", + }, + }, + { id: "@acme/analytics", metadata: null }, + ], +}); + +const listAs = async (permissions: PermissionsStaffArgs[]) => { + const cache = createTestCache(); + await grantStaffPermissions(cache, { permissions, userId: ADMIN.id }); + + const app = new OpenAPIHono(); + app.use("*", async (c, next) => { + c.set("admin", { user: ADMIN } as unknown as Context["var"]["admin"]); + c.set("cache", cache); + c.set("core", { installedPlugins } as unknown as Context["var"]["core"]); + await next(); + }); + app.openapi(listPluginsAdminRoute.route, listPluginsAdminRoute.handler); + + return await app.request("/"); +}; + +describe("GET /admin/plugins", () => { + it("lists the installed plugins and the core version", async () => { + const response = await listAs([ + { module: "plugins", permission: "can_view", plugin: "@vitnode/core" }, + ]); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + core: { version: CONFIG_PLUGIN.version }, + frontendInventory: true, + plugins: installedPlugins.plugins, + }); + }); + + it("refuses an administrator without the plugins permission", async () => { + const response = await listAs([ + { module: "system", permission: "can_view", plugin: "@vitnode/core" }, + ]); + + expect(response.status).toBe(403); + }); +}); diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/list.route.ts new file mode 100644 index 0000000000..e9d377d5d1 --- /dev/null +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/list.route.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +import { buildRoute } from "@/api/lib/route"; +import { CONFIG_PLUGIN } from "@/config"; + +const registrationSchema = z + .object({ + /** `null` when this side registered the plugin without a known version. */ + version: z.string().nullable(), + }) + .nullable(); + +export const listPluginsAdminRoute = buildRoute({ + pluginId: CONFIG_PLUGIN.pluginId, + adminStaffPermission: { module: "plugins", permission: "can_view" }, + route: { + method: "get", + description: + "The plugins this application is configured with, from its build-generated metadata", + path: "/", + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + core: z.object({ version: z.string() }), + /** Whether the API was given the web build's plugin inventory. */ + frontendInventory: z.boolean(), + plugins: z.array( + z.object({ + id: z.string(), + title: z.string(), + description: z.string().nullable(), + version: z.string().nullable(), + versionMismatch: z.boolean(), + registrations: z.object({ + api: registrationSchema, + frontend: registrationSchema, + }), + }), + ), + }), + }, + }, + description: "Installed plugins", + }, + 403: { description: "Access Denied" }, + }, + }, + handler: c => { + const { frontendInventory, plugins } = c.get("core").installedPlugins; + + return c.json({ + core: { version: CONFIG_PLUGIN.version }, + frontendInventory, + plugins, + }); + }, +}); diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts index edc0fe3c7f..7dce021280 100644 --- a/packages/vitnode/src/api/plugin.ts +++ b/packages/vitnode/src/api/plugin.ts @@ -100,6 +100,7 @@ export const newBuildPluginApiCore = buildApiPlugin({ { permission: "can_delete", dependsOn: ["can_view"] }, ], queue: ["can_view"], + plugins: ["can_view"], notifications: [ "can_view", { permission: "can_edit", dependsOn: ["can_view"] }, diff --git a/packages/vitnode/src/framework/plugin-inventory/generate.test.ts b/packages/vitnode/src/framework/plugin-inventory/generate.test.ts new file mode 100644 index 0000000000..21f33765c2 --- /dev/null +++ b/packages/vitnode/src/framework/plugin-inventory/generate.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import type { FrontendPluginInventoryEntry } from "../../lib/plugin-metadata.js"; + +import { + generatePluginInventorySource, + pluginInventoryFromLoadedConfig, +} from "./generate.js"; + +const SOURCE = "src/vitnode.config.ts"; + +const BLOG_METADATA = { + description: "Posts", + id: "@acme/blog", + title: "Blog", + version: "1.0.0", +}; + +describe("pluginInventoryFromLoadedConfig", () => { + it("lists each registration with its metadata, or null without one", () => { + expect( + pluginInventoryFromLoadedConfig( + [ + { metadata: BLOG_METADATA, pluginId: "@acme/blog", routes: {} }, + { pluginId: "@acme/shop" }, + ], + SOURCE, + ), + ).toEqual([ + { id: "@acme/blog", metadata: BLOG_METADATA }, + { id: "@acme/shop", metadata: null }, + ]); + }); + + it.each([ + ["a string", "Blog"], + ["a missing title", { ...BLOG_METADATA, title: undefined }], + ["a numeric version", { ...BLOG_METADATA, version: 1 }], + ["an undefined description", { ...BLOG_METADATA, description: undefined }], + ])("rejects metadata that is %s", (_, metadata) => { + expect(() => + pluginInventoryFromLoadedConfig( + [{ metadata, pluginId: "@acme/blog" }], + SOURCE, + ), + ).toThrow(/vitNodeConfig.plugins\[0\].metadata.*is not plugin metadata/); + }); + + it("rejects metadata generated for another package", () => { + expect(() => + pluginInventoryFromLoadedConfig( + [{ metadata: BLOG_METADATA, pluginId: "@acme/shop" }], + SOURCE, + ), + ).toThrow( + /registers the plugin "@acme\/shop" with the metadata of "@acme\/blog"/, + ); + }); +}); + +describe("generatePluginInventorySource", () => { + it("writes the same bytes whatever order the plugins are configured in", () => { + const blog: FrontendPluginInventoryEntry = { + id: "@acme/blog", + metadata: BLOG_METADATA, + }; + const shop: FrontendPluginInventoryEntry = { + id: "@acme/shop", + metadata: null, + }; + const forum: FrontendPluginInventoryEntry = { + id: "@acme/forum", + metadata: null, + }; + + const source = generatePluginInventorySource([shop, blog, forum]); + + expect(generatePluginInventorySource([forum, shop, blog])).toBe(source); + expect(source.indexOf("@acme/blog")).toBeLessThan( + source.indexOf("@acme/forum"), + ); + expect(source.indexOf("@acme/forum")).toBeLessThan( + source.indexOf("@acme/shop"), + ); + }); +}); diff --git a/packages/vitnode/src/framework/plugin-inventory/generate.ts b/packages/vitnode/src/framework/plugin-inventory/generate.ts new file mode 100644 index 0000000000..d78479abd7 --- /dev/null +++ b/packages/vitnode/src/framework/plugin-inventory/generate.ts @@ -0,0 +1,118 @@ +import type { + FrontendPluginInventoryEntry, + PluginMetadata, +} from "../../lib/plugin-metadata.js"; + +import { assertPluginMetadataId } from "../../lib/plugin-metadata.js"; +import { PLUGIN_ROUTES_ERROR_PREFIX as ERROR_PREFIX } from "../plugin-routes/diagnostics.js"; + +/** Where the plugin metadata a configured plugin registers comes from. */ +export const PLUGIN_METADATA_SUBPATH = "plugin-metadata.gen"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const isNullableString = (value: unknown): value is null | string => + value === null || typeof value === "string"; + +const metadataFrom = ( + value: unknown, + pluginId: string, + where: string, +): null | PluginMetadata => { + if (value === undefined) return null; + + if ( + !isRecord(value) || + typeof value.id !== "string" || + typeof value.title !== "string" || + !isNullableString(value.description) || + !isNullableString(value.version) + ) { + throw new Error( + `${ERROR_PREFIX} ${where} is not plugin metadata. Pass \`pluginMetadata\` from the plugin's generated \`src/plugin-metadata.gen.ts\`.`, + ); + } + + const metadata: PluginMetadata = { + description: value.description, + id: value.id, + title: value.title, + version: value.version, + }; + assertPluginMetadataId(pluginId, metadata, where); + + return metadata; +}; + +/** + * Every plugin the app's loaded `vitnode.config.ts` registers, with the + * metadata its registration carries - and nothing else from it. + */ +export const pluginInventoryFromLoadedConfig = ( + plugins: readonly unknown[], + source: string, +): FrontendPluginInventoryEntry[] => + plugins.flatMap((plugin, index) => { + if (!isRecord(plugin) || typeof plugin.pluginId !== "string") return []; + + return [ + { + id: plugin.pluginId, + metadata: metadataFrom( + plugin.metadata, + plugin.pluginId, + `\`vitNodeConfig.plugins[${String(index)}].metadata\` in ${source}`, + ), + }, + ]; + }); + +const HEADER = `/* oxlint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by the \`vitnode:plugin-routes\` Vite plugin on every +// \`vite dev\` and \`vite build\`, from the plugins configured in +// \`src/vitnode.config.ts\` and the package metadata each one registers. +// +// Plain data and nothing else: no plugin code is imported, so the API can read +// it without loading the frontend. Pass it to +// \`buildApiConfig({ frontendPlugins })\` so AdminCP → System → Plugins lists +// what this web build runs, frontend-only plugins included. +// +// Same plugin configuration in, same bytes out: the entries are sorted by +// plugin id. +`; + +const literal = (value: null | string): string => + value === null ? "null" : JSON.stringify(value); + +const entrySource = ({ id, metadata }: FrontendPluginInventoryEntry): string => + metadata === null + ? ` { id: ${literal(id)}, metadata: null },\n` + : ` { + id: ${literal(id)}, + metadata: { + id: ${literal(metadata.id)}, + title: ${literal(metadata.title)}, + description: ${literal(metadata.description)}, + version: ${literal(metadata.version)}, + }, + }, +`; + +export const generatePluginInventorySource = ( + entries: readonly FrontendPluginInventoryEntry[], +): string => { + const sorted = [...entries].sort((a, b) => + a.id === b.id ? 0 : a.id < b.id ? -1 : 1, + ); + + return `${HEADER} +import type { FrontendPluginInventory } from '@vitnode/core/lib/plugin-metadata' + +export const frontendPlugins: FrontendPluginInventory = [ +${sorted.map(entrySource).join("")}] +`; +}; diff --git a/packages/vitnode/src/framework/plugin-inventory/index.ts b/packages/vitnode/src/framework/plugin-inventory/index.ts new file mode 100644 index 0000000000..018515bfbd --- /dev/null +++ b/packages/vitnode/src/framework/plugin-inventory/index.ts @@ -0,0 +1,5 @@ +export { + generatePluginInventorySource, + PLUGIN_METADATA_SUBPATH, + pluginInventoryFromLoadedConfig, +} from "./generate.js"; diff --git a/packages/vitnode/src/framework/plugin-metadata/generate.test.ts b/packages/vitnode/src/framework/plugin-metadata/generate.test.ts new file mode 100644 index 0000000000..64bdd787a0 --- /dev/null +++ b/packages/vitnode/src/framework/plugin-metadata/generate.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; + +import { + assertIconPath, + PLUGIN_ICON_MAX_BYTES, + pluginIconDataUrl, + pluginMetadataFromManifest, +} from "./generate.js"; + +const SOURCE = "package.json"; + +const bytes = (...values: number[]) => new Uint8Array(values); +const text = (value: string) => new TextEncoder().encode(value); + +const WEBP = new Uint8Array([ + ...text("RIFF"), + 0x24, + 0x00, + 0x00, + 0x00, + ...text("WEBP"), +]); + +describe("pluginMetadataFromManifest", () => { + it("reads the catalog fields, trimmed, with the declared icon path", () => { + expect( + pluginMetadataFromManifest( + { + description: " Posts and categories ", + name: "@acme/blog", + version: "1.2.0", + vitnode: { icon: "./assets/icon.svg", title: " Blog " }, + }, + SOURCE, + ), + ).toEqual({ + icon: "./assets/icon.svg", + metadata: { + description: "Posts and categories", + id: "@acme/blog", + title: "Blog", + version: "1.2.0", + }, + }); + }); + + it.each([ + ["no vitnode field", { name: "@acme/blog" }], + [ + "blank description and version", + { description: " ", name: "@acme/blog", version: "" }, + ], + [ + "null optional fields", + { description: null, name: "@acme/blog", version: null }, + ], + ])("falls back to the id and unknown values with %s", (_, manifest) => { + expect(pluginMetadataFromManifest(manifest, SOURCE)).toEqual({ + icon: null, + metadata: { + description: null, + id: "@acme/blog", + title: "@acme/blog", + version: null, + }, + }); + }); + + it.each([ + ["a non-object manifest", ["@acme/blog"], /is not a JSON object/], + ["a missing name", { version: "1.0.0" }, /has no `name`/], + ["a blank name", { name: " " }, /has no `name`/], + [ + "a non-string version", + { name: "@acme/blog", version: 1 }, + /`version`.*has to be a string/, + ], + [ + "a non-object vitnode field", + { name: "@acme/blog", vitnode: "Blog" }, + /`vitnode`.*has to be an object/, + ], + [ + "an array vitnode field", + { name: "@acme/blog", vitnode: [] }, + /`vitnode`.*has to be an object/, + ], + [ + "an empty title", + { name: "@acme/blog", vitnode: { title: " " } }, + /`vitnode.title`.*is empty/, + ], + [ + "an empty icon", + { name: "@acme/blog", vitnode: { icon: "" } }, + /`vitnode.icon`.*is empty/, + ], + [ + "a URL icon", + { name: "@acme/blog", vitnode: { icon: "https://x.dev/a.svg" } }, + /a URL/, + ], + ])("rejects %s", (_, manifest, error) => { + expect(() => pluginMetadataFromManifest(manifest, SOURCE)).toThrow(error); + }); + + it("names the file and the metadata prefix in its errors", () => { + expect(() => + pluginMetadataFromManifest({}, "plugins/blog/package.json"), + ).toThrow(/^\[VitNode plugin metadata\] plugins\/blog\/package.json/); + }); +}); + +describe("assertIconPath", () => { + it.each([ + "./assets/icon.svg", + "assets/icon.png", + "icon.webp", + "./icon.jpg", + "./ICON.JPEG", + ])("accepts %s", path => { + expect(() => { + assertIconPath(path, SOURCE); + }).not.toThrow(); + }); + + it.each([ + ["https://cdn.example.com/icon.svg", /a URL/], + ["data:image/svg+xml;base64,PHN2Zz4=", /a URL/], + ["file:///tmp/icon.svg", /a URL/], + ["//cdn.example.com/icon.svg", /a URL/], + ["/etc/icon.svg", /absolute path/], + ["\\icons\\icon.svg", /absolute path/], + ["./assets/icon.gif", /Supported icon formats/], + ["./assets/icon", /Supported icon formats/], + ])("rejects %s", (path, error) => { + expect(() => { + assertIconPath(path, SOURCE); + }).toThrow(error); + }); +}); + +describe("pluginIconDataUrl", () => { + it.each([ + [ + "icon.svg", + text(''), + "image/svg+xml", + ], + ["icon.png", bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a), "image/png"], + ["icon.jpg", bytes(0xff, 0xd8, 0xff, 0xe0), "image/jpeg"], + ["icon.jpeg", bytes(0xff, 0xd8, 0xff, 0xe0), "image/jpeg"], + ["icon.webp", WEBP, "image/webp"], + ])("inlines %s as a %s data URL", (path, content, type) => { + expect(pluginIconDataUrl(path, content, SOURCE)).toBe( + `data:${type};base64,${Buffer.from(content).toString("base64")}`, + ); + }); + + it.each([ + ["icon.svg", text("")], + ["icon.png", text('')], + ["icon.jpg", bytes(0x89, 0x50, 0x4e, 0x47)], + [ + "icon.webp", + new Uint8Array([...text("RIFF"), 0, 0, 0, 0, ...text("WAVE")]), + ], + ])("refuses %s whose bytes are another format", (path, content) => { + expect(() => pluginIconDataUrl(path, content, SOURCE)).toThrow( + /is not a valid/, + ); + }); + + it("refuses an icon over the size limit, and keeps one at it", () => { + const svg = (size: number) => { + const content = new Uint8Array(size).fill(0x20); + content.set(text("")); + + return content; + }; + + expect(() => + pluginIconDataUrl("icon.svg", svg(PLUGIN_ICON_MAX_BYTES + 1), SOURCE), + ).toThrow(/bytes/); + expect( + pluginIconDataUrl("icon.svg", svg(PLUGIN_ICON_MAX_BYTES), SOURCE), + ).toMatch(/^data:image\/svg\+xml;base64,/); + }); +}); diff --git a/packages/vitnode/src/framework/plugin-metadata/generate.ts b/packages/vitnode/src/framework/plugin-metadata/generate.ts new file mode 100644 index 0000000000..00d47271ef --- /dev/null +++ b/packages/vitnode/src/framework/plugin-metadata/generate.ts @@ -0,0 +1,239 @@ +import type { PluginMetadata } from "../../lib/plugin-metadata.js"; + +import { PLUGIN_METADATA_ERROR_PREFIX as ERROR_PREFIX } from "../../lib/plugin-metadata.js"; + +/** Where `vitnode build` and `vitnode dev` write a plugin's metadata, relative to its root. */ +export const PLUGIN_METADATA_PATH = "src/plugin-metadata.gen.ts"; + +/** The browser-only twin: the icon, as a `data:` URL the AdminCP lazy-loads. */ +export const PLUGIN_ICON_PATH = "src/plugin-icon.gen.ts"; + +/** + * The largest icon a plugin may declare, in bytes. + * + * The icon is inlined into the generated module, so it travels with the + * package wherever it is installed - no asset pipeline, no `node_modules` path + * for a server to read. Small is the point: it is shown at 40px. + */ +export const PLUGIN_ICON_MAX_BYTES = 32 * 1024; + +const ICON_TYPES: Readonly> = { + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +}; + +export const PLUGIN_ICON_EXTENSIONS = Object.keys(ICON_TYPES); + +/** What a plugin's `package.json` declares, validated and with defaults applied. */ +export interface PluginManifestMetadata { + /** The declared icon path, exactly as written, or `null` for none. */ + icon: null | string; + metadata: PluginMetadata; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const optionalText = ( + value: unknown, + field: string, + source: string, +): null | string => { + if (value === undefined || value === null) return null; + if (typeof value !== "string") { + throw new Error( + `${ERROR_PREFIX} \`${field}\` in ${source} has to be a string.`, + ); + } + + const trimmed = value.trim(); + + return trimmed === "" ? null : trimmed; +}; + +/** + * Reads the catalog's fields out of a parsed `package.json`. + * + * `name` is required - it is the plugin id. Everything else is optional and + * falls back to what the AdminCP can honestly show: the id as the title, and + * `null` for a description or version the package does not declare. + */ +export const pluginMetadataFromManifest = ( + manifest: unknown, + source: string, +): PluginManifestMetadata => { + if (!isRecord(manifest)) { + throw new Error(`${ERROR_PREFIX} ${source} is not a JSON object.`); + } + + const id = optionalText(manifest.name, "name", source); + if (id === null) { + throw new Error( + `${ERROR_PREFIX} ${source} has no \`name\`. A plugin's id is its package name, so it needs one.`, + ); + } + + const { vitnode } = manifest; + if (vitnode !== undefined && !isRecord(vitnode)) { + throw new Error( + `${ERROR_PREFIX} \`vitnode\` in ${source} has to be an object - for example \`{ "title": "Blog", "icon": "./assets/icon.svg" }\`.`, + ); + } + + const title = optionalText(vitnode?.title, "vitnode.title", source); + if (vitnode?.title !== undefined && title === null) { + throw new Error( + `${ERROR_PREFIX} \`vitnode.title\` in ${source} is empty. Remove it to fall back to the package name.`, + ); + } + + const icon = optionalText(vitnode?.icon, "vitnode.icon", source); + if (vitnode?.icon !== undefined && icon === null) { + throw new Error( + `${ERROR_PREFIX} \`vitnode.icon\` in ${source} is empty. Remove it to use the default icon.`, + ); + } + if (icon !== null) assertIconPath(icon, source); + + return { + icon, + metadata: { + description: optionalText(manifest.description, "description", source), + id, + title: title ?? id, + version: optionalText(manifest.version, "version", source), + }, + }; +}; + +const extensionOf = (path: string): string => { + const dot = path.lastIndexOf("."); + + return dot === -1 ? "" : path.slice(dot).toLowerCase(); +}; + +/** + * The checks an icon path passes before anything is read: a relative path to a + * supported image. Whether it stays inside the package is decided against the + * file system by the caller, which can follow symlinks. + */ +export const assertIconPath = (path: string, source: string): void => { + if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith("//")) { + throw new Error( + `${ERROR_PREFIX} \`vitnode.icon\` in ${source} is ${JSON.stringify(path)}, a URL. Point it at an image file inside the package, such as "./assets/icon.svg" - VitNode never fetches icons.`, + ); + } + + if (path.startsWith("/") || path.startsWith("\\")) { + throw new Error( + `${ERROR_PREFIX} \`vitnode.icon\` in ${source} is the absolute path ${JSON.stringify(path)}. Use a path relative to package.json, such as "./assets/icon.svg".`, + ); + } + + if (!(extensionOf(path) in ICON_TYPES)) { + throw new Error( + `${ERROR_PREFIX} \`vitnode.icon\` in ${source} is ${JSON.stringify(path)}. Supported icon formats are ${PLUGIN_ICON_EXTENSIONS.join(", ")}.`, + ); + } +}; + +const startsWith = (bytes: Uint8Array, signature: readonly number[]) => + signature.every((byte, index) => bytes[index] === byte); + +const looksLike = (extension: string, bytes: Uint8Array): boolean => { + switch (extension) { + case ".jpeg": + case ".jpg": + return startsWith(bytes, [0xff, 0xd8, 0xff]); + case ".png": + return startsWith(bytes, [0x89, 0x50, 0x4e, 0x47]); + case ".svg": + return /]/i.test(new TextDecoder().decode(bytes)); + case ".webp": + return ( + startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && + new TextDecoder().decode(bytes.subarray(8, 12)) === "WEBP" + ); + default: + return false; + } +}; + +/** + * The icon's bytes as a `data:` URL, after checking they are what the + * extension claims and small enough to inline. + * + * Rendered with ``, never as markup, so an SVG's scripts and external + * references cannot run in the AdminCP. + */ +export const pluginIconDataUrl = ( + path: string, + bytes: Uint8Array, + source: string, +): string => { + if (bytes.byteLength > PLUGIN_ICON_MAX_BYTES) { + throw new Error( + `${ERROR_PREFIX} The icon ${JSON.stringify(path)} declared in ${source} is ${String(bytes.byteLength)} bytes. Icons are inlined into the build, so keep them under ${String(PLUGIN_ICON_MAX_BYTES / 1024)} KB - an SVG usually is.`, + ); + } + + const extension = extensionOf(path); + if (!looksLike(extension, bytes)) { + throw new Error( + `${ERROR_PREFIX} The icon ${JSON.stringify(path)} declared in ${source} is not a valid ${extension.slice(1).toUpperCase()} file.`, + ); + } + + return `data:${ICON_TYPES[extension]};base64,${Buffer.from(bytes).toString("base64")}`; +}; + +const HEADER = `/* oxlint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by \`vitnode build\` and \`vitnode dev\` from this package's +// package.json: \`name\`, \`version\`, \`description\` and the \`vitnode\` field. +`; + +const literal = (value: null | string): string => + value === null ? "null" : JSON.stringify(value); + +/** + * `src/plugin-metadata.gen.ts` - what the AdminCP plugin catalog lists. + * + * Plain data, imported by both registrations - `buildPlugin({ metadata })` and + * `buildApiPlugin({ metadata })` - so neither copies a version by hand. + */ +export const generatePluginMetadataSource = ( + metadata: PluginMetadata, +): string => `${HEADER}// +// Register \`pluginMetadata\` as \`metadata\` in both \`buildPlugin\` and +// \`buildApiPlugin\`. + +export const pluginMetadata = { + id: ${literal(metadata.id)}, + title: ${literal(metadata.title)}, + description: ${literal(metadata.description)}, + version: ${literal(metadata.version)}, +} as const +`; + +/** + * `src/plugin-icon.gen.ts` - the icon, for the browser only. + * + * Written even when no icon is declared, as `null`, so removing the icon never + * breaks the `import()` that loads it. + */ +export const generatePluginIconSource = ( + dataUrl: null | string, +): string => `${HEADER}// +// Loaded lazily by \`buildPlugin({ icon: () => import("./plugin-icon.gen") })\`, +// so the bytes reach a browser only on the AdminCP screen that shows them. + +const icon: null | string = ${literal(dataUrl)} + +export default icon +`; diff --git a/packages/vitnode/src/framework/plugin-metadata/index.ts b/packages/vitnode/src/framework/plugin-metadata/index.ts new file mode 100644 index 0000000000..2ecb44ec31 --- /dev/null +++ b/packages/vitnode/src/framework/plugin-metadata/index.ts @@ -0,0 +1,12 @@ +export { + assertIconPath, + generatePluginIconSource, + generatePluginMetadataSource, + PLUGIN_ICON_EXTENSIONS, + PLUGIN_ICON_MAX_BYTES, + PLUGIN_ICON_PATH, + PLUGIN_METADATA_PATH, + pluginIconDataUrl, + pluginMetadataFromManifest, +} from "./generate.js"; +export type { PluginManifestMetadata } from "./generate.js"; diff --git a/packages/vitnode/src/framework/vite/plugin-routes.ts b/packages/vitnode/src/framework/vite/plugin-routes.ts index 07fb241a7b..c15b5a9872 100644 --- a/packages/vitnode/src/framework/vite/plugin-routes.ts +++ b/packages/vitnode/src/framework/vite/plugin-routes.ts @@ -14,6 +14,7 @@ import { } from "node:path"; import { pathToFileURL } from "node:url"; +import type { FrontendPluginInventoryEntry } from "../../lib/plugin-metadata"; import type { ResolvedPageCacheConfig } from "../../page-cache/config"; import type { ResolvedPageCacheRoute } from "../../page-cache/resolve"; import type { ResolvedAdminNavModule } from "../admin-nav"; @@ -45,6 +46,11 @@ import { generatePackageMessagesSource, resolvePackageMessagesModules, } from "../package-messages"; +import { + generatePluginInventorySource, + PLUGIN_METADATA_SUBPATH, + pluginInventoryFromLoadedConfig, +} from "../plugin-inventory"; import { compilePluginRoutes, contentTypesFromContentModule, @@ -107,6 +113,8 @@ const pathsFor = (appRoot: string) => ({ pageCache: join(appRoot, "src", "page-cache.gen.ts"), + pluginInventory: join(appRoot, "src", "plugin-inventory.gen.ts"), + registry: join(appRoot, "src", "plugin-routes.gen.ts"), staleManifest: join(appRoot, "src", "plugin-route-manifest.gen.ts"), @@ -130,6 +138,7 @@ export const resolverFor = (appRoot: string) => { interface AppConfig { i18n: LocaleRoutePathsConfig | undefined; + inventory: FrontendPluginInventoryEntry[]; pageCache: ResolvedPageCacheConfig; plugins: PackageMessagesSource[]; } @@ -143,11 +152,19 @@ const readAppConfig = async ( moduleCache: false, }); const loaded = await jiti.import(configPath); + const source = relative(appRoot, configPath); + const plugins = pluginsFromLoadedConfig(loaded, source); return { i18n: i18nFromLoadedConfig(loaded), + // Read only once `pluginsFromLoadedConfig` has proven the list is an array. + inventory: pluginInventoryFromLoadedConfig( + (loaded as { vitNodeConfig: { plugins: unknown[] } }).vitNodeConfig + .plugins, + source, + ), pageCache: pageCacheConfigFromLoadedConfig(loaded), - plugins: pluginsFromLoadedConfig(loaded, relative(appRoot, configPath)), + plugins, }; }; @@ -472,7 +489,7 @@ const discover = async ( ) => { const paths = pathsFor(appRoot); const resolvePackageFile = resolverFor(appRoot); - const { i18n, pageCache, plugins } = await readAppConfig( + const { i18n, inventory, pageCache, plugins } = await readAppConfig( appRoot, paths.config, ); @@ -521,6 +538,12 @@ const discover = async ( WIDGETS_SUBPATH, resolvePackageFile, ); + // Watched only: the inventory is read from the loaded config, and a plugin's + // rebuilt metadata module is what changes it while the dev server runs. + const pluginMetadata = readOptionalPluginModules<{ + pluginId: string; + specifier: string; + }>(pluginIds, PLUGIN_METADATA_SUBPATH, resolvePackageFile); const remainingPluginIds = pluginIds.filter( id => !pluginWidgets.modules.some(m => m.pluginId === id), ); @@ -542,6 +565,7 @@ const discover = async ( ...coreBlocks.watch, ...pluginBlocks.watch, ...contentModules.map(({ file }) => file), + ...pluginMetadata.watch, ]; onLoaded?.(watch); @@ -590,6 +614,7 @@ const discover = async ( contentRegistry: contentRegistry.modules, hostRoutes, i18n, + inventory, packageMessages, pageCache, pageCacheRoutes, @@ -618,7 +643,7 @@ type DiscoveredRoutes = Omit< pageCacheRoutes: ResolvedPageCacheRoute[]; }; -/** All eight generated files, from one discovery pass. */ +/** All nine generated files, from one discovery pass. */ const writeGenerated = async ( appRoot: string, options: VitNodePluginRoutesOptions, @@ -634,6 +659,7 @@ const writeGenerated = async ( contentRegistry, hostRoutes, i18n, + inventory, packageMessages, pageCache, pageCacheRoutes, @@ -663,6 +689,10 @@ const writeGenerated = async ( paths.pageCache, generatePageCacheSource(pageCacheRouteTable(pageCacheRoutes)), ), + writeIfChanged( + paths.pluginInventory, + generatePluginInventorySource(inventory), + ), removeIfPresent(paths.staleManifest), ]); diff --git a/packages/vitnode/src/lib/plugin-metadata.ts b/packages/vitnode/src/lib/plugin-metadata.ts new file mode 100644 index 0000000000..025a96af93 --- /dev/null +++ b/packages/vitnode/src/lib/plugin-metadata.ts @@ -0,0 +1,60 @@ +/** + * What a plugin's `package.json` says about it, as `vitnode build` and + * `vitnode dev` write it into the plugin's `src/plugin-metadata.gen.ts`. + * + * Plain data on purpose: the API and the browser both import it, so it carries + * no React, no Node and no image - the icon travels separately, see + * {@link PluginIconLoader}. + */ +export interface PluginMetadata { + /** `description` from `package.json`, or `null` when it has none. */ + description: null | string; + /** `name` from `package.json` - which is the plugin id. */ + id: string; + /** `vitnode.title`, falling back to `name`. */ + title: string; + /** `version` from `package.json`, or `null` when it has none. */ + version: null | string; +} + +/** + * The plugin's icon as a `data:` URL, or `null` when `package.json` declares + * none. Lazy, so the bytes reach the browser only on the screen that shows + * them - `() => import("./plugin-icon.gen")`. + */ +export type PluginIconLoader = () => Promise<{ default: null | string }>; + +export const PLUGIN_METADATA_ERROR_PREFIX = "[VitNode plugin metadata]"; + +/** + * Refuses metadata generated for a different package than the one registering + * it - a copied `config.tsx`, or a `pluginId` that drifted from the package + * name. Either way the catalog would otherwise report another package's + * version under this plugin's id. + */ +export const assertPluginMetadataId = ( + pluginId: string, + metadata: Pick | undefined, + where: string, +): void => { + if (metadata === undefined || metadata.id === pluginId) return; + + throw new Error( + `${PLUGIN_METADATA_ERROR_PREFIX} ${where} registers the plugin "${pluginId}" with the metadata of "${metadata.id}". The metadata comes from the package's own package.json, whose "name" is the plugin id - pass the \`pluginMetadata\` of this plugin's own \`src/plugin-metadata.gen.ts\`, or make \`pluginId\` match the package name.`, + ); +}; + +/** One plugin the web build registered, as its generated inventory lists it. */ +export interface FrontendPluginInventoryEntry { + id: string; + /** `null` for a plugin registered without generated metadata. */ + metadata: null | PluginMetadata; +} + +/** + * The plugins an app's `vitnode.config.ts` registers, written by the web build + * to `src/plugin-inventory.gen.ts` as plain data. Handing it to + * `buildApiConfig({ frontendPlugins })` is how the API learns what the browser + * runs - it cannot see that configuration itself. + */ +export type FrontendPluginInventory = readonly FrontendPluginInventoryEntry[]; diff --git a/packages/vitnode/src/lib/plugin.test.ts b/packages/vitnode/src/lib/plugin.test.ts new file mode 100644 index 0000000000..ea082b0586 --- /dev/null +++ b/packages/vitnode/src/lib/plugin.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { buildPlugin } from "./plugin"; + +describe("buildPlugin metadata", () => { + it("refuses metadata generated for another package", () => { + expect(() => + buildPlugin({ + metadata: { + description: null, + id: "@acme/blog", + title: "Blog", + version: "1.0.0", + }, + pluginId: "@acme/shop", + }), + ).toThrow( + /^\[VitNode plugin metadata\] buildPlugin\(\) registers the plugin "@acme\/shop" with the metadata of "@acme\/blog"/, + ); + }); +}); diff --git a/packages/vitnode/src/lib/plugin.ts b/packages/vitnode/src/lib/plugin.ts index 438d69c982..b022504e2b 100644 --- a/packages/vitnode/src/lib/plugin.ts +++ b/packages/vitnode/src/lib/plugin.ts @@ -11,6 +11,9 @@ import type { PluginRoutes } from "../routing/tree"; import type { AdminNavItem as ResolvedAdminNavItem } from "../views/admin/layouts/sidebar/nav/nav-model"; import type { ContentFormSkeletonControl } from "../views/admin/views/content/form/skeleton"; import type { LocaleFilesMap, LocaleMessagesMap } from "./i18n/types"; +import type { PluginIconLoader, PluginMetadata } from "./plugin-metadata"; + +import { assertPluginMetadataId } from "./plugin-metadata"; export type AdminNavPermission = Omit; @@ -173,6 +176,12 @@ export interface BuildPluginReturn

{ }; containers?: ContentContainerFrontendRegistration[]; contentTypes?: ContentTypeFrontendRegistration[]; + /** + * The plugin's icon for AdminCP → System → Plugins, loaded only there: + * `() => import("./plugin-icon.gen")`, generated from `vitnode.icon` in + * `package.json`. Without it the catalog shows a default icon. + */ + icon?: PluginIconLoader; /** * Every locale file this plugin ships, as literal `*.json` specifiers an * app's bundler can resolve - `{ en: "@acme/blog/locales/en.json" }`. @@ -188,6 +197,13 @@ export interface BuildPluginReturn

{ */ localeFiles?: LocaleFilesMap; messages?: LocaleMessagesMap; + /** + * `pluginMetadata` from the plugin's generated `src/plugin-metadata.gen.ts`: + * its title, description and installed version, read from `package.json` by + * `vitnode build`. Without it the AdminCP lists the plugin by id with an + * unknown version. + */ + metadata?: PluginMetadata; pluginId: P; routes?: PluginRoutes; @@ -196,5 +212,7 @@ export interface BuildPluginReturn

{ export function buildPlugin

( props: BuildPluginReturn

, ): BuildPluginReturn

{ + assertPluginMetadataId(props.pluginId, props.metadata, "buildPlugin()"); + return props; } diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 03f5b93c74..9117ed5095 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -40,6 +40,8 @@ "@vitnode/core:ai:can_manage": "Manage AI settings, models, pricing and access", "@vitnode/core:queue": "Queue Tasks", "@vitnode/core:queue:can_view": "View queue tasks", + "@vitnode/core:plugins": "Plugins", + "@vitnode/core:plugins:can_view": "View installed plugins and their versions", "@vitnode/core:notifications": "Notifications", "@vitnode/core:notifications:can_view": "View notification settings and delivery health", "@vitnode/core:notifications:can_edit": "Edit notification settings", @@ -2690,7 +2692,8 @@ "integrations": "Integrations", "navigation": "Navigation", "files": "Files", - "notifications": "Notifications" + "notifications": "Notifications", + "plugins": "Plugins" }, "advanced": { "title": "Advanced", @@ -3711,6 +3714,29 @@ } } } + }, + "plugins": { + "title": "Plugins", + "desc": "Every plugin this application is configured with, and the version it runs.", + "core": "VitNode core", + "count_label": "Installed plugins", + "search": "Search plugins...", + "version_unknown": "Unknown version", + "no_description": "No description provided.", + "mismatch": "Version mismatch: the web app runs {frontend}, the API runs {api}. Rebuild and redeploy both so they match.", + "frontend_unknown": { + "title": "Showing API plugins only", + "desc": "This API wasn't given the web build's plugin inventory, so plugins registered only in vitnode.config.ts aren't listed and versions can't be compared. Pass frontendPlugins to buildApiConfig to include them." + }, + "empty": { + "title": "No plugins installed", + "desc": "Add a plugin to vitnode.config.ts or vitnode.api.config.ts and it shows up here after the next build." + }, + "no_results": { + "title": "No plugins match your search", + "desc": "Try the plugin's name, its package ID or a word from its description.", + "clear": "Clear search" + } } }, "debug": { diff --git a/packages/vitnode/src/pages/admin/system/plugins.tsx b/packages/vitnode/src/pages/admin/system/plugins.tsx new file mode 100644 index 0000000000..83af55a1d9 --- /dev/null +++ b/packages/vitnode/src/pages/admin/system/plugins.tsx @@ -0,0 +1,24 @@ +import type { PluginRoutePageProps } from "@/routing"; +import type { AdminPluginsRouteData } from "@/tanstack/admin/plugins/route"; + +import { adminBreadcrumb } from "@/tanstack/admin/breadcrumb"; +import { loadAdminPluginsRoute } from "@/tanstack/admin/plugins/route"; +import { AdminPluginsRouteContent } from "@/tanstack/admin/plugins/screen"; +import { defineAdminRoute } from "@/tanstack/plugin-routes"; + +const AdminPluginsPage = ({ + loaderData, +}: PluginRoutePageProps) => ( + +); + +export const route = defineAdminRoute({ + // `head` after `load`, always. + load: async ({ context, t }) => + await loadAdminPluginsRoute({ ...context, t }), + head: ({ loaderData }) => ({ ...loaderData }), + + breadcrumb: adminBreadcrumb({ segments: ["core", "system", "plugins"] }), +}); + +export default AdminPluginsPage; diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx index d48fc9b9ca..5dfb64ba76 100644 --- a/packages/vitnode/src/routes.tsx +++ b/packages/vitnode/src/routes.tsx @@ -14,6 +14,7 @@ import { normalizeAdminFilesRouteSearch } from "./tanstack/admin/files/route-sea import { ADMIN_INTEGRATIONS_NAMESPACES } from "./tanstack/admin/integrations/route"; import { ADMIN_NAVIGATION_NAMESPACES } from "./tanstack/admin/navigation/route"; import { ADMIN_NOTIFICATIONS_NAMESPACES } from "./tanstack/admin/notifications/route"; +import { ADMIN_PLUGINS_NAMESPACES } from "./tanstack/admin/plugins/route"; import { ADMIN_QUEUE_NAMESPACES } from "./tanstack/admin/queue/route"; import { normalizeQueueRouteSearch } from "./tanstack/admin/queue/route-search"; import { ADMIN_ROLES_NAMESPACES } from "./tanstack/admin/roles/route"; @@ -326,6 +327,13 @@ export const routes = defineRoutes([ pendingComponent: CardsPendingSkeleton, }), + page("/admin/core/system/plugins", { + area: "admin", + component: lazy(() => import("./pages/admin/system/plugins")), + messages: ADMIN_PLUGINS_NAMESPACES, + pendingComponent: TablePendingSkeleton, + }), + page("/admin/core/system/notifications", { area: "admin", component: lazy(() => import("./pages/admin/system/notifications")), diff --git a/packages/vitnode/src/tanstack/admin/plugins/index.ts b/packages/vitnode/src/tanstack/admin/plugins/index.ts new file mode 100644 index 0000000000..2021a23bfd --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/plugins/index.ts @@ -0,0 +1,11 @@ +export type { AdminPluginsRouteData } from "./route"; +export { ADMIN_PLUGINS_NAMESPACES, loadAdminPluginsRoute } from "./route"; +export { AdminPluginsRouteContent } from "./screen"; +export type { + AdminInstalledPlugin, + AdminInstalledPlugins, +} from "@/views/admin/views/core/system/plugins/plugins-query"; +export { + installedPluginsQueryKey, + installedPluginsQueryOptions, +} from "@/views/admin/views/core/system/plugins/plugins-query"; diff --git a/packages/vitnode/src/tanstack/admin/plugins/route.tsx b/packages/vitnode/src/tanstack/admin/plugins/route.tsx new file mode 100644 index 0000000000..b8d705ac0f --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/plugins/route.tsx @@ -0,0 +1,47 @@ +import type { PluginRouteTranslator } from "@/routing"; + +import { installedPluginsQueryOptions } from "@/views/admin/views/core/system/plugins/plugins-query"; + +import type { AdminScreenContext } from "../screen"; + +import { requireAdminPermission } from "../screen"; + +/** + * `/admin/core/system/plugins`, as everything a TanStack Start route needs + * and nothing a route owns. + */ + +export const ADMIN_PLUGINS_NAMESPACES = [ + "admin.system.plugins", + "core.global", +] as const; + +export interface AdminPluginsRouteData { + description: string; + title: string; +} + +export const PLUGINS_VIEW_PERMISSION = { + module: "plugins", + permission: "can_view", +} as const; + +export const loadAdminPluginsRoute = async ({ + adminAccess, + queryClient, + t, +}: AdminScreenContext & { + t: PluginRouteTranslator; +}): Promise => { + requireAdminPermission(adminAccess, PLUGINS_VIEW_PERMISSION); + + await queryClient.query({ + ...installedPluginsQueryOptions(), + staleTime: "static", + }); + + return { + description: t("admin.system.plugins.desc"), + title: t("admin.system.plugins.title"), + }; +}; diff --git a/packages/vitnode/src/tanstack/admin/plugins/screen.tsx b/packages/vitnode/src/tanstack/admin/plugins/screen.tsx new file mode 100644 index 0000000000..4911f3415b --- /dev/null +++ b/packages/vitnode/src/tanstack/admin/plugins/screen.tsx @@ -0,0 +1,40 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; + +import type { PluginIconLoader } from "@/lib/plugin-metadata"; + +import { PageTitle } from "@/components/ui/page-title"; +import { PluginsContent } from "@/views/admin/views/core/system/plugins/plugins-content"; +import { installedPluginsQueryOptions } from "@/views/admin/views/core/system/plugins/plugins-query"; +import { getVitNodeConfig } from "@/vitnode.config"; + +import type { AdminPluginsRouteData } from "./route"; + +import { RouteMessages } from "../../i18n/route-messages"; +import { ADMIN_PLUGINS_NAMESPACES } from "./route"; + +/** + * Icons come from the frontend registrations - the browser is where they are + * shown, and the API never carries image bytes. A plugin registered only in the + * API gets the default icon. + */ +const frontendIcons = (): Record => + Object.fromEntries( + getVitNodeConfig().plugins.map(plugin => [plugin.pluginId, plugin.icon]), + ); + +export const AdminPluginsRouteContent = ({ + description, + title, +}: AdminPluginsRouteData) => { + const { data } = useSuspenseQuery(installedPluginsQueryOptions()); + + return ( + +

+ + + +
+ + ); +}; diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts index 69cef7a1b8..98f78c77e7 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.test.ts @@ -64,6 +64,7 @@ describe("the core group", () => { "/admin/core/system/navigation", "/admin/core/system/files", "/admin/core/system/notifications", + "/admin/core/system/plugins", "/admin/core/users", "/admin/core/users", "/admin/core/users/roles", diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx index c5673f15a6..5f0e8d2763 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/nav-model.tsx @@ -194,6 +194,11 @@ const coreNavGroup = (): AdminNavGroupDeclaration => ({ href: "/admin/core/system/notifications", permission: core("notifications"), }, + { + title: key("admin.global.nav.system.plugins"), + href: "/admin/core/system/plugins", + permission: core("plugins"), + }, ], }, { diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-icon.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-icon.tsx new file mode 100644 index 0000000000..cdb593f8a1 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-icon.tsx @@ -0,0 +1,59 @@ +import { useQuery } from "@tanstack/react-query"; +import { PuzzleIcon } from "lucide-react"; +import React from "react"; + +import type { PluginIconLoader } from "@/lib/plugin-metadata"; + +import { installedPluginsQueryKey } from "./plugins-query"; + +const FRAME = + "bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"; + +/** + * The plugin's icon, or a puzzle piece while it loads, when the plugin ships + * none, and when the image fails to decode. + * + * Always an ``, never inlined markup: an SVG icon rendered as an image + * cannot run scripts or reach the network. Decorative, because the title sits + * right next to it. + */ +export const PluginIcon = ({ + id, + load, +}: { + id: string; + load: PluginIconLoader | undefined; +}) => { + const { data } = useQuery({ + enabled: load !== undefined, + queryFn: async () => (load ? (await load()).default : null), + queryKey: [...installedPluginsQueryKey, "icon", id], + retry: false, + staleTime: Infinity, + }); + const [failed, setFailed] = React.useState(null); + + if (!data || failed === data) { + return ( + + ); + } + + return ( + + { + setFailed(data); + }} + src={data} + width={32} + /> + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-search.test.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-search.test.ts new file mode 100644 index 0000000000..fa18d1924d --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-search.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { matchesPluginSearch } from "./plugin-search"; + +const blog = { + description: "Posts and Categories", + id: "@acme/blog", + title: "Journal", +}; + +describe("matchesPluginSearch", () => { + it.each([ + ["an empty search", "", true], + ["a blank search", " ", true], + ["the title, in any case", "JOUR", true], + ["the id", "acme/bl", true], + ["the description", "categories", true], + ["a padded needle", " journal ", true], + ["nothing", "forum", false], + ])("matches %s", (_, search, expected) => { + expect(matchesPluginSearch(blog, search)).toBe(expected); + }); + + it("searches a plugin without a description by its title and id", () => { + const plugin = { ...blog, description: null }; + + expect(matchesPluginSearch(plugin, "acme")).toBe(true); + expect(matchesPluginSearch(plugin, "posts")).toBe(false); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-search.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-search.ts new file mode 100644 index 0000000000..4db69a29a6 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-search.ts @@ -0,0 +1,14 @@ +import type { AdminInstalledPlugin } from "./plugins-query"; + +/** Case-insensitive, across what an administrator would type: title, id, description. */ +export const matchesPluginSearch = ( + plugin: Pick, + search: string, +): boolean => { + const needle = search.trim().toLowerCase(); + if (needle === "") return true; + + return [plugin.title, plugin.id, plugin.description ?? ""].some(value => + value.toLowerCase().includes(needle), + ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.test.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.test.tsx new file mode 100644 index 0000000000..2f237742b0 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.test.tsx @@ -0,0 +1,164 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { PluginIconLoader } from "@/lib/plugin-metadata"; + +import type { + AdminInstalledPlugin, + AdminInstalledPlugins, +} from "./plugins-query"; + +import { PluginsContent } from "./plugins-content"; + +const plugin = ( + overrides: Partial = {}, +): AdminInstalledPlugin => ({ + description: "Posts and categories", + id: "@acme/blog", + registrations: { + api: { version: "1.0.0" }, + frontend: { version: "1.0.0" }, + }, + title: "Blog", + version: "1.0.0", + versionMismatch: false, + ...overrides, +}); + +const forum = plugin({ + description: "Threads and replies", + id: "@acme/forum", + registrations: { + api: { version: "2.0.0" }, + frontend: { version: "1.0.0" }, + }, + title: "Forum", + version: "2.0.0", + versionMismatch: true, +}); + +const renderPlugins = ( + data: Partial = {}, + icons: Record = {}, +) => + render( + + + + + , + ); + +// The table's search box is debounced, and navigates in an async transition. +const search = async (value: string) => { + fireEvent.change(screen.getByRole("searchbox"), { target: { value } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("PluginsContent", () => { + it("narrows the list as you search, and clears a search with no results", async () => { + vi.useFakeTimers(); + renderPlugins(); + + await search("threads"); + expect(screen.queryByText("Blog")).toBeNull(); + expect(screen.getByText("Forum")).toBeTruthy(); + + await search("nothing like this"); + expect(screen.queryByText("Forum")).toBeNull(); + expect( + screen.getByText("admin.system.plugins.no_results.title"), + ).toBeTruthy(); + + fireEvent.click( + screen.getByRole("button", { + name: "admin.system.plugins.no_results.clear", + }), + ); + expect(screen.getByText("Blog")).toBeTruthy(); + expect(screen.getByText("Forum")).toBeTruthy(); + }); + + it("says no plugins are installed when there are none", () => { + renderPlugins({ plugins: [] }); + + expect(screen.getByText("admin.system.plugins.empty.title")).toBeTruthy(); + expect( + screen.queryByText("admin.system.plugins.no_results.title"), + ).toBeNull(); + }); + + it("warns only about the plugin whose API and frontend versions differ", () => { + renderPlugins(); + + expect(screen.getAllByText("admin.system.plugins.mismatch")).toHaveLength( + 1, + ); + }); + + it("marks a plugin with no known version", () => { + renderPlugins({ plugins: [plugin({ version: null })] }); + + expect( + screen.getByText("admin.system.plugins.version_unknown"), + ).toBeTruthy(); + }); + + it.each([ + [true, 0], + [false, 1], + ])( + "warns that frontend plugins are unknown only when the inventory is missing (%s)", + (frontendInventory, count) => { + renderPlugins({ frontendInventory }); + + expect( + screen.queryAllByText("admin.system.plugins.frontend_unknown.title"), + ).toHaveLength(count); + }, + ); + + it("shows the default icon when the plugin ships none", () => { + const { container } = renderPlugins({ plugins: [plugin()] }); + + expect(container.querySelector("img")).toBeNull(); + }); + + it("shows the plugin's own icon from its loader", async () => { + const icon = "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="; + const { container } = renderPlugins( + { plugins: [plugin()] }, + { "@acme/blog": async () => await Promise.resolve({ default: icon }) }, + ); + + await waitFor(() => { + expect(container.querySelector("img")?.getAttribute("src")).toBe(icon); + }); + }); +}); diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx new file mode 100644 index 0000000000..a5fd0d901a --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx @@ -0,0 +1,193 @@ +import { TriangleAlertIcon } from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { DataTableNavigation } from "@/components/table/navigation"; +import type { PluginIconLoader } from "@/lib/plugin-metadata"; + +import { ContentDataTable } from "@/components/table/content"; +import { DataTableNavigationProvider } from "@/components/table/navigation"; +import { + readTablePage, + readTablePageSize, + readTableSearch, + withTableSearch, +} from "@/components/table/url-state"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +import type { + AdminInstalledPlugin, + AdminInstalledPlugins, +} from "./plugins-query"; + +import { PluginIcon } from "./plugin-icon"; +import { matchesPluginSearch } from "./plugin-search"; + +export interface PluginsContentProps { + data: AdminInstalledPlugins; + /** Each frontend registration's icon loader, by plugin id. */ + icons: Readonly>; +} + +const PluginVersion = ({ version }: { version: null | string }) => { + const t = useTranslations("admin.system.plugins"); + + if (version === null) { + return {t("version_unknown")}; + } + + return ( + + {version} + + ); +}; + +const PluginRow = ({ + icon, + plugin, +}: { + icon: PluginIconLoader | undefined; + plugin: AdminInstalledPlugin; +}) => { + const t = useTranslations("admin.system.plugins"); + const { api, frontend } = plugin.registrations; + + return ( +
+ +
+
+ {plugin.title} + +
+ {plugin.title === plugin.id ? null : ( + + {plugin.id} + + )} + {plugin.description === null ? ( +

+ {t("no_description")} +

+ ) : ( +

+ {plugin.description} +

+ )} + {plugin.versionMismatch && api?.version && frontend?.version ? ( +

+ + {t("mismatch", { api: api.version, frontend: frontend.version })} +

+ ) : null} +
+
+ ); +}; + +const PluginsSummary = ({ core, count }: { core: string; count: number }) => { + const t = useTranslations("admin.system.plugins"); + + return ( +
+
+
{t("core")}
+
{core}
+
+
+
{t("count_label")}
+
{count}
+
+
+ ); +}; + +export const PluginsContent = ({ data, icons }: PluginsContentProps) => { + const t = useTranslations("admin.system.plugins"); + const [searchParams, setSearchParams] = React.useState( + () => new URLSearchParams(), + ); + const navigation = React.useMemo( + () => ({ + navigate: nextSearch => { + setSearchParams(new URLSearchParams(nextSearch)); + }, + searchParams, + }), + [searchParams], + ); + + const search = readTableSearch(searchParams); + const rows = data.plugins.filter(plugin => + matchesPluginSearch(plugin, search), + ); + const pageSize = readTablePageSize(searchParams); + const totalPages = Math.max(1, Math.ceil(rows.length / pageSize)); + const currentPage = Math.min(readTablePage(searchParams), totalPages); + const pageRows = rows.slice( + (currentPage - 1) * pageSize, + currentPage * pageSize, + ); + + return ( +
+ + + {data.frontendInventory ? null : ( + + {t("frontend_unknown.title")} + {t("frontend_unknown.desc")} + + )} + + + { + setSearchParams( + new URLSearchParams( + withTableSearch(searchParams, ""), + ), + ); + }} + variant="outline" + > + {t("no_results.clear")} + + ), + title: t("no_results.title"), + } + } + edges={pageRows} + id="installed-plugins" + order={{ defaultOrder: { column: "id", order: "asc" } }} + pageInfo={{ + count: pageRows.length, + currentPage, + endCursor: null, + hasNextPage: currentPage < totalPages, + hasPreviousPage: currentPage > 1, + pageSize, + startCursor: null, + totalCount: rows.length, + totalPages, + }} + renderRow={({ row }) => ( + + )} + search + searchPlaceholder={t("search")} + /> + +
+ ); +}; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-query.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-query.ts new file mode 100644 index 0000000000..e5345b2dde --- /dev/null +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-query.ts @@ -0,0 +1,56 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { CONFIG_PLUGIN } from "@/config"; +import { fetcher } from "@/tanstack/fetcher"; +import { AdminRequestError } from "@/views/admin/admin-request"; +import { adminQueryRoot } from "@/views/admin/table/query"; + +export interface AdminPluginRegistration { + version: null | string; +} + +export interface AdminInstalledPlugin { + description: null | string; + id: string; + registrations: { + api: AdminPluginRegistration | null; + frontend: AdminPluginRegistration | null; + }; + title: string; + version: null | string; + versionMismatch: boolean; +} + +export interface AdminInstalledPlugins { + core: { version: string }; + frontendInventory: boolean; + plugins: AdminInstalledPlugin[]; +} + +export type InstalledPluginsFetcher = () => Promise; + +export const fetchInstalledPlugins: InstalledPluginsFetcher = async () => { + const response = await fetcher({ + plugin: CONFIG_PLUGIN.pluginId, + method: "get", + module: "admin/plugins", + path: "/", + }); + + if (!response.ok) { + throw new AdminRequestError(response.status, "the plugin catalog"); + } + + return await response.json(); +}; + +export const installedPluginsQueryKey = adminQueryRoot("plugins"); + +export const installedPluginsQueryOptions = () => + queryOptions({ + queryFn: async () => await fetchInstalledPlugins(), + queryKey: installedPluginsQueryKey, + retry: false, + // The list changes only when the application is rebuilt and restarted. + staleTime: Infinity, + }); diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index 6776d22d31..6df189b621 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -25,6 +25,7 @@ import type { } from "./lib/i18n/types"; import type { VitNodeMetadata } from "./lib/metadata"; import type { BuildPluginReturn } from "./lib/plugin"; +import type { FrontendPluginInventory } from "./lib/plugin-metadata"; import type { PersonalInformationFieldsConfig } from "./lib/user-personal-information"; import type { PageCacheConfig } from "./page-cache/config"; @@ -112,6 +113,19 @@ export interface VitNodeApiConfig { adapter?: EventsApiPlugin; }; + /** + * The plugins the web app registers in `vitnode.config.ts`, as its build + * generates them into `src/plugin-inventory.gen.ts` - metadata only, no + * frontend code. + * + * The API cannot see the web app's configuration, so this is how + * AdminCP → System → Plugins lists frontend-only plugins and spots a web + * build and an API running different versions. Leave it out for an API + * deployed without that web build: the catalog then lists the API's own + * plugins and says the frontend is unknown. + */ + frontendPlugins?: FrontendPluginInventory; + i18n?: VitNodeApiI18nConfig; /** * Largest request body the API will read, in bytes. Defaults to 25 MB. diff --git a/packages/vitnode/vitest.config.ts b/packages/vitnode/vitest.config.ts index c5ccb77dfd..dd42d2f6c2 100644 --- a/packages/vitnode/vitest.config.ts +++ b/packages/vitnode/vitest.config.ts @@ -37,6 +37,7 @@ const INTEGRATION = [ "scripts/cli/commands/plugin.test.ts", "scripts/cli/commands/start.test.ts", "scripts/cli/dev/package-watch.test.ts", + "scripts/watch-plugin-metadata.test.ts", ]; export default defineConfig({ diff --git a/plugins/blog/assets/icon.svg b/plugins/blog/assets/icon.svg new file mode 100644 index 0000000000..1ef99eca3b --- /dev/null +++ b/plugins/blog/assets/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/blog/package.json b/plugins/blog/package.json index f6e5c9755e..ad32e190ca 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -2,6 +2,10 @@ "name": "@vitnode/blog", "version": "2.0.0-canary.13", "description": "Blog plugin for VitNode, providing a blogging platform on Hono.js.", + "vitnode": { + "title": "Blog", + "icon": "./assets/icon.svg" + }, "author": "VitNode Team", "license": "MIT", "homepage": "https://vitnode.com", diff --git a/plugins/blog/src/config.api.ts b/plugins/blog/src/config.api.ts index 743c253b75..705035c42b 100644 --- a/plugins/blog/src/config.api.ts +++ b/plugins/blog/src/config.api.ts @@ -8,10 +8,12 @@ import { adminModule } from "@/api/modules/admin/admin.module"; import { CONFIG_PLUGIN } from "@/const"; import { categoryContent } from "@/database/categories"; import { postContent } from "@/database/posts"; +import { pluginMetadata } from "@/plugin-metadata.gen"; export const blogApiPlugin = () => buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, + metadata: pluginMetadata, aiActions: blogAiActions, modules: [ adminModule, diff --git a/plugins/blog/src/config.tsx b/plugins/blog/src/config.tsx index 3156fc605a..1c26e7ecc9 100644 --- a/plugins/blog/src/config.tsx +++ b/plugins/blog/src/config.tsx @@ -3,11 +3,14 @@ import { buildPlugin } from "@vitnode/core/lib/plugin"; import { CONFIG_PLUGIN } from "@/const"; import messages from "./locales"; +import { pluginMetadata } from "./plugin-metadata.gen"; import { routes } from "./routes"; export const blogPlugin = () => buildPlugin({ ...CONFIG_PLUGIN, + metadata: pluginMetadata, + icon: async () => await import("./plugin-icon.gen"), localeFiles: { en: "@vitnode/blog/locales/en.json", }, diff --git a/plugins/blog/src/plugin-icon.gen.ts b/plugins/blog/src/plugin-icon.gen.ts new file mode 100644 index 0000000000..e5fab09681 --- /dev/null +++ b/plugins/blog/src/plugin-icon.gen.ts @@ -0,0 +1,13 @@ +/* oxlint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by `vitnode build` and `vitnode dev` from this package's +// package.json: `name`, `version`, `description` and the `vitnode` field. +// +// Loaded lazily by `buildPlugin({ icon: () => import("./plugin-icon.gen") })`, +// so the bytes reach a browser only on the AdminCP screen that shows them. + +const icon: null | string = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0ibm9uZSI+CiAgPHJlY3Qgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4IiByeD0iMTIiIGZpbGw9IiMwZjc2NmUiLz4KICA8cGF0aCBkPSJNMTUgMTRoMTRhNCA0IDAgMCAxIDQgNHYxNkgxOWE0IDQgMCAwIDEtNC00VjE0WiIgc3Ryb2tlPSIjZmZmZmZmIiBzdHJva2Utd2lkdGg9IjIuNSIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgogIDxwYXRoIGQ9Ik0zMyAyMmgyYTIgMiAwIDAgMSAyIDJ2OGEyIDIgMCAwIDEtNCAwIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMi41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KICA8cGF0aCBkPSJNMjAgMjBoOE0yMCAyNWg4TTIwIDMwaDUiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSIyLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K" + +export default icon diff --git a/plugins/blog/src/plugin-metadata.gen.ts b/plugins/blog/src/plugin-metadata.gen.ts new file mode 100644 index 0000000000..0ebd2f2482 --- /dev/null +++ b/plugins/blog/src/plugin-metadata.gen.ts @@ -0,0 +1,16 @@ +/* oxlint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by `vitnode build` and `vitnode dev` from this package's +// package.json: `name`, `version`, `description` and the `vitnode` field. +// +// Register `pluginMetadata` as `metadata` in both `buildPlugin` and +// `buildApiPlugin`. + +export const pluginMetadata = { + id: "@vitnode/blog", + title: "Blog", + description: "Blog plugin for VitNode, providing a blogging platform on Hono.js.", + version: "2.0.0-canary.13", +} as const diff --git a/plugins/example/assets/icon.svg b/plugins/example/assets/icon.svg new file mode 100644 index 0000000000..ba218f0a2c --- /dev/null +++ b/plugins/example/assets/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/example/package.json b/plugins/example/package.json index 8090218522..f0c2716cd1 100644 --- a/plugins/example/package.json +++ b/plugins/example/package.json @@ -2,6 +2,10 @@ "name": "@vitnode/example", "version": "2.0.0-canary.13", "description": "Reference plugin exercising the VitNode Content Engine end to end.", + "vitnode": { + "title": "Example", + "icon": "./assets/icon.svg" + }, "license": "MIT", "private": true, "type": "module", diff --git a/plugins/example/src/config.api.ts b/plugins/example/src/config.api.ts index 1a68b8c30e..fc79df1986 100644 --- a/plugins/example/src/config.api.ts +++ b/plugins/example/src/config.api.ts @@ -17,6 +17,7 @@ import { eventContent } from "@/database/events"; import { localizedArticleContent } from "@/database/localized-articles"; import { pageContent } from "@/database/pages"; import { tagContent } from "@/database/tags"; +import { pluginMetadata } from "@/plugin-metadata.gen"; import { widgets } from "./widgets"; import "@/api/lib/events"; @@ -24,6 +25,7 @@ import "@/api/lib/events"; export const exampleApiPlugin = () => buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, + metadata: pluginMetadata, aiActions: [articleExcerptAiAction], blocks: widgets, editablePages: [settingsPage], diff --git a/plugins/example/src/config.tsx b/plugins/example/src/config.tsx index 0889252d1c..0a7773d49e 100644 --- a/plugins/example/src/config.tsx +++ b/plugins/example/src/config.tsx @@ -3,11 +3,14 @@ import { buildPlugin } from "@vitnode/core/lib/plugin"; import { CONFIG_PLUGIN } from "@/const"; import messages from "./locales"; +import { pluginMetadata } from "./plugin-metadata.gen"; import { routes } from "./routes"; export const examplePlugin = () => buildPlugin({ ...CONFIG_PLUGIN, + metadata: pluginMetadata, + icon: async () => await import("./plugin-icon.gen"), localeFiles: { en: "@vitnode/example/locales/en.json", }, diff --git a/plugins/example/src/plugin-icon.gen.ts b/plugins/example/src/plugin-icon.gen.ts new file mode 100644 index 0000000000..24dc2bb68b --- /dev/null +++ b/plugins/example/src/plugin-icon.gen.ts @@ -0,0 +1,13 @@ +/* oxlint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by `vitnode build` and `vitnode dev` from this package's +// package.json: `name`, `version`, `description` and the `vitnode` field. +// +// Loaded lazily by `buildPlugin({ icon: () => import("./plugin-icon.gen") })`, +// so the bytes reach a browser only on the AdminCP screen that shows them. + +const icon: null | string = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0ibm9uZSI+CiAgPHJlY3Qgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4IiByeD0iMTIiIGZpbGw9IiNjMjQxMGMiLz4KICA8cGF0aCBkPSJNMjAgMTJoOE0yMSAxMnY5bC03IDEyYTMgMyAwIDAgMCAyLjYgNC41aDE0LjhBMyAzIDAgMCAwIDM0IDMzbC03LTEydi05IiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMi41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KICA8cGF0aCBkPSJNMTcuNSAyOGgxMyIgc3Ryb2tlPSIjZmZmZmZmIiBzdHJva2Utd2lkdGg9IjIuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+Cjwvc3ZnPgo=" + +export default icon diff --git a/plugins/example/src/plugin-metadata.gen.ts b/plugins/example/src/plugin-metadata.gen.ts new file mode 100644 index 0000000000..3b47eddac4 --- /dev/null +++ b/plugins/example/src/plugin-metadata.gen.ts @@ -0,0 +1,16 @@ +/* oxlint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by `vitnode build` and `vitnode dev` from this package's +// package.json: `name`, `version`, `description` and the `vitnode` field. +// +// Register `pluginMetadata` as `metadata` in both `buildPlugin` and +// `buildApiPlugin`. + +export const pluginMetadata = { + id: "@vitnode/example", + title: "Example", + description: "Reference plugin exercising the VitNode Content Engine end to end.", + version: "2.0.0-canary.13", +} as const diff --git a/plugins/forum/assets/icon.svg b/plugins/forum/assets/icon.svg new file mode 100644 index 0000000000..eda6d7726e --- /dev/null +++ b/plugins/forum/assets/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/forum/package.json b/plugins/forum/package.json index 81668dc526..0cca4bb972 100644 --- a/plugins/forum/package.json +++ b/plugins/forum/package.json @@ -2,6 +2,10 @@ "name": "@vitnode/forum", "version": "2.0.0-canary.13", "description": "Community forum for VitNode: structured forums and nested threads, built on the Content Engine.", + "vitnode": { + "title": "Forum", + "icon": "./assets/icon.svg" + }, "license": "MIT", "private": true, "type": "module", diff --git a/plugins/forum/src/config.api.ts b/plugins/forum/src/config.api.ts index f0fc8ae60d..5346f4e373 100644 --- a/plugins/forum/src/config.api.ts +++ b/plugins/forum/src/config.api.ts @@ -9,10 +9,12 @@ import { CONFIG_PLUGIN } from "@/const"; import { forumContainers } from "@/content/forums"; import { commentContent } from "@/database/comments"; import { topicContent } from "@/database/topics"; +import { pluginMetadata } from "@/plugin-metadata.gen"; export const forumApiPlugin = () => buildApiPlugin({ pluginId: CONFIG_PLUGIN.pluginId, + metadata: pluginMetadata, navigation: [{ href: "/forum", icon: "messages-square", id: "forum" }], modules: [ adminModule, diff --git a/plugins/forum/src/config.tsx b/plugins/forum/src/config.tsx index 071db6fa70..d6216f333a 100644 --- a/plugins/forum/src/config.tsx +++ b/plugins/forum/src/config.tsx @@ -3,11 +3,14 @@ import { buildPlugin } from "@vitnode/core/lib/plugin"; import { CONFIG_PLUGIN } from "@/const"; import messages from "./locales"; +import { pluginMetadata } from "./plugin-metadata.gen"; import { routes } from "./routes"; export const forumPlugin = () => buildPlugin({ ...CONFIG_PLUGIN, + metadata: pluginMetadata, + icon: async () => await import("./plugin-icon.gen"), localeFiles: { en: "@vitnode/forum/locales/en.json", }, diff --git a/plugins/forum/src/plugin-icon.gen.ts b/plugins/forum/src/plugin-icon.gen.ts new file mode 100644 index 0000000000..256bb9fb2d --- /dev/null +++ b/plugins/forum/src/plugin-icon.gen.ts @@ -0,0 +1,13 @@ +/* oxlint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by `vitnode build` and `vitnode dev` from this package's +// package.json: `name`, `version`, `description` and the `vitnode` field. +// +// Loaded lazily by `buildPlugin({ icon: () => import("./plugin-icon.gen") })`, +// so the bytes reach a browser only on the AdminCP screen that shows them. + +const icon: null | string = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0ibm9uZSI+CiAgPHJlY3Qgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4IiByeD0iMTIiIGZpbGw9IiMxZDRlZDgiLz4KICA8cGF0aCBkPSJNMTIgMTVhMyAzIDAgMCAxIDMtM2gxM2EzIDMgMCAwIDEgMyAzdjhhMyAzIDAgMCAxLTMgM2gtOGwtNSA0di00aDBhMyAzIDAgMCAxLTMtM3YtOFoiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSIyLjUiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KICA8cGF0aCBkPSJNMzUgMjFhMyAzIDAgMCAxIDIgM3Y4YTMgMyAwIDAgMS0zIDN2NGwtNS00aC02YTMgMyAwIDAgMS0zLTMiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSIyLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K" + +export default icon diff --git a/plugins/forum/src/plugin-metadata.gen.ts b/plugins/forum/src/plugin-metadata.gen.ts new file mode 100644 index 0000000000..3c46b64728 --- /dev/null +++ b/plugins/forum/src/plugin-metadata.gen.ts @@ -0,0 +1,16 @@ +/* oxlint-disable */ + +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by `vitnode build` and `vitnode dev` from this package's +// package.json: `name`, `version`, `description` and the `vitnode` field. +// +// Register `pluginMetadata` as `metadata` in both `buildPlugin` and +// `buildApiPlugin`. + +export const pluginMetadata = { + id: "@vitnode/forum", + title: "Forum", + description: "Community forum for VitNode: structured forums and nested threads, built on the Content Engine.", + version: "2.0.0-canary.13", +} as const From a290d82696d432bd1175490350427d70fbe1f369 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:15:59 +0000 Subject: [PATCH 2/5] =?UTF-8?q?refactor(plugins):=20=E2=99=BB=EF=B8=8F=20m?= =?UTF-8?q?ove=20catalog=20title=20and=20icon=20to=20src/plugin-metadata.t?= =?UTF-8?q?s=20with=20Lucide=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package.json keeps only its standard fields. A plugin's title and Lucide icon name live in a hand-written src/plugin-metadata.ts, which the generated src/plugin-metadata.gen.ts imports next to the name, version and description read from package.json. - icons are Lucide names, validated at registration and returned by the API, so API-only plugins show their own icon too - drop plugin-icon.gen.ts, SVG assets, data URLs and icon file checks - DynamicIcon gains a `missing` fallback for unknown icon names - scaffold writes src/plugin-metadata.ts; docs and tests updated Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqJtNifFvW63sCoYVKgJ7m --- .../dev/plugins/admin-plugins-catalog.webp | Bin 64390 -> 62378 bytes apps/web/content/docs/dev/plugins/build.mdx | 2 +- apps/web/content/docs/dev/plugins/catalog.mdx | 65 +++--- apps/web/content/docs/dev/plugins/create.mdx | 6 +- apps/web/src/plugin-inventory.gen.ts | 3 + .../root/assets/icon.svg | 4 - .../src/helpers/packages-json.ts | 2 - .../src/plugin/create/create-package-json.ts | 10 +- .../src/plugin/create/route-templates.ts | 30 ++- .../scripts/cli/builder/compiler-build.ts | 4 +- .../scripts/watch-plugin-metadata.test.ts | 33 +-- .../scripts/write-plugin-metadata.test.ts | 168 +++++++++------ .../vitnode/scripts/write-plugin-metadata.ts | 100 +++------ .../src/api/lib/installed-plugins.test.ts | 24 +++ .../vitnode/src/api/lib/installed-plugins.ts | 7 +- packages/vitnode/src/api/lib/plugin.test.ts | 1 + packages/vitnode/src/api/lib/plugin.ts | 8 +- .../admin/plugins/routes/list.route.test.ts | 2 + .../admin/plugins/routes/list.route.ts | 2 + .../src/components/ui/dynamic-icon.test.tsx | 14 ++ .../src/components/ui/dynamic-icon.tsx | 10 +- .../plugin-inventory/generate.test.ts | 53 +++-- .../framework/plugin-inventory/generate.ts | 7 +- .../plugin-metadata/generate.test.ts | 160 ++------------ .../src/framework/plugin-metadata/generate.ts | 203 +++--------------- .../src/framework/plugin-metadata/index.ts | 9 +- packages/vitnode/src/lib/plugin-metadata.ts | 59 +++-- packages/vitnode/src/lib/plugin.test.ts | 43 ++-- packages/vitnode/src/lib/plugin.ts | 18 +- .../src/tanstack/admin/plugins/screen.tsx | 15 +- .../views/core/system/plugins/plugin-icon.tsx | 69 ++---- .../system/plugins/plugins-content.test.tsx | 36 +--- .../core/system/plugins/plugins-content.tsx | 19 +- .../core/system/plugins/plugins-query.ts | 1 + plugins/blog/assets/icon.svg | 6 - plugins/blog/package.json | 4 - plugins/blog/src/config.tsx | 1 - plugins/blog/src/plugin-icon.gen.ts | 13 -- plugins/blog/src/plugin-metadata.gen.ts | 12 +- plugins/blog/src/plugin-metadata.ts | 7 + plugins/example/assets/icon.svg | 5 - plugins/example/package.json | 4 - plugins/example/src/config.tsx | 1 - plugins/example/src/plugin-icon.gen.ts | 13 -- plugins/example/src/plugin-metadata.gen.ts | 12 +- plugins/example/src/plugin-metadata.ts | 7 + plugins/forum/assets/icon.svg | 5 - plugins/forum/package.json | 4 - plugins/forum/src/config.tsx | 1 - plugins/forum/src/plugin-icon.gen.ts | 13 -- plugins/forum/src/plugin-metadata.gen.ts | 12 +- plugins/forum/src/plugin-metadata.ts | 7 + 52 files changed, 503 insertions(+), 811 deletions(-) delete mode 100644 packages/create-vitnode-app/copy-of-vitnode-plugin/root/assets/icon.svg delete mode 100644 plugins/blog/assets/icon.svg delete mode 100644 plugins/blog/src/plugin-icon.gen.ts create mode 100644 plugins/blog/src/plugin-metadata.ts delete mode 100644 plugins/example/assets/icon.svg delete mode 100644 plugins/example/src/plugin-icon.gen.ts create mode 100644 plugins/example/src/plugin-metadata.ts delete mode 100644 plugins/forum/assets/icon.svg delete mode 100644 plugins/forum/src/plugin-icon.gen.ts create mode 100644 plugins/forum/src/plugin-metadata.ts diff --git a/apps/web/content/docs/dev/plugins/admin-plugins-catalog.webp b/apps/web/content/docs/dev/plugins/admin-plugins-catalog.webp index 1f3b8581edc1918d63ea07fc4f77388e1d072d0a..901e3b81c7c5b43ee260c470fdb80cd2908e2663 100644 GIT binary patch literal 62378 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

+7$Y?F1XsoDzL=zCrO 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

}Gsrke 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 E0HLE^00000 diff --git a/apps/web/content/docs/dev/plugins/build.mdx b/apps/web/content/docs/dev/plugins/build.mdx index 90883d7d7a..c442f4a248 100644 --- a/apps/web/content/docs/dev/plugins/build.mdx +++ b/apps/web/content/docs/dev/plugins/build.mdx @@ -78,7 +78,7 @@ You don't need `isolatedDeclarations` or explicit return types. Inferred types c ## Generated metadata -Before compiling, `vitnode build` also writes `src/plugin-metadata.gen.ts` and `src/plugin-icon.gen.ts` from the plugin's `package.json`: its name, version, description, and the `vitnode.title` and `vitnode.icon` fields. `vitnode dev` writes them on start and again whenever `package.json` or the icon changes. They are what the [AdminCP plugin catalog](/docs/dev/plugins/catalog) shows, so commit them with the rest of `src`. +Before compiling, `vitnode build` also writes `src/plugin-metadata.gen.ts`. It holds the plugin's `name`, `version` and `description` from `package.json`, and imports the title and Lucide icon from the plugin's own `src/plugin-metadata.ts`. `vitnode dev` writes it on start and again whenever `package.json` changes. It is what the [AdminCP plugin catalog](/docs/dev/plugins/catalog) shows, so commit it with the rest of `src`. ## What a plugin package needs diff --git a/apps/web/content/docs/dev/plugins/catalog.mdx b/apps/web/content/docs/dev/plugins/catalog.mdx index 6f12464421..d2b0d9559e 100644 --- a/apps/web/content/docs/dev/plugins/catalog.mdx +++ b/apps/web/content/docs/dev/plugins/catalog.mdx @@ -1,6 +1,6 @@ --- title: Show a plugin in the AdminCP catalog -description: Add a title, description and icon to package.json so AdminCP → System → Plugins lists your plugin with the version that is actually installed. +description: Give your plugin a title and a Lucide icon so AdminCP → System → Plugins lists it with the version that is actually installed. icon: Puzzle --- @@ -8,7 +8,7 @@ import { ImgDocs } from '@/components/fumadocs/img' import catalog from './admin-plugins-catalog.webp' -**AdminCP → System → Plugins** lists every plugin the running application is configured with: its icon, title, package ID, description and installed version, with the VitNode core version shown separately above the list. Everything on that page comes from each plugin's own `package.json`, read at build time, so nobody types a version number by hand. +**AdminCP → System → Plugins** lists every plugin the running application is configured with: its icon, title, package ID, description and installed version, with the VitNode core version shown separately above the list. The version and description come from each plugin's own `package.json` at build time, so nobody types a version number by hand. The title and icon come from a small file in the plugin. + + +### Set the title and icon + +Create `src/plugin-metadata.ts` with the title and a [Lucide icon](https://lucide.dev/icons) name: + +```ts title="src/plugin-metadata.ts" +import type { PluginInfo } from '@vitnode/core/lib/plugin-metadata' + +export const pluginInfo: PluginInfo = { + title: 'Site Notes', + icon: 'sticky-note', +} +``` + +Plugins created with the generator already have this file, with the `puzzle` icon. ### Register the generated metadata -`vitnode build` writes `src/plugin-metadata.gen.ts` and `src/plugin-icon.gen.ts` from that `package.json`. Pass them to both registrations: +`vitnode build` writes `src/plugin-metadata.gen.ts`. It holds the `name`, `version` and `description` from `package.json` and imports the title and icon from `src/plugin-metadata.ts`. Pass it to both registrations: ```tsx title="src/config.tsx" import { buildPlugin } from '@vitnode/core/lib/plugin' @@ -60,7 +72,6 @@ export const siteNotesPlugin = () => buildPlugin({ pluginId: CONFIG_PLUGIN.pluginId, metadata: pluginMetadata, - icon: async () => await import('./plugin-icon.gen'), routes, }) ``` @@ -79,8 +90,6 @@ export const siteNotesApiPlugin = () => }) ``` -The icon is a lazy `import()`, so its bytes reach the browser only on the catalog page, never on your public pages. - @@ -92,7 +101,7 @@ Run the build in the plugin's folder, or keep `vitnode dev` running: vitnode build ``` -Both commands generate the two files before TypeScript and tsdown run. Commit them, like `types/api-registry.gen.d.ts`. +Both commands generate the file before TypeScript and tsdown run. Commit it, like `types/api-registry.gen.d.ts`. @@ -101,25 +110,23 @@ Both commands generate the two files before TypeScript and tsdown run. Commit th Restart your app, sign in to the AdminCP and open **System → Plugins**. Your plugin shows its icon, title, `@acme/site-notes`, the description and `1.2.0`. Search matches the title, the package ID and the description. -## package.json fields - -| Name | Type | Required | Default | Description | -| --------------- | ------ | -------- | ----------- | ----------------------------------------------------------------- | -| `name` | string | Yes | - | The plugin ID. It must equal the `pluginId` you register. | -| `version` | string | No | Unknown | The installed version, exactly as published. | -| `description` | string | No | - | Plain text shown under the title. | -| `vitnode.title` | string | No | `name` | Human-readable title, shown as written in every language. | -| `vitnode.icon` | string | No | Puzzle icon | Path to an image inside the package, relative to `package.json`. | +## Where each value comes from -The build stops with a clear error when `vitnode.icon` is a URL, an absolute path, a path outside the package (including a symlink that leads outside), a missing file, a format other than SVG, PNG, WebP or JPEG, a file whose bytes don't match its extension, or a file over 32 KB. Icons are never fetched from the internet. +| Value | Source | Required | Default | +| ----------- | ----------------------------------- | -------- | -------------- | +| ID | `name` in `package.json` | Yes | - | +| Version | `version` in `package.json` | No | Unknown | +| Description | `description` in `package.json` | No | No description | +| Title | `title` in `src/plugin-metadata.ts` | No | The ID | +| Icon | `icon` in `src/plugin-metadata.ts` | No | Puzzle icon | -The icon is inlined into `src/plugin-icon.gen.ts` as a `data:` URL, so it ships inside your compiled package and works the same in a workspace and from `node_modules`. The AdminCP draws it with an `` tag, never as inline SVG markup, so an SVG icon cannot run scripts. A square icon around 48 × 48 looks best. +The ID must equal the `pluginId` you register. Title and description are plain text, shown as written in every language. The icon is a Lucide icon name in kebab-case, such as `newspaper` or `messages-square`. `buildPlugin()` and `buildApiPlugin()` throw at startup for anything else, and an unknown name falls back to the puzzle icon. Because an icon is just a name, the API returns it too, so plugins registered only in the API show their own icon as well. ## When the metadata is regenerated -- `vitnode build` regenerates both files before every build, so a published package always carries its real version. -- `vitnode dev` regenerates them whenever `package.json` or the icon changes. A file is only rewritten when its content changes, so adding a dependency rebuilds nothing. -- A broken edit, such as a missing icon, is reported in the terminal and the last good files stay in place. +- `vitnode build` regenerates the file before every build, so a published package always carries its real version. +- `vitnode dev` regenerates it whenever `package.json` changes, or `src/plugin-metadata.ts` is created or deleted. Edits inside `src/plugin-metadata.ts` need no regeneration: the generated file imports it. The file is only rewritten when its content changes, so adding a dependency rebuilds nothing. +- A broken `package.json` edit is reported in the terminal and the last good file stays in place. The app picks up a new plugin, a removed one or a new version after it is rebuilt and restarted. Nothing is stored in the database. @@ -149,7 +156,7 @@ A side that reports no version is unknown, not a mismatch. ## Older plugins -Plugins registered without `metadata` keep working. The catalog lists them by plugin ID, with the default icon, "Unknown version" and "No description provided." Add the metadata when convenient. +Plugins registered without `metadata` keep working. The catalog lists them by plugin ID, with the default icon, "Unknown version" and "No description provided." A plugin without `src/plugin-metadata.ts` gets its ID as the title and the default icon. Add either when convenient. Passing the metadata of another package throws at startup: `buildPlugin()` and `buildApiPlugin()` check that `metadata.id` equals `pluginId`. diff --git a/apps/web/content/docs/dev/plugins/create.mdx b/apps/web/content/docs/dev/plugins/create.mdx index a3bd790adc..349b5ae257 100644 --- a/apps/web/content/docs/dev/plugins/create.mdx +++ b/apps/web/content/docs/dev/plugins/create.mdx @@ -91,9 +91,6 @@ import generatedPageImage from './generated-page-image.png' - - - @@ -110,6 +107,7 @@ import generatedPageImage from './generated-page-image.png' + @@ -119,7 +117,7 @@ import generatedPageImage from './generated-page-image.png' -One page at `/site-notes` whose loader calls the plugin's own `hello` endpoint, its strings, and a Vitest test for the endpoint (`pnpm test` in the plugin). `package.json` gets a `description` and a `vitnode` field with the plugin's title and `assets/icon.svg`, which is how it appears in [the AdminCP plugin catalog](/docs/dev/plugins/catalog). The generator also registers the plugin in your apps' `vitnode.config.ts` and `vitnode.api.config.ts`, installs it and builds it. +One page at `/site-notes` whose loader calls the plugin's own `hello` endpoint, its strings, and a Vitest test for the endpoint (`pnpm test` in the plugin). `src/plugin-metadata.ts` sets the plugin's title and Lucide icon for [the AdminCP plugin catalog](/docs/dev/plugins/catalog). The generator also registers the plugin in your apps' `vitnode.config.ts` and `vitnode.api.config.ts`, installs it and builds it. The plugin name must be a valid npm name and must not be one of core's routes: `admin`, `api`, `core`, `discover`, `files`, `login`, `notifications`, `register`, `search`, `users` or `vitnode`. diff --git a/apps/web/src/plugin-inventory.gen.ts b/apps/web/src/plugin-inventory.gen.ts index 51ba4109c2..7fd76bf735 100644 --- a/apps/web/src/plugin-inventory.gen.ts +++ b/apps/web/src/plugin-inventory.gen.ts @@ -22,6 +22,7 @@ export const frontendPlugins: FrontendPluginInventory = [ metadata: { id: "@vitnode/blog", title: "Blog", + icon: "newspaper", description: "Blog plugin for VitNode, providing a blogging platform on Hono.js.", version: "2.0.0-canary.13", }, @@ -31,6 +32,7 @@ export const frontendPlugins: FrontendPluginInventory = [ metadata: { id: "@vitnode/example", title: "Example", + icon: "flask-conical", description: "Reference plugin exercising the VitNode Content Engine end to end.", version: "2.0.0-canary.13", }, @@ -40,6 +42,7 @@ export const frontendPlugins: FrontendPluginInventory = [ metadata: { id: "@vitnode/forum", title: "Forum", + icon: "messages-square", description: "Community forum for VitNode: structured forums and nested threads, built on the Content Engine.", version: "2.0.0-canary.13", }, diff --git a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/assets/icon.svg b/packages/create-vitnode-app/copy-of-vitnode-plugin/root/assets/icon.svg deleted file mode 100644 index 0aec6c4673..0000000000 --- a/packages/create-vitnode-app/copy-of-vitnode-plugin/root/assets/icon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/packages/create-vitnode-app/src/helpers/packages-json.ts b/packages/create-vitnode-app/src/helpers/packages-json.ts index 6c9003796d..fb975bbd31 100644 --- a/packages/create-vitnode-app/src/helpers/packages-json.ts +++ b/packages/create-vitnode-app/src/helpers/packages-json.ts @@ -13,7 +13,5 @@ export interface PackageJSON { scripts?: Record; type?: string; version?: string; - /** What AdminCP → System → Plugins shows for a plugin package. */ - vitnode?: { icon?: string; title?: string }; workspaces?: string[]; } diff --git a/packages/create-vitnode-app/src/plugin/create/create-package-json.ts b/packages/create-vitnode-app/src/plugin/create/create-package-json.ts index b2bfd3632f..e08fbfb1e9 100644 --- a/packages/create-vitnode-app/src/plugin/create/create-package-json.ts +++ b/packages/create-vitnode-app/src/plugin/create/create-package-json.ts @@ -6,11 +6,7 @@ import { versionsPackageJson } from "../../create/package-versions.js"; import { getVitnodePackageVersion } from "../../helpers/get-vitnode-package-version.js"; import { supportedNodeRange } from "../../helpers/node-version.js"; import { withIf } from "../../helpers/with-If.js"; -import { - PLUGIN_ICON_PATH, - pluginPackageExports, - pluginTitleFor, -} from "./route-templates.js"; +import { pluginPackageExports } from "./route-templates.js"; const writeJson = async (path: string, data: unknown) => writeFile(path, JSON.stringify(data, null, 2)); @@ -41,10 +37,6 @@ export const createPluginPackageJSON = async ({ name: pluginName, version: "0.1.0", description: "A VitNode plugin.", - vitnode: { - title: pluginTitleFor(pluginName), - icon: `./${PLUGIN_ICON_PATH}`, - }, private: true, type: "module", engines: { node: supportedNodeRange }, 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 ced1e9704b..a1a00ebdfe 100644 --- a/packages/create-vitnode-app/src/plugin/create/route-templates.ts +++ b/packages/create-vitnode-app/src/plugin/create/route-templates.ts @@ -12,10 +12,24 @@ export const pluginTitleFor = (pluginName: string): string => .join(" ") || pluginName; /** - * The icon `package.json` names as `vitnode.icon`, copied with the rest of - * `copy-of-vitnode-plugin/root`. + * `src/plugin-metadata.ts` - how AdminCP → System → Plugins shows the plugin. + * + * Written by hand and kept by the build: `vitnode build` imports it into the + * generated `plugin-metadata.gen.ts` next to the version and description it + * reads from `package.json`. + */ +export const pluginInfoTemplate = (pluginName: string): string => + `import type { PluginInfo } from "@vitnode/core/lib/plugin-metadata"; + +/** + * How AdminCP → System → Plugins shows this plugin. \`icon\` is a Lucide icon + * name - https://lucide.dev/icons. */ -export const PLUGIN_ICON_PATH = "assets/icon.svg"; +export const pluginInfo: PluginInfo = { + title: ${JSON.stringify(pluginTitleFor(pluginName))}, + icon: "puzzle", +}; +`; export const pluginConstTemplate = (pluginName: string): string => `export const CONFIG_PLUGIN = { @@ -165,10 +179,10 @@ export const pluginVariableName = (pluginName: string): string => { /** * `src/config.tsx` - what an application registers. * - * The routes, the locale files, the messages and the package metadata, and - * nothing else. `plugin-metadata.gen.ts` and `plugin-icon.gen.ts` are written - * from `package.json` by `vitnode build` and `vitnode dev`, so the version the - * AdminCP shows is never typed by hand. + * The routes, the locale files, the messages and the plugin metadata, and + * nothing else. `plugin-metadata.gen.ts` is written by `vitnode build` and + * `vitnode dev` from `package.json` and `src/plugin-metadata.ts`, so the + * version the AdminCP shows is never typed by hand. * * `routes` is the same tree `routes.ts` exports, handed on unchanged: an app on * Vite reads that file directly at build time and an app that registers the @@ -192,7 +206,6 @@ export const ${pluginVariableName(pluginName)} = () => buildPlugin({ pluginId: CONFIG_PLUGIN.pluginId, metadata: pluginMetadata, - icon: async () => await import("./plugin-icon.gen"), localeFiles: { en: "${pluginName}/locales/en.json", }, @@ -371,6 +384,7 @@ export const pluginRouteScaffold = ( "src/const.ts": pluginConstTemplate(pluginName), "src/locales/en.json": pluginMessagesTemplate(pluginName), "src/locales/index.ts": pluginMessagesBarrelTemplate(), + "src/plugin-metadata.ts": pluginInfoTemplate(pluginName), "src/pages/home-page.tsx": pluginRouteModuleTemplate(pluginName), "src/routes.ts": pluginRoutesTemplate(pluginName), }); diff --git a/packages/vitnode/scripts/cli/builder/compiler-build.ts b/packages/vitnode/scripts/cli/builder/compiler-build.ts index f99adbfaa4..066ec2324e 100644 --- a/packages/vitnode/scripts/cli/builder/compiler-build.ts +++ b/packages/vitnode/scripts/cli/builder/compiler-build.ts @@ -204,8 +204,8 @@ export const prepareRegistryStep = (project: Project, ui: Ui) => { }; /** - * Writes `src/plugin-metadata.gen.ts` and `src/plugin-icon.gen.ts` from the - * plugin's `package.json`, before tsdown and `tsc` resolve the imports of them. + * Writes `src/plugin-metadata.gen.ts` from the plugin's `package.json` and + * `src/plugin-metadata.ts`, before tsdown and `tsc` resolve the import of it. */ export const prepareMetadataStep = (project: Project, ui: Ui) => { if (!definesPlugin(project.root)) return; diff --git a/packages/vitnode/scripts/watch-plugin-metadata.test.ts b/packages/vitnode/scripts/watch-plugin-metadata.test.ts index cb5c7fc670..d38b1bd89e 100644 --- a/packages/vitnode/scripts/watch-plugin-metadata.test.ts +++ b/packages/vitnode/scripts/watch-plugin-metadata.test.ts @@ -10,7 +10,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - PLUGIN_ICON_PATH, + PLUGIN_INFO_PATH, PLUGIN_METADATA_PATH, } from "../src/framework/plugin-metadata/generate.js"; import { @@ -20,7 +20,6 @@ import { // Real `fs.watchFile` polling (every 300ms), so this lives in INTEGRATION. -const SVG = ''; const BLOG = { name: "@acme/blog", version: "1.0.0" }; let root: string; @@ -49,7 +48,6 @@ const watch = async () => { beforeEach(() => { root = mkdtempSync(join(tmpdir(), "vitnode-plugin-metadata-watch-")); mkdirSync(join(root, "src")); - mkdirSync(join(root, "assets")); write("package.json", JSON.stringify(BLOG)); }); @@ -59,36 +57,41 @@ afterEach(() => { }); describe("watchPluginMetadata", () => { - it("regenerates when package.json changes, then follows a newly declared icon", async () => { + it("regenerates when package.json changes and when src/plugin-metadata.ts comes and goes", async () => { const { onChange, onError } = await watch(); + const importsInfo = () => read(PLUGIN_METADATA_PATH).includes("pluginInfo"); - write("assets/icon.svg", SVG); - write( - "package.json", - JSON.stringify({ ...BLOG, vitnode: { icon: "./assets/icon.svg" } }), + write("package.json", JSON.stringify({ ...BLOG, version: "2.0.0" })); + await vi.waitFor( + () => { + expect(read(PLUGIN_METADATA_PATH)).toContain('"2.0.0"'); + }, + { timeout: 5000 }, ); + await settle(); + + write(PLUGIN_INFO_PATH, 'export const pluginInfo = { icon: "newspaper" };'); await vi.waitFor( () => { - expect(read(PLUGIN_ICON_PATH)).toContain("data:image/svg+xml"); + expect(importsInfo()).toBe(true); }, { timeout: 5000 }, ); - expect(onChange).toHaveBeenCalled(); await settle(); - write("assets/icon.svg", `${SVG}\n`); + rmSync(join(root, PLUGIN_INFO_PATH)); await vi.waitFor( () => { - expect(read(PLUGIN_ICON_PATH)).toContain( - Buffer.from(`${SVG}\n`).toString("base64"), - ); + expect(importsInfo()).toBe(false); }, { timeout: 5000 }, ); + + expect(onChange).toHaveBeenCalledTimes(3); expect(onError).not.toHaveBeenCalled(); }); - it("reports a broken edit, keeps the last files, and recovers", async () => { + it("reports a broken edit, keeps the last file, and recovers", async () => { const { onChange, onError } = await watch(); const metadata = read(PLUGIN_METADATA_PATH); diff --git a/packages/vitnode/scripts/write-plugin-metadata.test.ts b/packages/vitnode/scripts/write-plugin-metadata.test.ts index c735ac757e..0cfb741d97 100644 --- a/packages/vitnode/scripts/write-plugin-metadata.test.ts +++ b/packages/vitnode/scripts/write-plugin-metadata.test.ts @@ -4,7 +4,6 @@ import { mkdtempSync, readFileSync, rmSync, - symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -12,12 +11,10 @@ import { dirname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { - PLUGIN_ICON_PATH, + PLUGIN_INFO_PATH, PLUGIN_METADATA_PATH, } from "../src/framework/plugin-metadata/generate.js"; -import { writePluginMetadata } from "./write-plugin-metadata.js"; - -const SVG = ''; +import { definesPlugin, writePluginMetadata } from "./write-plugin-metadata.js"; const dirs: string[] = []; @@ -43,6 +40,14 @@ const writeManifest = (root: string, manifest: Record) => { write(root, "package.json", JSON.stringify(manifest, null, 2)); }; +const writeInfo = (root: string, info: Record) => { + write( + root, + PLUGIN_INFO_PATH, + `export const pluginInfo = ${JSON.stringify(info)};\n`, + ); +}; + const pluginAt = (manifest: Record) => { const root = tempDir(); mkdirSync(join(root, "src")); @@ -51,102 +56,131 @@ const pluginAt = (manifest: Record) => { return root; }; -const read = (root: string, path: string) => - existsSync(join(root, path)) ? readFileSync(join(root, path), "utf8") : null; +const BLOG = { description: "Posts", name: "@acme/blog", version: "1.0.0" }; -const BLOG = { - description: "Posts", - name: "@acme/blog", - version: "1.0.0", - vitnode: { icon: "./assets/icon.svg", title: "Blog" }, +/** What the generated module exports, evaluated for real. */ +const generated = async (root: string): Promise => { + const module = (await import(join(root, PLUGIN_METADATA_PATH))) as { + pluginMetadata: unknown; + }; + + return module.pluginMetadata; }; describe("writePluginMetadata", () => { - it("writes the metadata and the inlined icon from package.json", () => { + it("writes the metadata, and watches src/plugin-metadata.ts before it exists", () => { const root = pluginAt(BLOG); - write(root, "assets/icon.svg", SVG); expect(writePluginMetadata(root)).toEqual({ changed: true, - inputs: [join(root, "package.json"), join(root, "assets/icon.svg")], + inputs: [join(root, "package.json"), join(root, PLUGIN_INFO_PATH)], }); - expect(read(root, PLUGIN_METADATA_PATH)).toContain('"@acme/blog"'); - expect(read(root, PLUGIN_ICON_PATH)).toContain( - `data:image/svg+xml;base64,${Buffer.from(SVG).toString("base64")}`, - ); + expect(existsSync(join(root, PLUGIN_METADATA_PATH))).toBe(true); }); - it("writes a null icon and watches only package.json without one", () => { - const root = pluginAt({ name: "@acme/blog" }); - - expect(writePluginMetadata(root).inputs).toEqual([ - join(root, "package.json"), - ]); - expect(read(root, PLUGIN_ICON_PATH)).toContain("= null"); - }); - - it("leaves the files alone when nothing they show changed", () => { + it.each([ + ["no src/plugin-metadata.ts", undefined, "@acme/blog", null], + ["an empty plugin info", {}, "@acme/blog", null], + [ + "a title and an icon", + { icon: "newspaper", title: "Blog" }, + "Blog", + "newspaper", + ], + ])("takes the title and icon from %s", async (_, info, title, icon) => { const root = pluginAt(BLOG); - write(root, "assets/icon.svg", SVG); - writePluginMetadata(root); + if (info) writeInfo(root, info); - writeManifest(root, { ...BLOG, dependencies: { zod: "^4.0.0" } }); + writePluginMetadata(root); - expect(writePluginMetadata(root).changed).toBe(false); + expect(await generated(root)).toEqual({ + description: "Posts", + icon, + id: "@acme/blog", + title, + version: "1.0.0", + }); }); it.each([ - ["the version", { ...BLOG, version: "1.1.0" }, () => undefined], [ - "the icon", - BLOG, + "a new dependency", (root: string) => { - write(root, "assets/icon.svg", `${SVG}\n`); + writeManifest(root, { ...BLOG, dependencies: { zod: "^4.0.0" } }); }, + false, ], - ])("rewrites when %s changes", (_, manifest, edit) => { - const root = pluginAt(BLOG); - write(root, "assets/icon.svg", SVG); - writePluginMetadata(root); - - writeManifest(root, manifest); - edit(root); - - expect(writePluginMetadata(root).changed).toBe(true); - }); - - it.each([ [ - "an icon path leaving the package", + "a title edited in src/plugin-metadata.ts", (root: string) => { - writeManifest(root, { ...BLOG, vitnode: { icon: "../icon.svg" } }); + writeInfo(root, { title: "Journal" }); }, - /points outside the package/, + false, ], - ["a missing icon file", () => undefined, /there is no file there/], [ - "an icon linking outside the package", + "a new version", (root: string) => { - const outside = tempDir(); - write(outside, "icon.svg", SVG); - mkdirSync(join(root, "assets")); - symlinkSync(join(outside, "icon.svg"), join(root, "assets/icon.svg")); + writeManifest(root, { ...BLOG, version: "1.1.0" }); }, - /a link to a file outside the package/, + true, ], [ - "an icon that is not what its extension says", + "src/plugin-metadata.ts disappearing", (root: string) => { - write(root, "assets/icon.svg", "plain text"); + rmSync(join(root, PLUGIN_INFO_PATH)); }, - /is not a valid SVG file/, + true, ], - ])("refuses %s and writes neither file", (_, setup, error) => { + ])("after %s, reports changed: %s", (_, edit, changed) => { const root = pluginAt(BLOG); - setup(root); + writeInfo(root, { title: "Blog" }); + writePluginMetadata(root); + const before = readFileSync(join(root, PLUGIN_METADATA_PATH), "utf8"); + + edit(root); + + expect(writePluginMetadata(root).changed).toBe(changed); + expect( + readFileSync(join(root, PLUGIN_METADATA_PATH), "utf8") !== before, + ).toBe(changed); + }); + + it("reports src/plugin-metadata.ts appearing as a change", () => { + const root = pluginAt(BLOG); + writePluginMetadata(root); + + writeInfo(root, { icon: "newspaper" }); + + expect(writePluginMetadata(root).changed).toBe(true); + }); + + it("refuses a package without a name and writes nothing", () => { + const root = pluginAt({ version: "1.0.0" }); + + expect(() => writePluginMetadata(root)).toThrow(/has no `name`/); + expect(existsSync(join(root, PLUGIN_METADATA_PATH))).toBe(false); + }); +}); + +describe("definesPlugin", () => { + it.each([ + [true, "a plugin with src/config.tsx", "@acme/blog", "src/config.tsx"], + [true, "a plugin with src/config.ts", "@acme/blog", "src/config.ts"], + [ + false, + "core, despite its src/config.ts", + "@vitnode/core", + "src/config.ts", + ], + [false, "a package without a config", "@acme/utils", "src/index.ts"], + ])("is %s for %s", (expected, _, name, file) => { + const root = pluginAt({ name }); + write(root, file, "export {};\n"); + + expect(definesPlugin(root)).toBe(expected); + }); - expect(() => writePluginMetadata(root)).toThrow(error); - expect(read(root, PLUGIN_METADATA_PATH)).toBeNull(); - expect(read(root, PLUGIN_ICON_PATH)).toBeNull(); + it("is false for a folder without package.json", () => { + expect(definesPlugin(tempDir())).toBe(false); }); }); diff --git a/packages/vitnode/scripts/write-plugin-metadata.ts b/packages/vitnode/scripts/write-plugin-metadata.ts index 745c9c92e5..092ea5d3cb 100644 --- a/packages/vitnode/scripts/write-plugin-metadata.ts +++ b/packages/vitnode/scripts/write-plugin-metadata.ts @@ -1,29 +1,27 @@ import { existsSync, readFileSync, - realpathSync, - statSync, unwatchFile, watchFile, writeFileSync, } from "node:fs"; -import { isAbsolute, join, relative, resolve } from "node:path"; +import { join, relative } from "node:path"; import { - generatePluginIconSource, generatePluginMetadataSource, - PLUGIN_ICON_PATH, + PLUGIN_INFO_PATH, PLUGIN_METADATA_PATH, - pluginIconDataUrl, pluginMetadataFromManifest, } from "../src/framework/plugin-metadata/generate.js"; import { CORE_PLUGIN_ID } from "../src/framework/plugin-routes/core.js"; -import { PLUGIN_METADATA_ERROR_PREFIX as ERROR_PREFIX } from "../src/lib/plugin-metadata.js"; export interface PluginMetadataWrite { - /** Whether either generated file changed on disk. */ + /** Whether the generated file changed on disk. */ changed: boolean; - /** The files the output depends on: `package.json`, and the icon if any. */ + /** + * What the output depends on: `package.json`, and whether + * `src/plugin-metadata.ts` exists - watched even while it does not. + */ inputs: string[]; } @@ -50,93 +48,43 @@ export const definesPlugin = (root: string): boolean => { ); }; -const isInside = (root: string, path: string): boolean => { - const rel = relative(root, path); - - return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); -}; - /** - * The declared icon, read only once it is proven to live inside the package - - * by its path *and* by where its symlinks lead, since a published package - * carries the file and nothing outside it. - */ -const readIcon = (root: string, declared: string, source: string) => { - const path = resolve(root, declared); - - if (!isInside(root, path)) { - throw new Error( - `${ERROR_PREFIX} \`vitnode.icon\` in ${source} is ${JSON.stringify(declared)}, which points outside the package. Keep the icon inside it, such as "./assets/icon.svg".`, - ); - } - - if (!existsSync(path) || !statSync(path).isFile()) { - throw new Error( - `${ERROR_PREFIX} \`vitnode.icon\` in ${source} is ${JSON.stringify(declared)}, and there is no file there.`, - ); - } - - if (!isInside(realpathSync(root), realpathSync(path))) { - throw new Error( - `${ERROR_PREFIX} \`vitnode.icon\` in ${source} is ${JSON.stringify(declared)}, a link to a file outside the package. Copy the icon into the package instead.`, - ); - } - - return { bytes: readFileSync(path), path }; -}; - -const writeIfChanged = (path: string, source: string): boolean => { - if (existsSync(path) && readFileSync(path, "utf8") === source) return false; - - writeFileSync(path, source, "utf8"); - - return true; -}; - -/** - * `src/plugin-metadata.gen.ts` and `src/plugin-icon.gen.ts`, from the plugin's - * own `package.json`. + * `src/plugin-metadata.gen.ts`, from the plugin's own `package.json` and + * `src/plugin-metadata.ts`. * * Synchronous and before the compilers, like the API registry: `tsc` and tsdown - * must find both modules on their first pass. A file is only rewritten when its - * bytes change, so an edit to an unrelated `package.json` field - a new - * dependency - rebuilds nothing. + * must find the module on their first pass. The title and icon are imported + * from `src/plugin-metadata.ts` rather than copied, so editing them rebuilds + * like any other source; this only rewrites the file when the package fields + * change or that module appears or disappears - an edit to an unrelated + * `package.json` field, such as a new dependency, rebuilds nothing. */ export const writePluginMetadata = ( root: string = process.cwd(), ): PluginMetadataWrite => { const manifestPath = join(root, "package.json"); + const infoPath = join(root, PLUGIN_INFO_PATH); const source = relative(process.cwd(), manifestPath) || "package.json"; - const { icon, metadata } = pluginMetadataFromManifest( + const metadata = pluginMetadataFromManifest( JSON.parse(readFileSync(manifestPath, "utf8")) as unknown, source, ); - const iconFile = icon === null ? null : readIcon(root, icon, source); - // Both checked before either file is written, so a broken icon never leaves - // new metadata next to a stale icon. - const iconSource = generatePluginIconSource( - icon === null || iconFile === null - ? null - : pluginIconDataUrl(icon, iconFile.bytes, source), - ); + const path = join(root, PLUGIN_METADATA_PATH); + const output = generatePluginMetadataSource(metadata, { + hasInfo: existsSync(infoPath), + }); + const changed = !existsSync(path) || readFileSync(path, "utf8") !== output; - const changedMetadata = writeIfChanged( - join(root, PLUGIN_METADATA_PATH), - generatePluginMetadataSource(metadata), - ); - const changedIcon = writeIfChanged(join(root, PLUGIN_ICON_PATH), iconSource); + if (changed) writeFileSync(path, output, "utf8"); - return { - changed: changedMetadata || changedIcon, - inputs: [manifestPath, ...(iconFile === null ? [] : [iconFile.path])], - }; + return { changed, inputs: [manifestPath, infoPath] }; }; const POLL_MS = 300; /** * Regenerates the metadata while `vitnode dev` runs, whenever `package.json` - * or the declared icon changes. + * changes or `src/plugin-metadata.ts` is created or deleted. * * Polled rather than `fs.watch`ed: editors and package managers replace * `package.json` instead of writing into it, which ends a native watcher. Only diff --git a/packages/vitnode/src/api/lib/installed-plugins.test.ts b/packages/vitnode/src/api/lib/installed-plugins.test.ts index 6b3dc54193..edd495f982 100644 --- a/packages/vitnode/src/api/lib/installed-plugins.test.ts +++ b/packages/vitnode/src/api/lib/installed-plugins.test.ts @@ -9,6 +9,7 @@ const metadata = ( overrides: Partial = {}, ): PluginMetadata => ({ description: `${id} description`, + icon: null, id, title: `${id} title`, version: "1.0.0", @@ -45,6 +46,7 @@ describe("buildInstalledPluginRegistry", () => { expect(plugin).toEqual({ description: "@acme/blog description", + icon: null, id: "@acme/blog", registrations: { api: { version: "1.0.0" }, @@ -96,6 +98,27 @@ describe("buildInstalledPluginRegistry", () => { }, ); + it.each([ + [ + "the API's metadata first", + metadata("@acme/blog", { icon: "rss" }), + "rss", + ], + ["the frontend's when the API has no metadata", undefined, "newspaper"], + ])("takes the icon from %s", (_, apiMetadata, icon) => { + const [plugin] = buildInstalledPluginRegistry({ + api: [{ metadata: apiMetadata, pluginId: "@acme/blog" }], + frontend: [ + { + id: "@acme/blog", + metadata: metadata("@acme/blog", { icon: "newspaper" }), + }, + ], + }).plugins; + + expect(plugin?.icon).toBe(icon); + }); + it("treats a side without metadata as unknown, not a mismatch", () => { const [plugin] = buildInstalledPluginRegistry({ api: [{ pluginId: "@acme/blog" }], @@ -118,6 +141,7 @@ describe("buildInstalledPluginRegistry", () => { expect(plugin).toEqual({ description: null, + icon: null, id: "@acme/blog", registrations: { api: null, frontend: { version: null } }, title: "@acme/blog", diff --git a/packages/vitnode/src/api/lib/installed-plugins.ts b/packages/vitnode/src/api/lib/installed-plugins.ts index 28feba9b82..ba92f782b9 100644 --- a/packages/vitnode/src/api/lib/installed-plugins.ts +++ b/packages/vitnode/src/api/lib/installed-plugins.ts @@ -4,7 +4,7 @@ import type { } from "@/lib/plugin-metadata"; import { CONFIG_PLUGIN } from "@/config"; -import { assertPluginMetadataId } from "@/lib/plugin-metadata"; +import { assertPluginMetadata } from "@/lib/plugin-metadata"; /** One side's registration of a plugin, as far as the catalog cares. */ export interface InstalledPluginRegistration { @@ -14,6 +14,8 @@ export interface InstalledPluginRegistration { export interface InstalledPlugin { description: null | string; + /** A Lucide icon name, or `null` for the default icon. */ + icon: null | string; id: string; registrations: { api: InstalledPluginRegistration | null; @@ -55,7 +57,7 @@ const byId = ( ); } - assertPluginMetadataId(id, metadata ?? undefined, `The ${side} config`); + assertPluginMetadata(id, metadata ?? undefined, `The ${side} config`); map.set(id, { metadata }); } @@ -109,6 +111,7 @@ export const buildInstalledPluginRegistry = ({ return { description: metadata?.description ?? null, + icon: metadata?.icon ?? null, id, registrations: { api: registrationOf(fromApi), diff --git a/packages/vitnode/src/api/lib/plugin.test.ts b/packages/vitnode/src/api/lib/plugin.test.ts index c23dc7d663..3c3b376e86 100644 --- a/packages/vitnode/src/api/lib/plugin.test.ts +++ b/packages/vitnode/src/api/lib/plugin.test.ts @@ -262,6 +262,7 @@ describe("buildApiPlugin metadata", () => { buildApiPlugin({ metadata: { description: null, + icon: null, id: "@vitnode/blog", title: "Blog", version: "1.0.0", diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index be6647e8c7..d24b334441 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -25,7 +25,7 @@ import { } from "@/content/registry"; import { contentDefinitionOf } from "@/content/server/model"; import { contentReportTarget } from "@/content/server/report-target"; -import { assertPluginMetadataId } from "@/lib/plugin-metadata"; +import { assertPluginMetadata } from "@/lib/plugin-metadata"; import type { SearchIndexer } from "../models/search"; import type { AnyAiActionDefinition } from "./ai/action"; @@ -109,8 +109,8 @@ export function buildApiPlugin< messages?: LocaleMessagesMap; /** * `pluginMetadata` from the plugin's generated `src/plugin-metadata.gen.ts`: - * the title, description and installed version AdminCP → System → Plugins - * lists. Without it the plugin is listed by id with an unknown version. + * the title, icon, description and installed version AdminCP → System → + * Plugins lists. Without it the plugin is listed by id with an unknown version. */ metadata?: PluginMetadata; moderation?: ModerationPluginConfig; @@ -123,7 +123,7 @@ export function buildApiPlugin< }): BuildPluginApiReturn { // Run for checking if the plugin is valid checkPluginId(pluginId); - assertPluginMetadataId(pluginId, metadata, "buildApiPlugin()"); + assertPluginMetadata(pluginId, metadata, "buildApiPlugin()"); if (blocks && blocks.pluginId !== pluginId) { throw new BlockError( diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/list.route.test.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/list.route.test.ts index 8201762cd0..d6cda2d283 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/list.route.test.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/list.route.test.ts @@ -19,6 +19,7 @@ const installedPlugins = buildInstalledPluginRegistry({ { metadata: { description: "Posts and categories", + icon: "newspaper", id: "@acme/blog", title: "Blog", version: "2.0.0", @@ -31,6 +32,7 @@ const installedPlugins = buildInstalledPluginRegistry({ id: "@acme/blog", metadata: { description: "Posts and categories", + icon: "newspaper", id: "@acme/blog", title: "Blog", version: "1.0.0", diff --git a/packages/vitnode/src/api/modules/admin/plugins/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/plugins/routes/list.route.ts index e9d377d5d1..5d988c1334 100644 --- a/packages/vitnode/src/api/modules/admin/plugins/routes/list.route.ts +++ b/packages/vitnode/src/api/modules/admin/plugins/routes/list.route.ts @@ -30,6 +30,8 @@ export const listPluginsAdminRoute = buildRoute({ z.object({ id: z.string(), title: z.string(), + /** A Lucide icon name, `null` for the default icon. */ + icon: z.string().nullable(), description: z.string().nullable(), version: z.string().nullable(), versionMismatch: z.boolean(), diff --git a/packages/vitnode/src/components/ui/dynamic-icon.test.tsx b/packages/vitnode/src/components/ui/dynamic-icon.test.tsx index 769278c52a..91d7f1b13f 100644 --- a/packages/vitnode/src/components/ui/dynamic-icon.test.tsx +++ b/packages/vitnode/src/components/ui/dynamic-icon.test.tsx @@ -76,3 +76,17 @@ test("renders nothing for an unknown icon", async () => { expect(html).toBe(""); }); + +test("renders `missing` for an unknown icon", async () => { + const html = await renderToHtml( + + } + missing={} + name="not-a-real-icon" + /> + , + ); + + expect(html).toBe(''); +}); diff --git a/packages/vitnode/src/components/ui/dynamic-icon.tsx b/packages/vitnode/src/components/ui/dynamic-icon.tsx index a8511557af..68ac4783df 100644 --- a/packages/vitnode/src/components/ui/dynamic-icon.tsx +++ b/packages/vitnode/src/components/ui/dynamic-icon.tsx @@ -20,9 +20,15 @@ export interface DynamicIconProps { export const DynamicIcon = ({ className, fallback, + missing = null, name, ...props -}: DynamicIconProps & { fallback?: React.ReactNode }) => { +}: DynamicIconProps & { + /** Shown while the icon loads. */ + fallback?: React.ReactNode; + /** Shown when Lucide has no icon by that name. Nothing by default. */ + missing?: React.ReactNode; +}) => { const collector = React.use(LucideIconCollectorContext); const icon = React.useSyncExternalStore( subscribeLucideIcons, @@ -46,7 +52,7 @@ export const DynamicIcon = ({ collector?.collect(name, icon); - if (icon === null) return null; + if (icon === null) return <>{missing}; return ; }; diff --git a/packages/vitnode/src/framework/plugin-inventory/generate.test.ts b/packages/vitnode/src/framework/plugin-inventory/generate.test.ts index 21f33765c2..f49f2a1adc 100644 --- a/packages/vitnode/src/framework/plugin-inventory/generate.test.ts +++ b/packages/vitnode/src/framework/plugin-inventory/generate.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import type { FrontendPluginInventoryEntry } from "../../lib/plugin-metadata.js"; @@ -11,6 +14,7 @@ const SOURCE = "src/vitnode.config.ts"; const BLOG_METADATA = { description: "Posts", + icon: "newspaper", id: "@acme/blog", title: "Blog", version: "1.0.0", @@ -35,6 +39,7 @@ describe("pluginInventoryFromLoadedConfig", () => { it.each([ ["a string", "Blog"], ["a missing title", { ...BLOG_METADATA, title: undefined }], + ["a missing icon", { ...BLOG_METADATA, icon: undefined }], ["a numeric version", { ...BLOG_METADATA, version: 1 }], ["an undefined description", { ...BLOG_METADATA, description: undefined }], ])("rejects metadata that is %s", (_, metadata) => { @@ -59,28 +64,36 @@ describe("pluginInventoryFromLoadedConfig", () => { }); describe("generatePluginInventorySource", () => { + const blog: FrontendPluginInventoryEntry = { + id: "@acme/blog", + metadata: BLOG_METADATA, + }; + const shop: FrontendPluginInventoryEntry = { + id: "@acme/shop", + metadata: null, + }; + const forum: FrontendPluginInventoryEntry = { + id: "@acme/forum", + metadata: { ...BLOG_METADATA, icon: null, id: "@acme/forum" }, + }; + it("writes the same bytes whatever order the plugins are configured in", () => { - const blog: FrontendPluginInventoryEntry = { - id: "@acme/blog", - metadata: BLOG_METADATA, - }; - const shop: FrontendPluginInventoryEntry = { - id: "@acme/shop", - metadata: null, - }; - const forum: FrontendPluginInventoryEntry = { - id: "@acme/forum", - metadata: null, - }; + expect(generatePluginInventorySource([forum, shop, blog])).toBe( + generatePluginInventorySource([shop, blog, forum]), + ); + }); - const source = generatePluginInventorySource([shop, blog, forum]); + it("evaluates to the entries, sorted by id", async () => { + const dir = mkdtempSync(join(tmpdir(), "vitnode-plugin-inventory-")); + const path = join(dir, "plugin-inventory.gen.ts"); + writeFileSync(path, generatePluginInventorySource([shop, blog, forum])); - expect(generatePluginInventorySource([forum, shop, blog])).toBe(source); - expect(source.indexOf("@acme/blog")).toBeLessThan( - source.indexOf("@acme/forum"), - ); - expect(source.indexOf("@acme/forum")).toBeLessThan( - source.indexOf("@acme/shop"), - ); + try { + const module = (await import(path)) as { frontendPlugins: unknown }; + + expect(module.frontendPlugins).toEqual([blog, forum, shop]); + } finally { + rmSync(dir, { force: true, recursive: true }); + } }); }); diff --git a/packages/vitnode/src/framework/plugin-inventory/generate.ts b/packages/vitnode/src/framework/plugin-inventory/generate.ts index d78479abd7..455c5cba5d 100644 --- a/packages/vitnode/src/framework/plugin-inventory/generate.ts +++ b/packages/vitnode/src/framework/plugin-inventory/generate.ts @@ -3,7 +3,7 @@ import type { PluginMetadata, } from "../../lib/plugin-metadata.js"; -import { assertPluginMetadataId } from "../../lib/plugin-metadata.js"; +import { assertPluginMetadata } from "../../lib/plugin-metadata.js"; import { PLUGIN_ROUTES_ERROR_PREFIX as ERROR_PREFIX } from "../plugin-routes/diagnostics.js"; /** Where the plugin metadata a configured plugin registers comes from. */ @@ -26,6 +26,7 @@ const metadataFrom = ( !isRecord(value) || typeof value.id !== "string" || typeof value.title !== "string" || + !isNullableString(value.icon) || !isNullableString(value.description) || !isNullableString(value.version) ) { @@ -36,11 +37,12 @@ const metadataFrom = ( const metadata: PluginMetadata = { description: value.description, + icon: value.icon, id: value.id, title: value.title, version: value.version, }; - assertPluginMetadataId(pluginId, metadata, where); + assertPluginMetadata(pluginId, metadata, where); return metadata; }; @@ -96,6 +98,7 @@ const entrySource = ({ id, metadata }: FrontendPluginInventoryEntry): string => metadata: { id: ${literal(metadata.id)}, title: ${literal(metadata.title)}, + icon: ${literal(metadata.icon)}, description: ${literal(metadata.description)}, version: ${literal(metadata.version)}, }, diff --git a/packages/vitnode/src/framework/plugin-metadata/generate.test.ts b/packages/vitnode/src/framework/plugin-metadata/generate.test.ts index 64bdd787a0..84c5ec8c11 100644 --- a/packages/vitnode/src/framework/plugin-metadata/generate.test.ts +++ b/packages/vitnode/src/framework/plugin-metadata/generate.test.ts @@ -1,68 +1,37 @@ import { describe, expect, it } from "vitest"; -import { - assertIconPath, - PLUGIN_ICON_MAX_BYTES, - pluginIconDataUrl, - pluginMetadataFromManifest, -} from "./generate.js"; +import { pluginMetadataFromManifest } from "./generate.js"; const SOURCE = "package.json"; -const bytes = (...values: number[]) => new Uint8Array(values); -const text = (value: string) => new TextEncoder().encode(value); - -const WEBP = new Uint8Array([ - ...text("RIFF"), - 0x24, - 0x00, - 0x00, - 0x00, - ...text("WEBP"), -]); - describe("pluginMetadataFromManifest", () => { - it("reads the catalog fields, trimmed, with the declared icon path", () => { + it("reads the package fields, trimmed, and ignores everything else", () => { expect( pluginMetadataFromManifest( { description: " Posts and categories ", name: "@acme/blog", version: "1.2.0", - vitnode: { icon: "./assets/icon.svg", title: " Blog " }, + vitnode: { icon: "./assets/icon.svg", title: "Blog" }, }, SOURCE, ), ).toEqual({ - icon: "./assets/icon.svg", - metadata: { - description: "Posts and categories", - id: "@acme/blog", - title: "Blog", - version: "1.2.0", - }, + description: "Posts and categories", + id: "@acme/blog", + version: "1.2.0", }); }); it.each([ - ["no vitnode field", { name: "@acme/blog" }], - [ - "blank description and version", - { description: " ", name: "@acme/blog", version: "" }, - ], - [ - "null optional fields", - { description: null, name: "@acme/blog", version: null }, - ], - ])("falls back to the id and unknown values with %s", (_, manifest) => { + ["missing", { name: "@acme/blog" }], + ["blank", { description: " ", name: "@acme/blog", version: "" }], + ["null", { description: null, name: "@acme/blog", version: null }], + ])("reads %s optional fields as unknown", (_, manifest) => { expect(pluginMetadataFromManifest(manifest, SOURCE)).toEqual({ - icon: null, - metadata: { - description: null, - id: "@acme/blog", - title: "@acme/blog", - version: null, - }, + description: null, + id: "@acme/blog", + version: null, }); }); @@ -76,29 +45,9 @@ describe("pluginMetadataFromManifest", () => { /`version`.*has to be a string/, ], [ - "a non-object vitnode field", - { name: "@acme/blog", vitnode: "Blog" }, - /`vitnode`.*has to be an object/, - ], - [ - "an array vitnode field", - { name: "@acme/blog", vitnode: [] }, - /`vitnode`.*has to be an object/, - ], - [ - "an empty title", - { name: "@acme/blog", vitnode: { title: " " } }, - /`vitnode.title`.*is empty/, - ], - [ - "an empty icon", - { name: "@acme/blog", vitnode: { icon: "" } }, - /`vitnode.icon`.*is empty/, - ], - [ - "a URL icon", - { name: "@acme/blog", vitnode: { icon: "https://x.dev/a.svg" } }, - /a URL/, + "a non-string description", + { description: ["Posts"], name: "@acme/blog" }, + /`description`.*has to be a string/, ], ])("rejects %s", (_, manifest, error) => { expect(() => pluginMetadataFromManifest(manifest, SOURCE)).toThrow(error); @@ -110,80 +59,3 @@ describe("pluginMetadataFromManifest", () => { ).toThrow(/^\[VitNode plugin metadata\] plugins\/blog\/package.json/); }); }); - -describe("assertIconPath", () => { - it.each([ - "./assets/icon.svg", - "assets/icon.png", - "icon.webp", - "./icon.jpg", - "./ICON.JPEG", - ])("accepts %s", path => { - expect(() => { - assertIconPath(path, SOURCE); - }).not.toThrow(); - }); - - it.each([ - ["https://cdn.example.com/icon.svg", /a URL/], - ["data:image/svg+xml;base64,PHN2Zz4=", /a URL/], - ["file:///tmp/icon.svg", /a URL/], - ["//cdn.example.com/icon.svg", /a URL/], - ["/etc/icon.svg", /absolute path/], - ["\\icons\\icon.svg", /absolute path/], - ["./assets/icon.gif", /Supported icon formats/], - ["./assets/icon", /Supported icon formats/], - ])("rejects %s", (path, error) => { - expect(() => { - assertIconPath(path, SOURCE); - }).toThrow(error); - }); -}); - -describe("pluginIconDataUrl", () => { - it.each([ - [ - "icon.svg", - text(''), - "image/svg+xml", - ], - ["icon.png", bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a), "image/png"], - ["icon.jpg", bytes(0xff, 0xd8, 0xff, 0xe0), "image/jpeg"], - ["icon.jpeg", bytes(0xff, 0xd8, 0xff, 0xe0), "image/jpeg"], - ["icon.webp", WEBP, "image/webp"], - ])("inlines %s as a %s data URL", (path, content, type) => { - expect(pluginIconDataUrl(path, content, SOURCE)).toBe( - `data:${type};base64,${Buffer.from(content).toString("base64")}`, - ); - }); - - it.each([ - ["icon.svg", text("")], - ["icon.png", text('')], - ["icon.jpg", bytes(0x89, 0x50, 0x4e, 0x47)], - [ - "icon.webp", - new Uint8Array([...text("RIFF"), 0, 0, 0, 0, ...text("WAVE")]), - ], - ])("refuses %s whose bytes are another format", (path, content) => { - expect(() => pluginIconDataUrl(path, content, SOURCE)).toThrow( - /is not a valid/, - ); - }); - - it("refuses an icon over the size limit, and keeps one at it", () => { - const svg = (size: number) => { - const content = new Uint8Array(size).fill(0x20); - content.set(text("")); - - return content; - }; - - expect(() => - pluginIconDataUrl("icon.svg", svg(PLUGIN_ICON_MAX_BYTES + 1), SOURCE), - ).toThrow(/bytes/); - expect( - pluginIconDataUrl("icon.svg", svg(PLUGIN_ICON_MAX_BYTES), SOURCE), - ).toMatch(/^data:image\/svg\+xml;base64,/); - }); -}); diff --git a/packages/vitnode/src/framework/plugin-metadata/generate.ts b/packages/vitnode/src/framework/plugin-metadata/generate.ts index 00d47271ef..f1b8c64e45 100644 --- a/packages/vitnode/src/framework/plugin-metadata/generate.ts +++ b/packages/vitnode/src/framework/plugin-metadata/generate.ts @@ -5,34 +5,14 @@ import { PLUGIN_METADATA_ERROR_PREFIX as ERROR_PREFIX } from "../../lib/plugin-m /** Where `vitnode build` and `vitnode dev` write a plugin's metadata, relative to its root. */ export const PLUGIN_METADATA_PATH = "src/plugin-metadata.gen.ts"; -/** The browser-only twin: the icon, as a `data:` URL the AdminCP lazy-loads. */ -export const PLUGIN_ICON_PATH = "src/plugin-icon.gen.ts"; +/** The plugin's own title and icon, written by hand - see `PluginInfo`. */ +export const PLUGIN_INFO_PATH = "src/plugin-metadata.ts"; -/** - * The largest icon a plugin may declare, in bytes. - * - * The icon is inlined into the generated module, so it travels with the - * package wherever it is installed - no asset pipeline, no `node_modules` path - * for a server to read. Small is the point: it is shown at 40px. - */ -export const PLUGIN_ICON_MAX_BYTES = 32 * 1024; - -const ICON_TYPES: Readonly> = { - ".jpeg": "image/jpeg", - ".jpg": "image/jpeg", - ".png": "image/png", - ".svg": "image/svg+xml", - ".webp": "image/webp", -}; - -export const PLUGIN_ICON_EXTENSIONS = Object.keys(ICON_TYPES); - -/** What a plugin's `package.json` declares, validated and with defaults applied. */ -export interface PluginManifestMetadata { - /** The declared icon path, exactly as written, or `null` for none. */ - icon: null | string; - metadata: PluginMetadata; -} +/** What the generated module reads from `package.json`. */ +export type PluginPackageMetadata = Pick< + PluginMetadata, + "description" | "id" | "version" +>; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); @@ -55,16 +35,14 @@ const optionalText = ( }; /** - * Reads the catalog's fields out of a parsed `package.json`. - * - * `name` is required - it is the plugin id. Everything else is optional and - * falls back to what the AdminCP can honestly show: the id as the title, and - * `null` for a description or version the package does not declare. + * Reads the catalog's fields out of a parsed `package.json`: `name` - required, + * it is the plugin id - and the optional `description` and `version`, `null` + * when the package does not declare them. */ export const pluginMetadataFromManifest = ( manifest: unknown, source: string, -): PluginManifestMetadata => { +): PluginPackageMetadata => { if (!isRecord(manifest)) { throw new Error(`${ERROR_PREFIX} ${source} is not a JSON object.`); } @@ -76,164 +54,41 @@ export const pluginMetadataFromManifest = ( ); } - const { vitnode } = manifest; - if (vitnode !== undefined && !isRecord(vitnode)) { - throw new Error( - `${ERROR_PREFIX} \`vitnode\` in ${source} has to be an object - for example \`{ "title": "Blog", "icon": "./assets/icon.svg" }\`.`, - ); - } - - const title = optionalText(vitnode?.title, "vitnode.title", source); - if (vitnode?.title !== undefined && title === null) { - throw new Error( - `${ERROR_PREFIX} \`vitnode.title\` in ${source} is empty. Remove it to fall back to the package name.`, - ); - } - - const icon = optionalText(vitnode?.icon, "vitnode.icon", source); - if (vitnode?.icon !== undefined && icon === null) { - throw new Error( - `${ERROR_PREFIX} \`vitnode.icon\` in ${source} is empty. Remove it to use the default icon.`, - ); - } - if (icon !== null) assertIconPath(icon, source); - return { - icon, - metadata: { - description: optionalText(manifest.description, "description", source), - id, - title: title ?? id, - version: optionalText(manifest.version, "version", source), - }, + description: optionalText(manifest.description, "description", source), + id, + version: optionalText(manifest.version, "version", source), }; }; -const extensionOf = (path: string): string => { - const dot = path.lastIndexOf("."); - - return dot === -1 ? "" : path.slice(dot).toLowerCase(); -}; - -/** - * The checks an icon path passes before anything is read: a relative path to a - * supported image. Whether it stays inside the package is decided against the - * file system by the caller, which can follow symlinks. - */ -export const assertIconPath = (path: string, source: string): void => { - if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith("//")) { - throw new Error( - `${ERROR_PREFIX} \`vitnode.icon\` in ${source} is ${JSON.stringify(path)}, a URL. Point it at an image file inside the package, such as "./assets/icon.svg" - VitNode never fetches icons.`, - ); - } - - if (path.startsWith("/") || path.startsWith("\\")) { - throw new Error( - `${ERROR_PREFIX} \`vitnode.icon\` in ${source} is the absolute path ${JSON.stringify(path)}. Use a path relative to package.json, such as "./assets/icon.svg".`, - ); - } - - if (!(extensionOf(path) in ICON_TYPES)) { - throw new Error( - `${ERROR_PREFIX} \`vitnode.icon\` in ${source} is ${JSON.stringify(path)}. Supported icon formats are ${PLUGIN_ICON_EXTENSIONS.join(", ")}.`, - ); - } -}; - -const startsWith = (bytes: Uint8Array, signature: readonly number[]) => - signature.every((byte, index) => bytes[index] === byte); - -const looksLike = (extension: string, bytes: Uint8Array): boolean => { - switch (extension) { - case ".jpeg": - case ".jpg": - return startsWith(bytes, [0xff, 0xd8, 0xff]); - case ".png": - return startsWith(bytes, [0x89, 0x50, 0x4e, 0x47]); - case ".svg": - return /]/i.test(new TextDecoder().decode(bytes)); - case ".webp": - return ( - startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && - new TextDecoder().decode(bytes.subarray(8, 12)) === "WEBP" - ); - default: - return false; - } -}; - -/** - * The icon's bytes as a `data:` URL, after checking they are what the - * extension claims and small enough to inline. - * - * Rendered with ``, never as markup, so an SVG's scripts and external - * references cannot run in the AdminCP. - */ -export const pluginIconDataUrl = ( - path: string, - bytes: Uint8Array, - source: string, -): string => { - if (bytes.byteLength > PLUGIN_ICON_MAX_BYTES) { - throw new Error( - `${ERROR_PREFIX} The icon ${JSON.stringify(path)} declared in ${source} is ${String(bytes.byteLength)} bytes. Icons are inlined into the build, so keep them under ${String(PLUGIN_ICON_MAX_BYTES / 1024)} KB - an SVG usually is.`, - ); - } - - const extension = extensionOf(path); - if (!looksLike(extension, bytes)) { - throw new Error( - `${ERROR_PREFIX} The icon ${JSON.stringify(path)} declared in ${source} is not a valid ${extension.slice(1).toUpperCase()} file.`, - ); - } - - return `data:${ICON_TYPES[extension]};base64,${Buffer.from(bytes).toString("base64")}`; -}; - -const HEADER = `/* oxlint-disable */ - -// This file is generated by VitNode. Do not edit it, and do not format it. -// -// It is rewritten by \`vitnode build\` and \`vitnode dev\` from this package's -// package.json: \`name\`, \`version\`, \`description\` and the \`vitnode\` field. -`; - const literal = (value: null | string): string => value === null ? "null" : JSON.stringify(value); /** * `src/plugin-metadata.gen.ts` - what the AdminCP plugin catalog lists. * - * Plain data, imported by both registrations - `buildPlugin({ metadata })` and + * The package's own fields as literals, plus the title and icon of the + * plugin's `src/plugin-metadata.ts` when it has one. Plain data, imported by + * both registrations - `buildPlugin({ metadata })` and * `buildApiPlugin({ metadata })` - so neither copies a version by hand. */ export const generatePluginMetadataSource = ( - metadata: PluginMetadata, -): string => `${HEADER}// -// Register \`pluginMetadata\` as \`metadata\` in both \`buildPlugin\` and -// \`buildApiPlugin\`. + metadata: PluginPackageMetadata, + { hasInfo }: { hasInfo: boolean }, +): string => `/* oxlint-disable */ +// This file is generated by VitNode. Do not edit it, and do not format it. +// +// It is rewritten by \`vitnode build\` and \`vitnode dev\` from this package's +// package.json - \`name\`, \`version\` and \`description\` - and the title and +// icon in \`src/plugin-metadata.ts\`. Register \`pluginMetadata\` as \`metadata\` +// in both \`buildPlugin\` and \`buildApiPlugin\`. +${hasInfo ? `\nimport { pluginInfo } from "./plugin-metadata"\n` : ""} export const pluginMetadata = { id: ${literal(metadata.id)}, - title: ${literal(metadata.title)}, + title: ${hasInfo ? `pluginInfo.title ?? ${literal(metadata.id)}` : literal(metadata.id)}, + icon: ${hasInfo ? "pluginInfo.icon ?? null" : "null"}, description: ${literal(metadata.description)}, version: ${literal(metadata.version)}, } as const `; - -/** - * `src/plugin-icon.gen.ts` - the icon, for the browser only. - * - * Written even when no icon is declared, as `null`, so removing the icon never - * breaks the `import()` that loads it. - */ -export const generatePluginIconSource = ( - dataUrl: null | string, -): string => `${HEADER}// -// Loaded lazily by \`buildPlugin({ icon: () => import("./plugin-icon.gen") })\`, -// so the bytes reach a browser only on the AdminCP screen that shows them. - -const icon: null | string = ${literal(dataUrl)} - -export default icon -`; diff --git a/packages/vitnode/src/framework/plugin-metadata/index.ts b/packages/vitnode/src/framework/plugin-metadata/index.ts index 2ecb44ec31..37844e8b39 100644 --- a/packages/vitnode/src/framework/plugin-metadata/index.ts +++ b/packages/vitnode/src/framework/plugin-metadata/index.ts @@ -1,12 +1,7 @@ export { - assertIconPath, - generatePluginIconSource, generatePluginMetadataSource, - PLUGIN_ICON_EXTENSIONS, - PLUGIN_ICON_MAX_BYTES, - PLUGIN_ICON_PATH, + PLUGIN_INFO_PATH, PLUGIN_METADATA_PATH, - pluginIconDataUrl, pluginMetadataFromManifest, } from "./generate.js"; -export type { PluginManifestMetadata } from "./generate.js"; +export type { PluginPackageMetadata } from "./generate.js"; diff --git a/packages/vitnode/src/lib/plugin-metadata.ts b/packages/vitnode/src/lib/plugin-metadata.ts index 025a96af93..5d5990c9a7 100644 --- a/packages/vitnode/src/lib/plugin-metadata.ts +++ b/packages/vitnode/src/lib/plugin-metadata.ts @@ -1,47 +1,62 @@ +import { isLucideIconName } from "./emoji-icon"; + +/** + * How AdminCP → System → Plugins presents a plugin, written by its author in + * `src/plugin-metadata.ts`. Plain data, so the API and the browser can both + * import it. + */ +export interface PluginInfo { + /** A Lucide icon name, such as `"newspaper"` - https://lucide.dev/icons. */ + icon?: string; + /** Human-readable title. Defaults to the package name. */ + title?: string; +} + /** - * What a plugin's `package.json` says about it, as `vitnode build` and - * `vitnode dev` write it into the plugin's `src/plugin-metadata.gen.ts`. - * - * Plain data on purpose: the API and the browser both import it, so it carries - * no React, no Node and no image - the icon travels separately, see - * {@link PluginIconLoader}. + * Everything the AdminCP plugin catalog knows about a plugin, as + * `vitnode build` and `vitnode dev` write it into `src/plugin-metadata.gen.ts`: + * `id`, `description` and `version` from `package.json`, `title` and `icon` + * from the plugin's own `src/plugin-metadata.ts`. */ export interface PluginMetadata { /** `description` from `package.json`, or `null` when it has none. */ description: null | string; + /** A Lucide icon name, or `null` for the default icon. */ + icon: null | string; /** `name` from `package.json` - which is the plugin id. */ id: string; - /** `vitnode.title`, falling back to `name`. */ + /** The plugin's title, falling back to `name`. */ title: string; /** `version` from `package.json`, or `null` when it has none. */ version: null | string; } -/** - * The plugin's icon as a `data:` URL, or `null` when `package.json` declares - * none. Lazy, so the bytes reach the browser only on the screen that shows - * them - `() => import("./plugin-icon.gen")`. - */ -export type PluginIconLoader = () => Promise<{ default: null | string }>; - export const PLUGIN_METADATA_ERROR_PREFIX = "[VitNode plugin metadata]"; /** * Refuses metadata generated for a different package than the one registering * it - a copied `config.tsx`, or a `pluginId` that drifted from the package - * name. Either way the catalog would otherwise report another package's - * version under this plugin's id. + * name - and an icon that is not a Lucide name. Either way the catalog would + * otherwise show something the plugin never meant. */ -export const assertPluginMetadataId = ( +export const assertPluginMetadata = ( pluginId: string, - metadata: Pick | undefined, + metadata: Pick | undefined, where: string, ): void => { - if (metadata === undefined || metadata.id === pluginId) return; + if (metadata === undefined) return; + + if (metadata.id !== pluginId) { + throw new Error( + `${PLUGIN_METADATA_ERROR_PREFIX} ${where} registers the plugin "${pluginId}" with the metadata of "${metadata.id}". The metadata comes from the package's own package.json, whose "name" is the plugin id - pass the \`pluginMetadata\` of this plugin's own \`src/plugin-metadata.gen.ts\`, or make \`pluginId\` match the package name.`, + ); + } - throw new Error( - `${PLUGIN_METADATA_ERROR_PREFIX} ${where} registers the plugin "${pluginId}" with the metadata of "${metadata.id}". The metadata comes from the package's own package.json, whose "name" is the plugin id - pass the \`pluginMetadata\` of this plugin's own \`src/plugin-metadata.gen.ts\`, or make \`pluginId\` match the package name.`, - ); + if (metadata.icon !== null && !isLucideIconName(metadata.icon)) { + throw new Error( + `${PLUGIN_METADATA_ERROR_PREFIX} ${where} gives the plugin "${pluginId}" the icon ${JSON.stringify(metadata.icon)}. An icon is a Lucide icon name such as "newspaper" - set it in the plugin's \`src/plugin-metadata.ts\`.`, + ); + } }; /** One plugin the web build registered, as its generated inventory lists it. */ diff --git a/packages/vitnode/src/lib/plugin.test.ts b/packages/vitnode/src/lib/plugin.test.ts index ea082b0586..1277ac2147 100644 --- a/packages/vitnode/src/lib/plugin.test.ts +++ b/packages/vitnode/src/lib/plugin.test.ts @@ -1,21 +1,38 @@ import { describe, expect, it } from "vitest"; +import type { PluginMetadata } from "./plugin-metadata"; + import { buildPlugin } from "./plugin"; +const BLOG: PluginMetadata = { + description: null, + icon: "newspaper", + id: "@acme/blog", + title: "Blog", + version: "1.0.0", +}; + describe("buildPlugin metadata", () => { - it("refuses metadata generated for another package", () => { - expect(() => - buildPlugin({ - metadata: { - description: null, - id: "@acme/blog", - title: "Blog", - version: "1.0.0", - }, - pluginId: "@acme/shop", - }), - ).toThrow( + it("accepts its own metadata with a Lucide icon name", () => { + expect( + buildPlugin({ metadata: BLOG, pluginId: "@acme/blog" }).metadata, + ).toBe(BLOG); + }); + + it.each([ + [ + "metadata generated for another package", + "@acme/shop", + BLOG, /^\[VitNode plugin metadata\] buildPlugin\(\) registers the plugin "@acme\/shop" with the metadata of "@acme\/blog"/, - ); + ], + [ + "an icon that is not a Lucide icon name", + "@acme/blog", + { ...BLOG, icon: "./assets/icon.svg" }, + /^\[VitNode plugin metadata\] buildPlugin\(\) gives the plugin "@acme\/blog" the icon "\.\/assets\/icon\.svg"/, + ], + ])("refuses %s", (_, pluginId, metadata, error) => { + expect(() => buildPlugin({ metadata, pluginId })).toThrow(error); }); }); diff --git a/packages/vitnode/src/lib/plugin.ts b/packages/vitnode/src/lib/plugin.ts index b022504e2b..9f7139058b 100644 --- a/packages/vitnode/src/lib/plugin.ts +++ b/packages/vitnode/src/lib/plugin.ts @@ -11,9 +11,9 @@ import type { PluginRoutes } from "../routing/tree"; import type { AdminNavItem as ResolvedAdminNavItem } from "../views/admin/layouts/sidebar/nav/nav-model"; import type { ContentFormSkeletonControl } from "../views/admin/views/content/form/skeleton"; import type { LocaleFilesMap, LocaleMessagesMap } from "./i18n/types"; -import type { PluginIconLoader, PluginMetadata } from "./plugin-metadata"; +import type { PluginMetadata } from "./plugin-metadata"; -import { assertPluginMetadataId } from "./plugin-metadata"; +import { assertPluginMetadata } from "./plugin-metadata"; export type AdminNavPermission = Omit; @@ -176,12 +176,6 @@ export interface BuildPluginReturn

{ }; containers?: ContentContainerFrontendRegistration[]; contentTypes?: ContentTypeFrontendRegistration[]; - /** - * The plugin's icon for AdminCP → System → Plugins, loaded only there: - * `() => import("./plugin-icon.gen")`, generated from `vitnode.icon` in - * `package.json`. Without it the catalog shows a default icon. - */ - icon?: PluginIconLoader; /** * Every locale file this plugin ships, as literal `*.json` specifiers an * app's bundler can resolve - `{ en: "@acme/blog/locales/en.json" }`. @@ -199,9 +193,9 @@ export interface BuildPluginReturn

{ messages?: LocaleMessagesMap; /** * `pluginMetadata` from the plugin's generated `src/plugin-metadata.gen.ts`: - * its title, description and installed version, read from `package.json` by - * `vitnode build`. Without it the AdminCP lists the plugin by id with an - * unknown version. + * its title and icon, and the description and installed version + * `vitnode build` reads from `package.json`. Without it the AdminCP lists the + * plugin by id with an unknown version. */ metadata?: PluginMetadata; pluginId: P; @@ -212,7 +206,7 @@ export interface BuildPluginReturn

{ export function buildPlugin

( props: BuildPluginReturn

, ): BuildPluginReturn

{ - assertPluginMetadataId(props.pluginId, props.metadata, "buildPlugin()"); + assertPluginMetadata(props.pluginId, props.metadata, "buildPlugin()"); return props; } diff --git a/packages/vitnode/src/tanstack/admin/plugins/screen.tsx b/packages/vitnode/src/tanstack/admin/plugins/screen.tsx index 4911f3415b..185d109b4d 100644 --- a/packages/vitnode/src/tanstack/admin/plugins/screen.tsx +++ b/packages/vitnode/src/tanstack/admin/plugins/screen.tsx @@ -1,27 +1,14 @@ import { useSuspenseQuery } from "@tanstack/react-query"; -import type { PluginIconLoader } from "@/lib/plugin-metadata"; - import { PageTitle } from "@/components/ui/page-title"; import { PluginsContent } from "@/views/admin/views/core/system/plugins/plugins-content"; import { installedPluginsQueryOptions } from "@/views/admin/views/core/system/plugins/plugins-query"; -import { getVitNodeConfig } from "@/vitnode.config"; import type { AdminPluginsRouteData } from "./route"; import { RouteMessages } from "../../i18n/route-messages"; import { ADMIN_PLUGINS_NAMESPACES } from "./route"; -/** - * Icons come from the frontend registrations - the browser is where they are - * shown, and the API never carries image bytes. A plugin registered only in the - * API gets the default icon. - */ -const frontendIcons = (): Record => - Object.fromEntries( - getVitNodeConfig().plugins.map(plugin => [plugin.pluginId, plugin.icon]), - ); - export const AdminPluginsRouteContent = ({ description, title, @@ -33,7 +20,7 @@ export const AdminPluginsRouteContent = ({

- +
); diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-icon.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-icon.tsx index cdb593f8a1..fb8718201f 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-icon.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugin-icon.tsx @@ -1,59 +1,30 @@ -import { useQuery } from "@tanstack/react-query"; import { PuzzleIcon } from "lucide-react"; -import React from "react"; -import type { PluginIconLoader } from "@/lib/plugin-metadata"; - -import { installedPluginsQueryKey } from "./plugins-query"; - -const FRAME = - "bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"; +import { DynamicIcon } from "@/components/ui/dynamic-icon"; /** - * The plugin's icon, or a puzzle piece while it loads, when the plugin ships - * none, and when the image fails to decode. - * - * Always an ``, never inlined markup: an SVG icon rendered as an image - * cannot run scripts or reach the network. Decorative, because the title sits - * right next to it. + * The plugin's Lucide icon, or a puzzle piece while it loads, when the plugin + * names none and when Lucide has no icon by that name. Decorative, because the + * title sits right next to it. */ -export const PluginIcon = ({ - id, - load, -}: { - id: string; - load: PluginIconLoader | undefined; -}) => { - const { data } = useQuery({ - enabled: load !== undefined, - queryFn: async () => (load ? (await load()).default : null), - queryKey: [...installedPluginsQueryKey, "icon", id], - retry: false, - staleTime: Infinity, - }); - const [failed, setFailed] = React.useState(null); - - if (!data || failed === data) { - return ( - - ); - } +export const PluginIcon = ({ icon }: { icon: null | string }) => { + const fallback = ; return ( - - { - setFailed(data); - }} - src={data} - width={32} - /> + ); }; diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.test.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.test.tsx index 2f237742b0..5ed08af4cf 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.test.tsx @@ -1,16 +1,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - act, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { IntlProvider } from "use-intl"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { PluginIconLoader } from "@/lib/plugin-metadata"; - import type { AdminInstalledPlugin, AdminInstalledPlugins, @@ -22,6 +14,7 @@ const plugin = ( overrides: Partial = {}, ): AdminInstalledPlugin => ({ description: "Posts and categories", + icon: null, id: "@acme/blog", registrations: { api: { version: "1.0.0" }, @@ -45,10 +38,7 @@ const forum = plugin({ versionMismatch: true, }); -const renderPlugins = ( - data: Partial = {}, - icons: Record = {}, -) => +const renderPlugins = (data: Partial = {}) => render( , @@ -144,21 +133,10 @@ describe("PluginsContent", () => { }, ); - it("shows the default icon when the plugin ships none", () => { - const { container } = renderPlugins({ plugins: [plugin()] }); - - expect(container.querySelector("img")).toBeNull(); - }); - - it("shows the plugin's own icon from its loader", async () => { - const icon = "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4="; - const { container } = renderPlugins( - { plugins: [plugin()] }, - { "@acme/blog": async () => await Promise.resolve({ default: icon }) }, - ); + it("shows the default icon for a plugin that names none", () => { + const { container } = renderPlugins({ plugins: [plugin({ icon: null })] }); - await waitFor(() => { - expect(container.querySelector("img")?.getAttribute("src")).toBe(icon); - }); + expect(screen.getByText("Blog")).toBeTruthy(); + expect(container.querySelector("svg.lucide-puzzle")).not.toBeNull(); }); }); diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx index a5fd0d901a..0a48b3b732 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-content.tsx @@ -3,7 +3,6 @@ import React from "react"; import { useTranslations } from "use-intl"; import type { DataTableNavigation } from "@/components/table/navigation"; -import type { PluginIconLoader } from "@/lib/plugin-metadata"; import { ContentDataTable } from "@/components/table/content"; import { DataTableNavigationProvider } from "@/components/table/navigation"; @@ -27,8 +26,6 @@ import { matchesPluginSearch } from "./plugin-search"; export interface PluginsContentProps { data: AdminInstalledPlugins; - /** Each frontend registration's icon loader, by plugin id. */ - icons: Readonly>; } const PluginVersion = ({ version }: { version: null | string }) => { @@ -45,19 +42,13 @@ const PluginVersion = ({ version }: { version: null | string }) => { ); }; -const PluginRow = ({ - icon, - plugin, -}: { - icon: PluginIconLoader | undefined; - plugin: AdminInstalledPlugin; -}) => { +const PluginRow = ({ plugin }: { plugin: AdminInstalledPlugin }) => { const t = useTranslations("admin.system.plugins"); const { api, frontend } = plugin.registrations; return (
- +
{plugin.title} @@ -105,7 +96,7 @@ const PluginsSummary = ({ core, count }: { core: string; count: number }) => { ); }; -export const PluginsContent = ({ data, icons }: PluginsContentProps) => { +export const PluginsContent = ({ data }: PluginsContentProps) => { const t = useTranslations("admin.system.plugins"); const [searchParams, setSearchParams] = React.useState( () => new URLSearchParams(), @@ -181,9 +172,7 @@ export const PluginsContent = ({ data, icons }: PluginsContentProps) => { totalCount: rows.length, totalPages, }} - renderRow={({ row }) => ( - - )} + renderRow={({ row }) => } search searchPlaceholder={t("search")} /> diff --git a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-query.ts b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-query.ts index e5345b2dde..70b1f20e94 100644 --- a/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-query.ts +++ b/packages/vitnode/src/views/admin/views/core/system/plugins/plugins-query.ts @@ -11,6 +11,7 @@ export interface AdminPluginRegistration { export interface AdminInstalledPlugin { description: null | string; + icon: null | string; id: string; registrations: { api: AdminPluginRegistration | null; diff --git a/plugins/blog/assets/icon.svg b/plugins/blog/assets/icon.svg deleted file mode 100644 index 1ef99eca3b..0000000000 --- a/plugins/blog/assets/icon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/plugins/blog/package.json b/plugins/blog/package.json index ad32e190ca..f6e5c9755e 100644 --- a/plugins/blog/package.json +++ b/plugins/blog/package.json @@ -2,10 +2,6 @@ "name": "@vitnode/blog", "version": "2.0.0-canary.13", "description": "Blog plugin for VitNode, providing a blogging platform on Hono.js.", - "vitnode": { - "title": "Blog", - "icon": "./assets/icon.svg" - }, "author": "VitNode Team", "license": "MIT", "homepage": "https://vitnode.com", diff --git a/plugins/blog/src/config.tsx b/plugins/blog/src/config.tsx index 1c26e7ecc9..1d27b39319 100644 --- a/plugins/blog/src/config.tsx +++ b/plugins/blog/src/config.tsx @@ -10,7 +10,6 @@ export const blogPlugin = () => buildPlugin({ ...CONFIG_PLUGIN, metadata: pluginMetadata, - icon: async () => await import("./plugin-icon.gen"), localeFiles: { en: "@vitnode/blog/locales/en.json", }, diff --git a/plugins/blog/src/plugin-icon.gen.ts b/plugins/blog/src/plugin-icon.gen.ts deleted file mode 100644 index e5fab09681..0000000000 --- a/plugins/blog/src/plugin-icon.gen.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* oxlint-disable */ - -// This file is generated by VitNode. Do not edit it, and do not format it. -// -// It is rewritten by `vitnode build` and `vitnode dev` from this package's -// package.json: `name`, `version`, `description` and the `vitnode` field. -// -// Loaded lazily by `buildPlugin({ icon: () => import("./plugin-icon.gen") })`, -// so the bytes reach a browser only on the AdminCP screen that shows them. - -const icon: null | string = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0ibm9uZSI+CiAgPHJlY3Qgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4IiByeD0iMTIiIGZpbGw9IiMwZjc2NmUiLz4KICA8cGF0aCBkPSJNMTUgMTRoMTRhNCA0IDAgMCAxIDQgNHYxNkgxOWE0IDQgMCAwIDEtNC00VjE0WiIgc3Ryb2tlPSIjZmZmZmZmIiBzdHJva2Utd2lkdGg9IjIuNSIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgogIDxwYXRoIGQ9Ik0zMyAyMmgyYTIgMiAwIDAgMSAyIDJ2OGEyIDIgMCAwIDEtNCAwIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMi41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KICA8cGF0aCBkPSJNMjAgMjBoOE0yMCAyNWg4TTIwIDMwaDUiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSIyLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K" - -export default icon diff --git a/plugins/blog/src/plugin-metadata.gen.ts b/plugins/blog/src/plugin-metadata.gen.ts index 0ebd2f2482..35d467e58f 100644 --- a/plugins/blog/src/plugin-metadata.gen.ts +++ b/plugins/blog/src/plugin-metadata.gen.ts @@ -3,14 +3,16 @@ // This file is generated by VitNode. Do not edit it, and do not format it. // // It is rewritten by `vitnode build` and `vitnode dev` from this package's -// package.json: `name`, `version`, `description` and the `vitnode` field. -// -// Register `pluginMetadata` as `metadata` in both `buildPlugin` and -// `buildApiPlugin`. +// package.json - `name`, `version` and `description` - and the title and +// icon in `src/plugin-metadata.ts`. Register `pluginMetadata` as `metadata` +// in both `buildPlugin` and `buildApiPlugin`. + +import { pluginInfo } from "./plugin-metadata" export const pluginMetadata = { id: "@vitnode/blog", - title: "Blog", + title: pluginInfo.title ?? "@vitnode/blog", + icon: pluginInfo.icon ?? null, description: "Blog plugin for VitNode, providing a blogging platform on Hono.js.", version: "2.0.0-canary.13", } as const diff --git a/plugins/blog/src/plugin-metadata.ts b/plugins/blog/src/plugin-metadata.ts new file mode 100644 index 0000000000..ebd781f5ca --- /dev/null +++ b/plugins/blog/src/plugin-metadata.ts @@ -0,0 +1,7 @@ +import type { PluginInfo } from "@vitnode/core/lib/plugin-metadata"; + +/** How AdminCP → System → Plugins shows this plugin. */ +export const pluginInfo: PluginInfo = { + title: "Blog", + icon: "newspaper", +}; diff --git a/plugins/example/assets/icon.svg b/plugins/example/assets/icon.svg deleted file mode 100644 index ba218f0a2c..0000000000 --- a/plugins/example/assets/icon.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/plugins/example/package.json b/plugins/example/package.json index f0c2716cd1..8090218522 100644 --- a/plugins/example/package.json +++ b/plugins/example/package.json @@ -2,10 +2,6 @@ "name": "@vitnode/example", "version": "2.0.0-canary.13", "description": "Reference plugin exercising the VitNode Content Engine end to end.", - "vitnode": { - "title": "Example", - "icon": "./assets/icon.svg" - }, "license": "MIT", "private": true, "type": "module", diff --git a/plugins/example/src/config.tsx b/plugins/example/src/config.tsx index 0a7773d49e..8fee9d0e47 100644 --- a/plugins/example/src/config.tsx +++ b/plugins/example/src/config.tsx @@ -10,7 +10,6 @@ export const examplePlugin = () => buildPlugin({ ...CONFIG_PLUGIN, metadata: pluginMetadata, - icon: async () => await import("./plugin-icon.gen"), localeFiles: { en: "@vitnode/example/locales/en.json", }, diff --git a/plugins/example/src/plugin-icon.gen.ts b/plugins/example/src/plugin-icon.gen.ts deleted file mode 100644 index 24dc2bb68b..0000000000 --- a/plugins/example/src/plugin-icon.gen.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* oxlint-disable */ - -// This file is generated by VitNode. Do not edit it, and do not format it. -// -// It is rewritten by `vitnode build` and `vitnode dev` from this package's -// package.json: `name`, `version`, `description` and the `vitnode` field. -// -// Loaded lazily by `buildPlugin({ icon: () => import("./plugin-icon.gen") })`, -// so the bytes reach a browser only on the AdminCP screen that shows them. - -const icon: null | string = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0ibm9uZSI+CiAgPHJlY3Qgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4IiByeD0iMTIiIGZpbGw9IiNjMjQxMGMiLz4KICA8cGF0aCBkPSJNMjAgMTJoOE0yMSAxMnY5bC03IDEyYTMgMyAwIDAgMCAyLjYgNC41aDE0LjhBMyAzIDAgMCAwIDM0IDMzbC03LTEydi05IiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMi41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KICA8cGF0aCBkPSJNMTcuNSAyOGgxMyIgc3Ryb2tlPSIjZmZmZmZmIiBzdHJva2Utd2lkdGg9IjIuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+Cjwvc3ZnPgo=" - -export default icon diff --git a/plugins/example/src/plugin-metadata.gen.ts b/plugins/example/src/plugin-metadata.gen.ts index 3b47eddac4..5aeb67c998 100644 --- a/plugins/example/src/plugin-metadata.gen.ts +++ b/plugins/example/src/plugin-metadata.gen.ts @@ -3,14 +3,16 @@ // This file is generated by VitNode. Do not edit it, and do not format it. // // It is rewritten by `vitnode build` and `vitnode dev` from this package's -// package.json: `name`, `version`, `description` and the `vitnode` field. -// -// Register `pluginMetadata` as `metadata` in both `buildPlugin` and -// `buildApiPlugin`. +// package.json - `name`, `version` and `description` - and the title and +// icon in `src/plugin-metadata.ts`. Register `pluginMetadata` as `metadata` +// in both `buildPlugin` and `buildApiPlugin`. + +import { pluginInfo } from "./plugin-metadata" export const pluginMetadata = { id: "@vitnode/example", - title: "Example", + title: pluginInfo.title ?? "@vitnode/example", + icon: pluginInfo.icon ?? null, description: "Reference plugin exercising the VitNode Content Engine end to end.", version: "2.0.0-canary.13", } as const diff --git a/plugins/example/src/plugin-metadata.ts b/plugins/example/src/plugin-metadata.ts new file mode 100644 index 0000000000..4298c66a76 --- /dev/null +++ b/plugins/example/src/plugin-metadata.ts @@ -0,0 +1,7 @@ +import type { PluginInfo } from "@vitnode/core/lib/plugin-metadata"; + +/** How AdminCP → System → Plugins shows this plugin. */ +export const pluginInfo: PluginInfo = { + title: "Example", + icon: "flask-conical", +}; diff --git a/plugins/forum/assets/icon.svg b/plugins/forum/assets/icon.svg deleted file mode 100644 index eda6d7726e..0000000000 --- a/plugins/forum/assets/icon.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/plugins/forum/package.json b/plugins/forum/package.json index 0cca4bb972..81668dc526 100644 --- a/plugins/forum/package.json +++ b/plugins/forum/package.json @@ -2,10 +2,6 @@ "name": "@vitnode/forum", "version": "2.0.0-canary.13", "description": "Community forum for VitNode: structured forums and nested threads, built on the Content Engine.", - "vitnode": { - "title": "Forum", - "icon": "./assets/icon.svg" - }, "license": "MIT", "private": true, "type": "module", diff --git a/plugins/forum/src/config.tsx b/plugins/forum/src/config.tsx index d6216f333a..3147e0e6cf 100644 --- a/plugins/forum/src/config.tsx +++ b/plugins/forum/src/config.tsx @@ -10,7 +10,6 @@ export const forumPlugin = () => buildPlugin({ ...CONFIG_PLUGIN, metadata: pluginMetadata, - icon: async () => await import("./plugin-icon.gen"), localeFiles: { en: "@vitnode/forum/locales/en.json", }, diff --git a/plugins/forum/src/plugin-icon.gen.ts b/plugins/forum/src/plugin-icon.gen.ts deleted file mode 100644 index 256bb9fb2d..0000000000 --- a/plugins/forum/src/plugin-icon.gen.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* oxlint-disable */ - -// This file is generated by VitNode. Do not edit it, and do not format it. -// -// It is rewritten by `vitnode build` and `vitnode dev` from this package's -// package.json: `name`, `version`, `description` and the `vitnode` field. -// -// Loaded lazily by `buildPlugin({ icon: () => import("./plugin-icon.gen") })`, -// so the bytes reach a browser only on the AdminCP screen that shows them. - -const icon: null | string = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCIgZmlsbD0ibm9uZSI+CiAgPHJlY3Qgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4IiByeD0iMTIiIGZpbGw9IiMxZDRlZDgiLz4KICA8cGF0aCBkPSJNMTIgMTVhMyAzIDAgMCAxIDMtM2gxM2EzIDMgMCAwIDEgMyAzdjhhMyAzIDAgMCAxLTMgM2gtOGwtNSA0di00aDBhMyAzIDAgMCAxLTMtM3YtOFoiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSIyLjUiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KICA8cGF0aCBkPSJNMzUgMjFhMyAzIDAgMCAxIDIgM3Y4YTMgMyAwIDAgMS0zIDN2NGwtNS00aC02YTMgMyAwIDAgMS0zLTMiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSIyLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K" - -export default icon diff --git a/plugins/forum/src/plugin-metadata.gen.ts b/plugins/forum/src/plugin-metadata.gen.ts index 3c46b64728..f89c0c1d33 100644 --- a/plugins/forum/src/plugin-metadata.gen.ts +++ b/plugins/forum/src/plugin-metadata.gen.ts @@ -3,14 +3,16 @@ // This file is generated by VitNode. Do not edit it, and do not format it. // // It is rewritten by `vitnode build` and `vitnode dev` from this package's -// package.json: `name`, `version`, `description` and the `vitnode` field. -// -// Register `pluginMetadata` as `metadata` in both `buildPlugin` and -// `buildApiPlugin`. +// package.json - `name`, `version` and `description` - and the title and +// icon in `src/plugin-metadata.ts`. Register `pluginMetadata` as `metadata` +// in both `buildPlugin` and `buildApiPlugin`. + +import { pluginInfo } from "./plugin-metadata" export const pluginMetadata = { id: "@vitnode/forum", - title: "Forum", + title: pluginInfo.title ?? "@vitnode/forum", + icon: pluginInfo.icon ?? null, description: "Community forum for VitNode: structured forums and nested threads, built on the Content Engine.", version: "2.0.0-canary.13", } as const diff --git a/plugins/forum/src/plugin-metadata.ts b/plugins/forum/src/plugin-metadata.ts new file mode 100644 index 0000000000..fa03efae3d --- /dev/null +++ b/plugins/forum/src/plugin-metadata.ts @@ -0,0 +1,7 @@ +import type { PluginInfo } from "@vitnode/core/lib/plugin-metadata"; + +/** How AdminCP → System → Plugins shows this plugin. */ +export const pluginInfo: PluginInfo = { + title: "Forum", + icon: "messages-square", +}; From 5095042c0256622f36f3bf1ca4158660a819ec5e Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 15 Oct 2026 19:05:04 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat(layout):=20=E2=9C=A8=20introduce=20Doc?= =?UTF-8?q?umentHead=20and=20DocumentScripts=20components=20for=20improved?= =?UTF-8?q?=20script=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/content/docs/dev/debugging/index.mdx | 2 +- apps/web/content/docs/dev/fetcher.mdx | 1 + apps/web/content/docs/dev/performance.mdx | 30 +++++ apps/web/src/routes/__root.tsx | 17 ++- apps/web/src/site/home/home-content.tsx | 28 ++--- .../hero-wall/hero-wall-columns.tsx | 79 ++++++++----- .../illustrations/hero-wall/hero-wall.tsx | 21 ++-- .../home/illustrations/hero-wall/when-idle.ts | 15 +++ apps/web/src/site/home/use-scroll-progress.ts | 7 +- apps/web/src/site/marketing/marketing.css | 44 ++++++++ .../root/src/routes/__root.tsx | 17 ++- packages/vitnode/package.json | 1 + .../modules/middleware/navigation-icons.ts | 51 +++++++++ .../src/api/modules/middleware/route.ts | 4 + .../vitnode/src/components/theme-provider.tsx | 20 ++-- packages/vitnode/src/lib/emoji-icon.ts | 9 +- .../src/tanstack/auth/auth-methods.test.ts | 1 + .../tanstack/auth/middleware-config.test.ts | 2 + .../src/tanstack/auth/middleware-config.ts | 7 +- .../src/tanstack/auth/recovery.test.ts | 2 + .../src/tanstack/layout/document-head.tsx | 55 +++++++++ .../src/tanstack/layout/document-scripts.tsx | 105 ++++++++++++++++++ packages/vitnode/src/tanstack/layout/index.ts | 2 + .../src/tanstack/layout/main-shell.test.ts | 1 + packages/vitnode/src/ws/provider.tsx | 6 +- pnpm-lock.yaml | 3 + 26 files changed, 448 insertions(+), 82 deletions(-) create mode 100644 apps/web/src/site/home/illustrations/hero-wall/when-idle.ts create mode 100644 packages/vitnode/src/api/modules/middleware/navigation-icons.ts create mode 100644 packages/vitnode/src/tanstack/layout/document-head.tsx create mode 100644 packages/vitnode/src/tanstack/layout/document-scripts.tsx diff --git a/apps/web/content/docs/dev/debugging/index.mdx b/apps/web/content/docs/dev/debugging/index.mdx index ce97ab7f83..7a2e877b50 100644 --- a/apps/web/content/docs/dev/debugging/index.mdx +++ b/apps/web/content/docs/dev/debugging/index.mdx @@ -85,7 +85,7 @@ plugins: [ /> ) : null} - + ``` diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx index cb5dea8bb4..2e0c74b0a5 100644 --- a/apps/web/content/docs/dev/fetcher.mdx +++ b/apps/web/content/docs/dev/fetcher.mdx @@ -247,6 +247,7 @@ export default LoginPage - `isEmail` — Whether an email adapter is configured (required for password reset). - `captcha` — Public site key and provider type (`recaptcha` or `cloudflare`). - `ai.models` — Configured AI models in the deployment. +- `icons` — Lucide icon data for every icon the header and bottom bar use. The browser seeds the icon cache from it, so menu icons show up without downloading the whole icon index first. - `isKnown` — `false` if the API was unreachable (`UNKNOWN_MIDDLEWARE_CONFIG`), ensuring login forms still render. ## Freshness constants diff --git a/apps/web/content/docs/dev/performance.mdx b/apps/web/content/docs/dev/performance.mdx index 2fb7cd87ed..7f8efe4bf2 100644 --- a/apps/web/content/docs/dev/performance.mdx +++ b/apps/web/content/docs/dev/performance.mdx @@ -183,6 +183,36 @@ build: { Group only libraries whose shared surface is small. A UI primitive library shared by every AdminCP screen turns into a single 280 KB chunk that public pages then download for a tooltip. +### 9. Load JavaScript After the First Paint + +TanStack's `HeadContent` puts a `modulepreload` link for every chunk the page needs into ``, ahead of the stylesheet. On a slow phone those downloads compete with the CSS the browser has to wait for, so the first paint arrives late. Render the document with `DocumentHead` and `DocumentScripts` from `@vitnode/core/tanstack/layout` instead: + +```tsx title="src/routes/__root.tsx" +import { + DocumentHead, // [!code ++] + DocumentScripts, // [!code ++] + VitNodeRootProviders, +} from '@vitnode/core/tanstack/layout' + + + + {/* [!code --] */} + + {/* [!code ++] */} + + + + {children} + {/* [!code --] */} + + {/* [!code ++] */} + + + +``` + +In a production build the HTML and CSS load first, and a small inline script adds the preloads and the app entry right after the browser paints. In a hidden tab it adds them immediately. Hydration starts a frame or two later than before. During `vite dev` both components render the stock `HeadContent` and `Scripts`. New apps from `create-vitnode-app` already use them. + --- ## Measuring Bundle Size diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 36bcfe4dab..caf629487d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -2,12 +2,7 @@ import type { QueryClient } from '@tanstack/react-query' import { TanStackDevtools } from '@tanstack/react-devtools' import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools' -import { - createRootRouteWithContext, - HeadContent, - Outlet, - Scripts, -} from '@tanstack/react-router' +import { createRootRouteWithContext, Outlet } from '@tanstack/react-router' import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' import { ThemeScript } from '@vitnode/core/components/theme-script' import { @@ -16,7 +11,11 @@ import { resolveLocale, useLocale, } from '@vitnode/core/tanstack/i18n' -import { VitNodeRootProviders } from '@vitnode/core/tanstack/layout' +import { + DocumentHead, + DocumentScripts, + VitNodeRootProviders, +} from '@vitnode/core/tanstack/layout' import type { Locale } from '@/vitnode.config' @@ -81,7 +80,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { return ( - + @@ -107,7 +106,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { /> ) : null} - + ) diff --git a/apps/web/src/site/home/home-content.tsx b/apps/web/src/site/home/home-content.tsx index 547ddddf4c..f7759485c6 100644 --- a/apps/web/src/site/home/home-content.tsx +++ b/apps/web/src/site/home/home-content.tsx @@ -1,19 +1,19 @@ import { RouteMessages } from '@vitnode/core/tanstack/i18n' import { HOME_NAMESPACES } from '@/site/home/namespaces' -import { AgentsSection } from '@/site/home/sections/agents' -import { CommunitySection } from '@/site/home/sections/community' -import { DevelopersSection } from '@/site/home/sections/developers' -import { FaqSection } from '@/site/home/sections/faq' -import { FeaturesBentoSection } from '@/site/home/sections/features-bento' -import { FinalCtaSection } from '@/site/home/sections/final-cta' +// import { AgentsSection } from '@/site/home/sections/agents' +// import { CommunitySection } from '@/site/home/sections/community' +// import { DevelopersSection } from '@/site/home/sections/developers' +// import { FaqSection } from '@/site/home/sections/faq' +// import { FeaturesBentoSection } from '@/site/home/sections/features-bento' +// import { FinalCtaSection } from '@/site/home/sections/final-cta' import { HeroSection } from '@/site/home/sections/hero' import { IntegrationsSection } from '@/site/home/sections/integrations' -import { OutcomesSection } from '@/site/home/sections/outcomes' -import { PluginsSection } from '@/site/home/sections/plugins' -import { PricingSection } from '@/site/home/sections/pricing' -import { SecuritySection } from '@/site/home/sections/security' -import { ShowcaseSection } from '@/site/home/sections/showcase' +// import { OutcomesSection } from '@/site/home/sections/outcomes' +// import { PluginsSection } from '@/site/home/sections/plugins' +// import { PricingSection } from '@/site/home/sections/pricing' +// import { SecuritySection } from '@/site/home/sections/security' +// import { ShowcaseSection } from '@/site/home/sections/showcase' import { MarketingPage } from '@/site/marketing/marketing-page' import { CanaryNotice } from '@/site/marketing/shared' @@ -24,7 +24,7 @@ export const HomeRouteContent = () => (
- + {/* @@ -33,9 +33,9 @@ export const HomeRouteContent = () => ( - + */} - + {/* */} ) diff --git a/apps/web/src/site/home/illustrations/hero-wall/hero-wall-columns.tsx b/apps/web/src/site/home/illustrations/hero-wall/hero-wall-columns.tsx index 4cbfddbcf6..70cc46cc41 100644 --- a/apps/web/src/site/home/illustrations/hero-wall/hero-wall-columns.tsx +++ b/apps/web/src/site/home/illustrations/hero-wall/hero-wall-columns.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from 'react' + import { ArticleScreen, CategoriesScreen, @@ -18,6 +20,7 @@ import { IntegrationsScreen, SearchScreen, } from './system-screens' +import { whenIdle } from './when-idle' const COLUMNS = [ { @@ -65,29 +68,53 @@ const slide = (index: number) => { return `translateY(calc(var(--progress, 0) * ${direction * 14}% + ${offset}%))` } -export const HeroWallColumns = () => ( -
- {COLUMNS.map(({ key, screens }, index) => ( -
- {screens.map(({ key: screenKey, Screen }) => ( -
- -
- ))} -
- ))} -
-) +const useMountedColumnCount = () => { + const [count, setCount] = useState(1) + + useEffect(() => { + if (count >= COLUMNS.length) return + + return whenIdle(() => { + setCount(count + 1) + }) + }, [count]) + + return count +} + +export const HeroWallColumns = () => { + const mountedCount = useMountedColumnCount() + + return ( +
+ {COLUMNS.map(({ key, screens }, index) => ( +
+ {index < mountedCount ? ( +
+ {screens.map(({ key: screenKey, Screen }) => ( +
+ +
+ ))} +
+ ) : ( +
+ )} +
+ ))} +
+ ) +} diff --git a/apps/web/src/site/home/illustrations/hero-wall/hero-wall.tsx b/apps/web/src/site/home/illustrations/hero-wall/hero-wall.tsx index a84fe000ef..8ddb22af74 100644 --- a/apps/web/src/site/home/illustrations/hero-wall/hero-wall.tsx +++ b/apps/web/src/site/home/illustrations/hero-wall/hero-wall.tsx @@ -2,6 +2,8 @@ import { lazy, startTransition, Suspense, useEffect, useState } from 'react' import { useScrollProgress } from '@/site/home/use-scroll-progress' +import { whenIdle } from './when-idle' + const HeroWallColumns = lazy(async () => import('./hero-wall-columns').then((module) => ({ default: module.HeroWallColumns, @@ -12,11 +14,15 @@ export const HeroWall = () => { const ref = useScrollProgress(0.9) const [isMounted, setIsMounted] = useState(false) - useEffect(() => { - startTransition(() => { - setIsMounted(true) - }) - }, []) + useEffect( + () => + whenIdle(() => { + startTransition(() => { + setIsMounted(true) + }) + }), + [], + ) return (
{ > {isMounted ? ( -
+
diff --git a/apps/web/src/site/home/illustrations/hero-wall/when-idle.ts b/apps/web/src/site/home/illustrations/hero-wall/when-idle.ts new file mode 100644 index 0000000000..d1fa4d7bad --- /dev/null +++ b/apps/web/src/site/home/illustrations/hero-wall/when-idle.ts @@ -0,0 +1,15 @@ +export const whenIdle = (callback: () => void) => { + if (typeof requestIdleCallback === 'function') { + const id = requestIdleCallback(callback, { timeout: 2000 }) + + return () => { + cancelIdleCallback(id) + } + } + + const id = setTimeout(callback, 200) + + return () => { + clearTimeout(id) + } +} diff --git a/apps/web/src/site/home/use-scroll-progress.ts b/apps/web/src/site/home/use-scroll-progress.ts index bde30e04ad..34394b8c3f 100644 --- a/apps/web/src/site/home/use-scroll-progress.ts +++ b/apps/web/src/site/home/use-scroll-progress.ts @@ -3,13 +3,16 @@ import { useEffect, useRef } from 'react' const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches +const supportsScrollTimeline = () => + CSS.supports('animation-timeline: scroll()') + export const useScrollProgress = (viewports: number) => { const ref = useRef(null) useEffect(() => { const node = ref.current - if (!node || prefersReducedMotion()) return + if (!node || prefersReducedMotion() || supportsScrollTimeline()) return let frame = 0 @@ -24,7 +27,7 @@ export const useScrollProgress = (viewports: number) => { if (!frame) frame = requestAnimationFrame(update) } - update() + schedule() window.addEventListener('scroll', schedule, { passive: true }) window.addEventListener('resize', schedule) diff --git a/apps/web/src/site/marketing/marketing.css b/apps/web/src/site/marketing/marketing.css index 06a81e039d..7e0d2987f7 100644 --- a/apps/web/src/site/marketing/marketing.css +++ b/apps/web/src/site/marketing/marketing.css @@ -391,6 +391,50 @@ } } +@keyframes mk-wall-fade { + from { + opacity: 0; + } +} + +@keyframes mk-wall-rise { + from { + transform: translateY(4%); + } + + to { + transform: translateY(-10%); + } +} + +@keyframes mk-wall-sink { + from { + transform: translateY(-8%); + } + + to { + transform: translateY(6%); + } +} + +@media (prefers-reduced-motion: no-preference) { + .mk-wall-fade { + animation: mk-wall-fade 700ms ease-out both; + } + + @supports (animation-timeline: scroll()) { + .mk-wall-column { + animation: mk-wall-rise linear both; + animation-timeline: scroll(root block); + animation-range: 0 max(90vh, 360px); + } + + .mk-wall-column:nth-child(even) { + animation-name: mk-wall-sink; + } + } +} + @keyframes mk-cycle-2 { 0%, 46% { diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx index 8f0524dcf0..cb0130f685 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/routes/__root.tsx @@ -2,12 +2,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { TanStackDevtools } from "@tanstack/react-devtools"; import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; -import { - createRootRouteWithContext, - HeadContent, - Outlet, - Scripts, -} from "@tanstack/react-router"; +import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; import { ThemeScript } from "@vitnode/core/components/theme-script"; import { @@ -16,7 +11,11 @@ import { resolveLocale, useLocale, } from "@vitnode/core/tanstack/i18n"; -import { VitNodeRootProviders } from "@vitnode/core/tanstack/layout"; +import { + DocumentHead, + DocumentScripts, + VitNodeRootProviders, +} from "@vitnode/core/tanstack/layout"; import type { Locale } from "@/vitnode.config"; @@ -77,7 +76,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { return ( - + @@ -102,7 +101,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { /> ) : null} - + ); diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index dc0c87a071..83af1e8ac5 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -244,6 +244,7 @@ "@simplewebauthn/browser": "^14.0.0", "@simplewebauthn/server": "^14.0.3", "@tanstack/charts": "^1.0.0", + "@tanstack/router-core": "^1.171.37", "@tiptap/extension-audio": "^3.31.4", "@tiptap/extension-collaboration": "^3.31.4", "@tiptap/extension-collaboration-caret": "^3.31.4", diff --git a/packages/vitnode/src/api/modules/middleware/navigation-icons.ts b/packages/vitnode/src/api/modules/middleware/navigation-icons.ts new file mode 100644 index 0000000000..adc09d3e64 --- /dev/null +++ b/packages/vitnode/src/api/modules/middleware/navigation-icons.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +import type { PublicNavigationMenus } from "@/api/modules/admin/navigation/lib/cache"; + +import { loadLucideIcon } from "@/components/ui/icon-registry"; +import { parseEmojiIcon } from "@/lib/emoji-icon"; + +export const zodLucideIconSchema = z.object({ + aliases: z.array(z.string()).optional(), + name: z.string().optional(), + node: z.array(z.tuple([z.string(), z.record(z.string(), z.string())])), + size: z.number().optional(), +}); + +const lucideIconNamesOf = ({ + bottomBar, + navigation, +}: PublicNavigationMenus): string[] => { + const names = new Set(); + const items = [ + ...bottomBar, + ...navigation, + ...navigation.flatMap(node => node.items), + ]; + + for (const item of items) { + const parsed = parseEmojiIcon(item.icon); + + if (parsed?.type === "icon") names.add(parsed.value); + } + + return [...names]; +}; + +export const loadNavigationIcons = async ( + menus: PublicNavigationMenus, +): Promise>> => { + const loaded = await Promise.all( + lucideIconNamesOf(menus).map( + async name => + [ + name, + zodLucideIconSchema.safeParse(await loadLucideIcon(name)), + ] as const, + ), + ); + + return Object.fromEntries( + loaded.flatMap(([name, icon]) => (icon.success ? [[name, icon.data]] : [])), + ); +}; diff --git a/packages/vitnode/src/api/modules/middleware/route.ts b/packages/vitnode/src/api/modules/middleware/route.ts index 8ad2995847..81bff2fd8c 100644 --- a/packages/vitnode/src/api/modules/middleware/route.ts +++ b/packages/vitnode/src/api/modules/middleware/route.ts @@ -9,6 +9,8 @@ import { } from "@/api/modules/admin/navigation/lib/schema"; import { CONFIG_PLUGIN } from "@/config"; +import { loadNavigationIcons, zodLucideIconSchema } from "./navigation-icons"; + export const routeMiddlewareSchema = z.object({ ai: z.object({ models: z.array( @@ -34,6 +36,7 @@ export const routeMiddlewareSchema = z.object({ password: z.boolean(), navigation: z.array(zodPublicNavigationNodeSchema), bottomBar: z.array(zodPublicNavigationItemSchema), + icons: z.record(z.string(), zodLucideIconSchema), captcha: z .object({ siteKey: z.string(), @@ -71,6 +74,7 @@ export const routeMiddleware = buildRoute({ password: c.get("core").authorization.password.enabled, navigation, bottomBar, + icons: await loadNavigationIcons({ bottomBar, navigation }), sso: sso.map(s => ({ id: s.id, name: s.name, diff --git a/packages/vitnode/src/components/theme-provider.tsx b/packages/vitnode/src/components/theme-provider.tsx index 1e67ac7577..5a005e019f 100644 --- a/packages/vitnode/src/components/theme-provider.tsx +++ b/packages/vitnode/src/components/theme-provider.tsx @@ -58,8 +58,6 @@ const subscribeSystemTheme = (callback: () => void): (() => void) => { }; }; -const getServerSystemTheme = (): undefined => undefined; - const getStoredTheme = ( storageKey: string, fallback?: string, @@ -120,11 +118,19 @@ export const ThemeProvider = ({ const [theme, setThemeState] = React.useState(() => getStoredTheme(storageKey, defaultTheme), ); - const systemTheme = React.useSyncExternalStore( - subscribeSystemTheme, - getSystemTheme, - getServerSystemTheme, - ); + const [systemTheme, setSystemTheme] = React.useState<"dark" | "light">(); + + React.useEffect(() => { + const syncSystemTheme = () => { + React.startTransition(() => { + setSystemTheme(getSystemTheme()); + }); + }; + + syncSystemTheme(); + + return subscribeSystemTheme(syncSystemTheme); + }, []); const resolvedTheme = forcedTheme ?? (theme === "system" ? systemTheme : theme); diff --git a/packages/vitnode/src/lib/emoji-icon.ts b/packages/vitnode/src/lib/emoji-icon.ts index 997a2fefd8..a5ef746733 100644 --- a/packages/vitnode/src/lib/emoji-icon.ts +++ b/packages/vitnode/src/lib/emoji-icon.ts @@ -6,13 +6,16 @@ export const EMOJI_ICON_MAX_LENGTH = 64; const LUCIDE_ICON_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; -const SINGLE_EMOJI = /^\p{RGI_Emoji}$/v; +let singleEmoji: RegExp | undefined; export const isLucideIconName = (value: string): boolean => LUCIDE_ICON_NAME.test(value); -export const isSingleEmoji = (value: string): boolean => - SINGLE_EMOJI.test(value); +export const isSingleEmoji = (value: string): boolean => { + singleEmoji ??= /^\p{RGI_Emoji}$/v; + + return singleEmoji.test(value); +}; export const parseEmojiIcon = ( raw: null | string | undefined, diff --git a/packages/vitnode/src/tanstack/auth/auth-methods.test.ts b/packages/vitnode/src/tanstack/auth/auth-methods.test.ts index c35484e09e..6cfd868812 100644 --- a/packages/vitnode/src/tanstack/auth/auth-methods.test.ts +++ b/packages/vitnode/src/tanstack/auth/auth-methods.test.ts @@ -9,6 +9,7 @@ const config = ( ): MiddlewareConfig => ({ ai: { models: [] }, bottomBar: [], + icons: {}, isEmail: true, navigation: [], passkeys: true, diff --git a/packages/vitnode/src/tanstack/auth/middleware-config.test.ts b/packages/vitnode/src/tanstack/auth/middleware-config.test.ts index 2d82e1507a..dbb65d90bc 100644 --- a/packages/vitnode/src/tanstack/auth/middleware-config.test.ts +++ b/packages/vitnode/src/tanstack/auth/middleware-config.test.ts @@ -40,6 +40,7 @@ describe("loadMiddlewareConfig", () => { const known = knownMiddlewareConfig({ ai: { models: [] }, bottomBar: [], + icons: {}, isEmail: true, navigation: [], passkeys: false, @@ -76,6 +77,7 @@ describe("middlewareConfigQueryOptions", () => { knownMiddlewareConfig({ ai: { models: [] }, bottomBar: [], + icons: {}, isEmail: true, navigation: [], passkeys: false, diff --git a/packages/vitnode/src/tanstack/auth/middleware-config.ts b/packages/vitnode/src/tanstack/auth/middleware-config.ts index 4f5b168e9e..c204584488 100644 --- a/packages/vitnode/src/tanstack/auth/middleware-config.ts +++ b/packages/vitnode/src/tanstack/auth/middleware-config.ts @@ -6,6 +6,7 @@ import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; import type { routeMiddlewareSchema } from "@/api/modules/middleware/route"; import type { SSOProvider } from "@/views/auth/sso/providers"; +import { seedLucideIcons } from "@/components/ui/icon-registry"; import { CONFIG_PLUGIN } from "@/config"; import { fetcher } from "@/tanstack/fetcher"; import { normalizeSSOProviders } from "@/views/auth/sso/providers"; @@ -19,6 +20,7 @@ export interface MiddlewareConfigState extends MiddlewareConfig { export const UNKNOWN_MIDDLEWARE_CONFIG: MiddlewareConfigState = Object.freeze({ ai: { models: [] }, + icons: {}, isDeferred: false, isEmail: false, isKnown: false, @@ -50,7 +52,10 @@ const fetchMiddlewareConfig = async (): Promise => { if (response.status !== 200) return UNKNOWN_MIDDLEWARE_CONFIG; - return knownMiddlewareConfig(await response.json()); + const config = await response.json(); + seedLucideIcons(config.icons); + + return knownMiddlewareConfig(config); } catch (error) { // oxlint-disable-next-line no-console console.error("[auth] middleware configuration unavailable", error); diff --git a/packages/vitnode/src/tanstack/auth/recovery.test.ts b/packages/vitnode/src/tanstack/auth/recovery.test.ts index b9dce25ab2..ebf4ce2111 100644 --- a/packages/vitnode/src/tanstack/auth/recovery.test.ts +++ b/packages/vitnode/src/tanstack/auth/recovery.test.ts @@ -205,6 +205,7 @@ describe("whether this deployment has password recovery at all", () => { password: true, navigation: [], bottomBar: [], + icons: {}, sso: [], }).isKnown, ).toBe(true); @@ -217,6 +218,7 @@ describe("whether this deployment has password recovery at all", () => { password: true, navigation: [], bottomBar: [], + icons: {}, sso: [], }), ), diff --git a/packages/vitnode/src/tanstack/layout/document-head.tsx b/packages/vitnode/src/tanstack/layout/document-head.tsx new file mode 100644 index 0000000000..56f2716311 --- /dev/null +++ b/packages/vitnode/src/tanstack/layout/document-head.tsx @@ -0,0 +1,55 @@ +import type { RouterManagedTag } from "@tanstack/router-core"; + +import { Asset, HeadContent, useRouter, useTags } from "@tanstack/react-router"; + +const isModulePreload = (tag: RouterManagedTag) => + tag.tag === "link" && tag.attrs?.rel === "modulepreload"; + +const isDataScript = (tag: RouterManagedTag) => + tag.tag === "script" && + typeof tag.attrs?.type === "string" && + !["", "module", "text/javascript"].includes(tag.attrs.type); + +const HeadTag = ({ + nonce, + tag, +}: { + nonce: string | undefined; + tag: RouterManagedTag; +}) => + isDataScript(tag) && typeof tag.children === "string" ? ( +