diff --git a/.claude/skills/vitnode-docs/SKILL.md b/.claude/skills/vitnode-docs/SKILL.md index cf35c7e1c..2f4c6569a 100644 --- a/.claude/skills/vitnode-docs/SKILL.md +++ b/.claude/skills/vitnode-docs/SKILL.md @@ -135,6 +135,7 @@ Keep important user-isolation and invalidation requirements beside the relevant - Keep important explanations and constraints in selectable text. Screenshots supplement instructions; they must not be the only source of UI labels or outcomes. - Support technical claims with implementation evidence, reproducible examples, or a relevant primary-source link. Add benchmark numbers, expert quotes, author attribution, or dates only when real and useful; do not add them as citation bait. - Keep HTML and Markdown exports equivalent in meaning, including all labeled tab alternatives. Do not add hidden bot-only text, prompt instructions for agents, fabricated FAQs, unsupported superlatives, or repetitive summaries to attract citations. +- For a how-to or tutorial that ends in working code in a reader's plugin or app, consider the optional `implementation` frontmatter behind **Copy implementation prompt**. Follow "Implementation prompts" in `apps/web/content/docs/dev/contribution.mdx`; it is the only place for coding-agent instructions. - Use a short question-and-answer section only for distinct recurring questions that are not already answered. Do not append a generic FAQ to every page. - For a requested SEO/AI-readiness audit or visibility review, read `references/seo-review.md`. Do not run a broad crawl, install audit tools, or require competitor research for every MDX edit. - Read `references/docs-site.md` for canonical metadata, crawlability, sitemaps, structured data, and AI discovery when site-level work is in scope. Clear content and accessible delivery improve usability; do not promise rankings, indexing, or AI citations. diff --git a/apps/web/content/docs/content-engine/container-policies.mdx b/apps/web/content/docs/content-engine/container-policies.mdx index 3ead926f6..3cda38b34 100644 --- a/apps/web/content/docs/content-engine/container-policies.mdx +++ b/apps/web/content/docs/content-engine/container-policies.mdx @@ -2,6 +2,25 @@ title: Publishing policies description: Set per-container publishing rules in the VitNode Content Engine. Decide who may post in a forum, whether new posts and edits wait for approval, and how long authors may edit, with defineContentContainer policies and the AdminCP Publishing sheet. icon: ShieldCheck +implementation: + objective: Set who may post in a set of containers and whether new posts and edits wait for approval. + decisions: + - Which containers get publishing rules? Offer container definitions in the project whose content types members can write to. If none exist, say so and ask how to proceed. + - 'Who may start posts: members or only moderators?' + - Do new posts go live at once or wait for approval? And edits to published posts? + - 'How long may authors edit their posts: always, never, or for a set number of minutes (60 by default)?' + - Does a content type in these containers, such as replies, need different rules? Offer the content types filed into the chosen containers. + constraints: + - 'Write the rules in `policies: { defaults, contentTypes }` on `defineContentContainer`. Per-container overrides are set in the AdminCP, not in code, and nothing needs migrating.' + - Label the container-scoped `can_edit` and `can_approve` moderator permissions in the plugin's locale. + - When approval is on, public forms must handle the draft status and the `approval` object in write responses. + related: + - title: Approvals + href: /docs/content-engine/approvals + - title: Review approvals + href: /docs/dev/moderation/review-approvals + - title: Scoped permissions + href: /docs/dev/moderation/scoped-permissions --- import { Tab, Tabs } from "fumadocs-ui/components/tabs"; diff --git a/apps/web/content/docs/content-engine/containers.mdx b/apps/web/content/docs/content-engine/containers.mdx index 917628df5..333608379 100644 --- a/apps/web/content/docs/content-engine/containers.mdx +++ b/apps/web/content/docs/content-engine/containers.mdx @@ -2,6 +2,25 @@ title: Containers description: Organize Content Engine records into a tree of containers, such as forum boards, blog sections or knowledge-base categories, with defineContentContainer and field.container. icon: FolderTree +implementation: + objective: File the records of an existing content type into a tree of containers, such as forums or categories. + decisions: + - Which plugin owns the containers? Offer plugins in the project that define Content Engine content types. + - Which items are filed into them? Offer content types from the chosen plugin that have no container field yet; a type can have only one. If its table already has rows, say that a default container is needed. + - What are the containers called, and what extra details does each one carry? Settle it in user terms, such as "Boards" with a description. Extra details can be text, long text, numbers, yes/no switches or choices. + - How deep do they nest, and how are siblings sorted? A flat list or 2 to 10 levels (5 by default), sorted by hand or alphabetically. + - Do members pick the container when they post? Only for a content type that members can write to. + constraints: + - Use `defineContentContainer` and `field.container({ definition })`. Do not build a separate categories table. + - Register the definition with the admin and public content modules and in the plugin's `adminNav` and `adminContent` containers, as the guide shows. + - A required container on a table that already has rows needs the generated migration edited to create and assign a default before `SET NOT NULL`. + related: + - title: Containers on the frontend + href: /docs/content-engine/container-frontend + - title: Publishing policies + href: /docs/content-engine/container-policies + - title: Container hierarchy + href: /docs/content-engine/container-hierarchy --- import { Tab, Tabs } from "fumadocs-ui/components/tabs"; diff --git a/apps/web/content/docs/content-engine/public-pages.mdx b/apps/web/content/docs/content-engine/public-pages.mdx index 8dc4f7ff5..69fe36868 100644 --- a/apps/web/content/docs/content-engine/public-pages.mdx +++ b/apps/web/content/docs/content-engine/public-pages.mdx @@ -2,6 +2,25 @@ title: Public pages description: Give every published VitNode Content Engine record its own URL with delivery, a plugin page, slug redirects and localized URLs. icon: Compass +implementation: + objective: Give each published record of an existing content type its own public page and URL. + decisions: + - Which items get their own page? Offer content types with a public API that have no `delivery` block yet. + - What URL do the pages use? Recommend the default `/{publicApi.path}/:slug`, or a custom path with exactly one `:slug` that does not start with `/admin` or `/api`. + - Which fields fill the page title, the description and "hide from search engines"? Offer the type's public text fields, its text or rich text fields, and a yes/no field. + - Should old URLs redirect after a slug changes? This needs editorial history; if the type has none, ask whether to add it. + - Should the pages be listed in the sitemap, and how often do they change? Always, hourly, daily, weekly, monthly, yearly or never. + constraints: + - Add the `delivery` block and claim the URL with a `page()` in the plugin's routes; the build fails while a content URL has no page. + - In the page loader, read the record with `fetcher` and build the page and its head with `contentDeliveryPage` and `contentDeliveryPageHead`, as the guide shows. + - Delivery adds no columns, so there is nothing to migrate. + related: + - title: SEO + href: /docs/content-engine/seo + - title: Localized URLs + href: /docs/dev/i18n/localized-urls + - title: Delivery reference + href: /docs/content-engine/reference#delivery --- import { ImgDocs } from "@/components/fumadocs/img"; diff --git a/apps/web/content/docs/content-engine/public-write.mdx b/apps/web/content/docs/content-engine/public-write.mdx index cbf3c9f34..72e605abe 100644 --- a/apps/web/content/docs/content-engine/public-write.mdx +++ b/apps/web/content/docs/content-engine/public-write.mdx @@ -2,6 +2,25 @@ title: Public write description: Let signed-in members create and edit Content Engine records from the public site with publicWrite, with server-stamped ownership, moderator permissions, strict field allowlists and rate limiting. icon: PenLine +implementation: + objective: Let signed-in members create and edit records of an existing content type from the public site. + decisions: + - Which plugin owns the content? Offer plugins in the project that define Content Engine content types. + - Which items do members write? Offer content types from the chosen plugin by their user-facing names. The type needs a public API and one user field that holds the owner; if either is missing, say so and ask whether to add it. + - What can members fill in when they create a record, and what can they change later? Offer the type's top-level fields. Files, users, groups, repeatables and widget fields cannot be written by members. + - Does a new record go live at once or start as a draft? Published or draft; draft is the default. + - Can authors edit their own records afterwards, and should creating a record ask for a captcha? + constraints: + - Use the content type `publicWrite` option with `ownership.field`. The server stamps the owner, so never accept it from the payload. + - Mount the generated routes through the plugin's `buildContentPublicModule`. Write no create or update route by hand, and add no public publish, hide or delete route. + - Label the generated `can_edit` moderator permission in the plugin's locale, then build and migrate with the project's scripts. + related: + - title: Public write frontend + href: /docs/content-engine/public-write-frontend + - title: Public write API + href: /docs/content-engine/public-write-api + - title: Public API + href: /docs/content-engine/public-api --- import { Tab, Tabs } from "fumadocs-ui/components/tabs"; diff --git a/apps/web/content/docs/content-engine/search.mdx b/apps/web/content/docs/content-engine/search.mdx index 4b3438383..4b02ed0f7 100644 --- a/apps/web/content/docs/content-engine/search.mdx +++ b/apps/web/content/docs/content-engine/search.mdx @@ -2,6 +2,25 @@ title: Search description: Add a Content Engine content type to the VitNode site search, keep the search index in sync with published records, and rebuild it from the AdminCP. icon: Search +implementation: + objective: Make an existing content type findable in site search. + decisions: + - Which items appear in search? Offer content types with a public API that already have a public page. If the choice has no page yet, say so and ask how to proceed. + - What is the result heading? Offer the type's public text fields, or a required relation to another searchable type to borrow its title. + - Which text is searched, and which text leads the excerpt? Offer the type's public text, long text, rich text and slug fields. + - Where does a result link to? Offer the existing public routes for the chosen type that take one slug. + - Should results credit the authors or show an image? Offer the type's user field and public file field, if it has them. + constraints: + - Add a `search` block to the content type. Indexing is automatic, so do not call the search service for Content Engine records. + - Every indexed field except `authorField` must be in `publicApi.fields`. + - Records saved before the change need a Reindex in Debug Panel → Search. Own code that writes records must use the editorial service with `contentEditorialEffects` to keep the index current. + related: + - title: Public pages + href: /docs/content-engine/public-pages + - title: Search + href: /docs/dev/search + - title: Search reference + href: /docs/content-engine/reference#search --- import { ImgDocs } from "@/components/fumadocs/img"; diff --git a/apps/web/content/docs/content-engine/threads.mdx b/apps/web/content/docs/content-engine/threads.mdx index ebc7052b7..2b6c3fff4 100644 --- a/apps/web/content/docs/content-engine/threads.mdx +++ b/apps/web/content/docs/content-engine/threads.mdx @@ -2,6 +2,26 @@ title: Threaded replies description: Build nested replies with the VitNode Content Engine thread option - a to-one self relation for the parent, a root relation that also gives the reply its container, generated slugs, public author summaries and the GET /thread route. icon: MessagesSquare +implementation: + objective: Add threaded replies, such as comments, under an existing content type. + decisions: + - Which plugin owns the replies? Offer plugins in the project that define Content Engine content types. + - Which items get replies? Offer content types from the chosen plugin by their user-facing names, such as "Topics" or "Articles". A root needs a public API; if the choice has none, say so and ask whether to add it. + - Who writes replies? Signed-in members from the public site (uses Public write), or only staff in the AdminCP. + - Where do replies appear? Offer the public pages that already show the chosen items, such as the detail page. + - How deep can replies nest? From 1 (a flat list) to 32 levels; recommend the guide's depth unless a flat list is wanted. + constraints: + - Use the content type `thread` option. Do not build a separate comments table, tree query or placement checks. + - 'The root relation is required, to-one and `container: true`; `parent` is a nullable to-one self relation. Both belong in `publicApi.fields` and neither in `publicWrite.update.fields`.' + - 'Read replies with the generated `GET /{publicApi.path}/thread` route through `fetcher`, and render tombstones (`removed: true`) so the tree keeps its shape.' + - Create the table with the project's build and migration scripts, not hand-written SQL. + related: + - title: Public write + href: /docs/content-engine/public-write + - title: Public write frontend + href: /docs/content-engine/public-write-frontend + - title: Thread reference + href: /docs/content-engine/reference#thread --- import { Tab, Tabs } from "fumadocs-ui/components/tabs"; diff --git a/apps/web/content/docs/dev/ai/actions.mdx b/apps/web/content/docs/dev/ai/actions.mdx index 125dd01ca..f347a6893 100644 --- a/apps/web/content/docs/dev/ai/actions.mdx +++ b/apps/web/content/docs/dev/ai/actions.mdx @@ -2,6 +2,26 @@ title: AI Actions description: Define managed AI features with defineAiAction, register them in a plugin and run them with c.get("ai").run(). VitNode Core handles permissions, limits, budgets and history. icon: Wand +implementation: + objective: Add an AI feature to a plugin through a registered AI action. + decisions: + - Which plugin offers the AI feature? Offer plugins in the project with an API part (`config.api.ts`). + - What should the AI do, and with what input? Settle it in user terms. The input comes from data the chosen trigger already has. + - Plain text or structured data? Structured data needs a model with structured output and cannot be streamed. + - 'Who runs it: people on request, background jobs, or both? People are the default, and their runs need an access check.' + - Where is it triggered, and is it on for every role by default? Offer routes, AdminCP screens, queue tasks or cron jobs found in the chosen plugin. + constraints: + - Define the action with `defineAiAction` and register it in `aiActions` of the plugin's `buildApiPlugin()`. + - Run it through `aiActionRef` with `c.get("ai").run()`, `runAsSystem()` or `stream()`. Never hard-code a model or call a provider SDK directly. + - '`authorize()` checks access to the content the action reads, for example with `checkStaffPermission`.' + - The action's `title` and `description` are message keys in the plugin's locale, not plain text. + related: + - title: AI in fields and the editor + href: /docs/dev/ai/fields-and-editor + - title: AI costs and limits + href: /docs/dev/ai/costs-and-limits + - title: AI in the AdminCP + href: /docs/dev/ai/admin --- import { TypeTable } from 'fumadocs-ui/components/type-table' diff --git a/apps/web/content/docs/dev/configuration.mdx b/apps/web/content/docs/dev/configuration.mdx index e913c8d0b..c0c4c161f 100644 --- a/apps/web/content/docs/dev/configuration.mdx +++ b/apps/web/content/docs/dev/configuration.mdx @@ -353,7 +353,7 @@ import { vitnode } from '@vitnode/core/framework/vite' export default defineConfig({ plugins: [ vitnode({ appRoot: import.meta.dirname }), - nitro({ compressPublicAssets: { brotli: true, gzip: true } }), + nitro(), tailwindcss(), tanstackStart(), viteReact(), @@ -361,10 +361,15 @@ export default defineConfig({ }) ``` -`compressPublicAssets` writes a `.br` and a `.gz` twin next to every built asset, -so the Node server from `pnpm start` serves scripts and styles compressed the -way a CDN would. The server-rendered HTML itself is not compressed by the Node -server; put a reverse proxy or a platform such as Vercel in front of it for that. +The Node server from `pnpm start` sends static files and HTML uncompressed. Put +a reverse proxy, a CDN or a platform such as Vercel in front of it and let that +layer compress responses. + +Nitro's `compressPublicAssets` can write a `.br` and a `.gz` twin next to every +built asset instead, but it stays off unless you pass it to `nitro()` yourself. +It compresses every file in the output at once, and on a 2-core, 8 GB build +machine that is enough to run out of memory. The twins only help when Nitro +itself serves the files: behind a CDN they are never read. `appRoot` has to be `import.meta.dirname`: a Vite config is loaded with the working directory set to wherever the command ran, which in a monorepo is diff --git a/apps/web/content/docs/dev/contribution.mdx b/apps/web/content/docs/dev/contribution.mdx index 7a2387997..20275f5b0 100644 --- a/apps/web/content/docs/dev/contribution.mdx +++ b/apps/web/content/docs/dev/contribution.mdx @@ -121,6 +121,46 @@ The documentation portal in `apps/web` speaks Markdown as well as HTML, so agent Requesting a docs page with an `Accept: text/markdown` header redirects to its `.md` twin, so a curious agent gets Markdown without asking twice. Every page also has a **Copy Markdown** button and an **Open in** menu for ChatGPT, Claude and friends. +### Implementation prompts + +A guide that adds a feature to someone's project can also show **Copy implementation prompt**. One click copies a prompt for a coding agent: it links the guide and its Markdown, tells the agent to inspect the reader's project, ask a few questions, and then build the feature where the reader chose. The eye button next to it opens a preview of the exact same text, with its own copy button. + +Add it only to a how-to or tutorial that ends in working code inside a plugin or app. Overviews, references and UI component demos do not get one. + +Declare the `implementation` field in the guide's frontmatter: + +```yaml title="apps/web/content/docs/dev/notifications/publishing.mdx" +--- +title: Publish notifications +description: Send a notification from a VitNode plugin. +implementation: + objective: Send an in-app notification when something happens in a plugin. + decisions: + - Which plugin sends it? Offer plugins in the project with an API part (`config.api.ts`). + - Which event triggers it? Offer write routes, actions, queue tasks or cron jobs found in the chosen plugin. + - Who receives it? Offer recipients the chosen event can resolve from existing data, such as the content author. + - Should members also get email by default? None (inbox only), immediately, or in a daily or weekly digest. + constraints: + - Publish with `c.get("notifications").publish()` inside the same transaction as the write. + related: + - title: Notification types + href: /docs/dev/notifications/notification-types +--- +``` + +| Field | Required | What to write | +| :------------ | :------- | :--------------------------------------------------------------------------- | +| `objective` | Yes | One line saying what gets built, without the guide's example names | +| `decisions` | Yes | 1 to 6 questions the reader must answer, in dependency order | +| `constraints` | No | Up to 6 rules the agent must keep, such as the API to reuse | +| `related` | No | Up to 4 other docs pages, as `/docs/...` paths, optionally with a `#heading` | + +Write each decision as a question in the reader's words, followed by where the agent finds the answers in the project: "Offer content types from the chosen plugin". The agent discovers the real choices by reading the reader's code, so never list the guide's example plugin, such as `@acme/forum`, as an answer. That plugin exists in the guide, not in the reader's project. When the feature supports a fixed set of options, list exactly those and nothing it cannot do. + +The shared workflow (inspect first, skip answered questions, ask dependent questions after their answer, work without a structured question tool, report the result) lives once in `apps/web/src/docs/implementation/prompt.ts`. Do not repeat it in a guide, and do not copy the tutorial into the metadata: the prompt links to it. + +To check your prompt, run `pnpm dev`, open the guide, and open the preview with the eye button next to **Copy implementation prompt**. Every link should open, and every decision should make sense for a project that has never heard of the guide's example plugin. A typo in the field, such as an unknown key or a link that is not a `/docs/` path, fails the docs build with the file name. YAML reads `key: value` inside a list item as an object, so wrap a line that contains `: ` in single quotes. + --- ## Pull Request Guidelines diff --git a/apps/web/content/docs/dev/moderation/enable-reports.mdx b/apps/web/content/docs/dev/moderation/enable-reports.mdx index b7a9d83cc..2d6c23f37 100644 --- a/apps/web/content/docs/dev/moderation/enable-reports.mdx +++ b/apps/web/content/docs/dev/moderation/enable-reports.mdx @@ -2,6 +2,24 @@ title: Enable reports description: Let signed-in readers report a VitNode Content Engine content type, such as blog articles, with one option and a Report button. icon: Flag +implementation: + objective: Let readers report records of an existing content type to moderators. + decisions: + - Which items can readers report? Offer content types with a public API that do not have reports yet. + - What should moderators see in a report's snapshot? Recommend the default (the title plus the public text, rich text, choice and file fields), or pick fields; they may be private. + - Where does the Report button appear? Offer the plugin's public pages that show the chosen items. + - Should moderators also be able to hide reported items? This needs visibility; if the type has none, ask whether to turn it on. + constraints: + - 'Set `reports: true` or `{ titleField, snapshotFields }` on the content type. Nothing needs migrating.' + - Place `ReportButton` from `@vitnode/core/tanstack/moderation` with the content type id and the record id, so `id` must be in `publicApi.fields`. + - Enabling reports grants nothing by itself. Label the generated moderator permissions in the plugin's locale. + related: + - title: Configure moderators + href: /docs/dev/moderation/configure-moderators + - title: Scoped permissions + href: /docs/dev/moderation/scoped-permissions + - title: Process reports + href: /docs/dev/moderation/process-reports --- import { ImgDocs } from "@/components/fumadocs/img"; diff --git a/apps/web/content/docs/dev/notifications/publishing.mdx b/apps/web/content/docs/dev/notifications/publishing.mdx index 8fdfcc971..5ec6d9176 100644 --- a/apps/web/content/docs/dev/notifications/publishing.mdx +++ b/apps/web/content/docs/dev/notifications/publishing.mdx @@ -2,6 +2,25 @@ title: Publish notifications description: Send a notification from a VitNode plugin. Declare a type with buildNotificationType, register it, and call c.get("notifications").publish() inside your transaction. icon: Send +implementation: + objective: Send an in-app notification when something happens in a plugin. + decisions: + - Which plugin sends it? Offer plugins in the project with an API part (`config.api.ts`). + - Which event triggers it? Offer write routes, actions, queue tasks or cron jobs found in the chosen plugin, such as posting a reply. + - Who receives it? Offer recipients the chosen event can resolve from existing data, such as the content author or participants. The actor is skipped unless the notification is about their own work. + - Should members also get email by default? None (inbox only), immediately, or in a daily or weekly digest. + constraints: + - Declare the type with `buildNotificationType` and register it in `notificationTypes` of the plugin's `buildApiPlugin()`. Its `id` is permanent once released. + - Publish with `c.get("notifications").publish()` inside the same transaction as the write, with an `idempotencyKey` that names the real event. + - Notification text uses the plugin's API message keys in `src/locales/api`, never hardcoded text. + - A default other than email `"none"` needs the type's `email` option. + related: + - title: Notification types + href: /docs/dev/notifications/notification-types + - title: Limit who sees a notification + href: /docs/dev/notifications/access + - title: Email and digests + href: /docs/dev/notifications/email-and-digests --- This guide notifies a topic's author when someone replies to their topic. You declare a notification type once, register it on your API plugin, and publish it from the route that saves the reply. The examples use a forum plugin with the id `@acme/forum`. diff --git a/apps/web/content/docs/dev/widgets/editable-pages.mdx b/apps/web/content/docs/dev/widgets/editable-pages.mdx index 7b509686b..23a6f1726 100644 --- a/apps/web/content/docs/dev/widgets/editable-pages.mdx +++ b/apps/web/content/docs/dev/widgets/editable-pages.mdx @@ -2,6 +2,25 @@ title: Editable Pages description: Declare a page's zones once, and the people who run the site rearrange them on the page itself - Edit widgets from the user menu, drag, Save, done. No admin screen, no zone-to-column mapping, no hand-written save route. icon: PanelsTopLeft +implementation: + objective: Let moderators place and arrange widgets on an existing public page. + decisions: + - Which page becomes editable? Offer public pages declared in the project's plugins, not AdminCP screens. + - Where on the page can widgets go? Settle the areas in user terms, such as above the content, a sidebar or before the footer. + - Which widgets may each area hold? Only this plugin's widgets, named widgets, core widgets too, or every installed widget. + - Who may arrange the page? Offer existing moderator permissions in the plugin, or a new one. + - Should an area show default widgets before anyone edits it? + constraints: + - Declare the page with `defineEditablePage` using a stable, hand-written id, and register it in `editablePages` of `buildApiPlugin()` together with its moderator permission. + - Render `EditablePage` around one `ContentZone` per area, and save through `createContentEditorAdapter`. Write no save route of your own. + - If reading the saved layout fails, the loader throws instead of rendering the defaults. + related: + - title: Content Zones + href: /docs/dev/widgets/content-zones + - title: Defining a widget + href: /docs/dev/widgets/defining-a-block + - title: Visual edit mode + href: /docs/dev/widgets/visual-editor --- A settings page is edited on the settings page. A forum index is edited on the forum index. Nobody opens an admin screen to arrange a page they cannot see. diff --git a/apps/web/content/docs/dev/widgets/end-to-end.mdx b/apps/web/content/docs/dev/widgets/end-to-end.mdx index 36e761b3e..e14fd5115 100644 --- a/apps/web/content/docs/dev/widgets/end-to-end.mdx +++ b/apps/web/content/docs/dev/widgets/end-to-end.mdx @@ -2,6 +2,24 @@ title: End-to-end Example description: Define a block, register it with a plugin, add a blocks() field, write a record, and render it - the whole path in seven steps. icon: Route +implementation: + objective: Add a custom widget and let editors place it on a content type's public page. + decisions: + - Which plugin defines the widget? Offer plugins in the project, preferring one that already exports a `blocks` module. + - What does the widget show, and what does an editor fill in? Settle the content in user terms, such as a title, a short text and a tone. Widget fields can be text, numbers, yes/no switches, choices, dates or groups of these; images, files, relations, users and repeatable lists are not supported yet. + - Which items hold widgets? Offer content types from the chosen plugin that could gain a `blocks()` field, or a new content type such as pages if none fits. + - Where do the widgets render? Offer the public pages that show the chosen items, and which other widgets the area may accept (only this plugin's, core widgets too, or all). + constraints: + - Define the widget with `defineWidget` (`defineBlock` is the same function) and register the same `blocks` module in the browser plugin export and in `buildApiPlugin()`. Write the bare id; the registry adds the namespace. + - Store widgets in a `blocks()` field with an explicit `allowed` list, then build and migrate with the project's scripts. + - Render with `ContentRenderer` or a named `ContentZone` using the generated `blocksRegistry`. Do not fetch or validate widget data separately. + related: + - title: Defining a widget + href: /docs/dev/widgets/defining-a-block + - title: The blocks() field + href: /docs/dev/widgets/blocks-field + - title: Content Zones + href: /docs/dev/widgets/content-zones --- import { Tab, Tabs } from 'fumadocs-ui/components/tabs' diff --git a/apps/web/package.json b/apps/web/package.json index 34f934909..65560f952 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,7 +12,8 @@ "check": "oxfmt --check .", "start": "vitnode start", "typecheck": "tsc --noEmit", - "lint:fix": "oxlint --type-aware --fix" + "lint:fix": "oxlint --type-aware --fix", + "test": "node --test src/tests/*.test.ts" }, "dependencies": { "@hono/zod-openapi": "^1.6.3", diff --git a/apps/web/src/docs/implementation/prompt-actions.tsx b/apps/web/src/docs/implementation/prompt-actions.tsx new file mode 100644 index 000000000..65e08e5c6 --- /dev/null +++ b/apps/web/src/docs/implementation/prompt-actions.tsx @@ -0,0 +1,70 @@ +import { Button } from '@vitnode/core/components/ui/button' +import { + ButtonGroup, + ButtonGroupSeparator, +} from '@vitnode/core/components/ui/button-group' +import { CopyButton } from '@vitnode/core/components/ui/copy-button' +import { TooltipWithContent } from '@vitnode/core/components/ui/tooltip' +import { EyeIcon } from 'lucide-react' +import React from 'react' + +import type { ImplementationPromptInput } from './prompt' + +import { buildImplementationPrompt } from './prompt' + +const importPreviewDialog = async () => await import('./prompt-preview-dialog') + +const ImplementationPromptPreviewDialog = React.lazy(async () => + importPreviewDialog().then((module) => ({ + default: module.ImplementationPromptPreviewDialog, + })), +) + +const prefetchPreviewDialog = () => { + void importPreviewDialog() +} + +/** "Copy implementation prompt" plus a preview of the same text. */ +export const ImplementationPromptActions = ( + input: ImplementationPromptInput, +) => { + const [open, setOpen] = React.useState(false) + const [hasOpened, setHasOpened] = React.useState(false) + const prompt = buildImplementationPrompt(input) + + return ( + <> + + + Copy implementation prompt + + + + + + + + {hasOpened && ( + + + + )} + + ) +} diff --git a/apps/web/src/docs/implementation/prompt-preview-dialog.tsx b/apps/web/src/docs/implementation/prompt-preview-dialog.tsx new file mode 100644 index 000000000..5bbbffd61 --- /dev/null +++ b/apps/web/src/docs/implementation/prompt-preview-dialog.tsx @@ -0,0 +1,48 @@ +import { CopyButton } from '@vitnode/core/components/ui/copy-button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@vitnode/core/components/ui/dialog' + +export const ImplementationPromptPreviewDialog = ({ + onOpenChange, + open, + prompt, +}: { + onOpenChange: (open: boolean) => void + open: boolean + prompt: string +}) => ( + + + + Implementation prompt + + Paste it into your coding agent. The agent reads this guide, looks + through your project and asks you a few questions before it changes + anything. + + + +
+        {prompt}
+      
+ + + + Copy prompt + + +
+
+) diff --git a/apps/web/src/docs/implementation/prompt.ts b/apps/web/src/docs/implementation/prompt.ts new file mode 100644 index 000000000..df6b4b4c3 --- /dev/null +++ b/apps/web/src/docs/implementation/prompt.ts @@ -0,0 +1,91 @@ +import type { DocsImplementation } from './schema' + +export interface ImplementationPromptInput { + implementation: DocsImplementation + /** Path of the guide's Markdown twin, such as `/docs/dev/fetcher.md`. */ + markdownUrl: string + /** Absolute origin the links point at, such as `https://vitnode.com`. */ + origin: string + title: string + /** Path of the guide, such as `/docs/dev/fetcher`. */ + url: string +} + +const toMarkdownPath = (path: string) => { + const page = path.split('#')[0] ?? path + + return `${page}.md` +} + +const absolute = (origin: string, path: string) => new URL(path, origin).href + +const list = (items: readonly string[], numbered = false) => + items + .map((item, index) => `${numbered ? `${index + 1}.` : '-'} ${item}`) + .join('\n') + +/** + * Builds the prompt behind "Copy implementation prompt": the shared agent + * workflow plus the guide's own decisions and constraints. Pure, so the same + * input always gives the same text. + */ +export const buildImplementationPrompt = ({ + implementation: { constraints = [], decisions, objective, related = [] }, + markdownUrl, + origin, + title, + url, +}: ImplementationPromptInput): string => { + const links = [ + `- Guide: ${absolute(origin, url)}`, + `- Guide as Markdown: ${absolute(origin, markdownUrl)}`, + ...related.map( + (link) => + `- ${link.title}: ${absolute(origin, link.href)} (Markdown: ${absolute(origin, toMarkdownPath(link.href))})`, + ), + ] + + const sections = [ + `# Implement in my VitNode project: ${title}`, + `Objective: ${objective}`, + 'You are a coding agent working in my project. Use the VitNode guide below as the technical reference, find where this feature belongs in my project, settle the open choices with me, then implement it.', + `## Documentation\n\n${links.join('\n')}`, + `## 1. Read the guide and inspect the project (no edits yet)\n\n${list([ + 'Read the guide as Markdown before changing anything. If you cannot open it, tell me instead of working from memory.', + 'Follow the project instructions, such as AGENTS.md or CLAUDE.md.', + 'Check the installed VitNode version (`@vitnode/core` in package.json and the lockfile) and the conventions the project already uses.', + 'Find the plugins, modules, content types, routes and UI components that matter for this task.', + "The guide's example plugin and names show the technique. They are not the target: integrate into what this project contains.", + 'If the guide and the installed code differ, inspect the installed implementation (its types and exports), never invent an API, and tell me about any material difference.', + ])}`, + `## 2. Ask a short questionnaire before editing\n\nSettle these decisions with me before you edit files:\n\n${list(decisions, true)}\n\nHow to ask:\n\n${list( + [ + 'Skip a decision I already answered in this conversation, or one the project settles beyond doubt, such as a single possible target. Say what you assumed.', + 'Offer concrete choices you found in the project. Never present an invented plugin, module or content type as a discovered choice.', + 'Recommend one choice when there is a clear reason, and let me pick another.', + 'Use the names I see in the interface, such as "Topics" or "Comments", and map them to technical ids yourself.', + 'Ask only what changes the implementation. Do not ask for paths, imports, identifiers or other details you can find in the code.', + 'Ask independent questions together in one short round. Ask a dependent question after its answer, for example offer content types only from the plugin I chose.', + 'Use a structured question tool if you have one. Otherwise ask in plain text with short numbered options I can answer by number.', + 'If the project has nothing that fits a decision, explain the gap and ask how to proceed. Never pick an ambiguous target silently.', + ], + )}`, + `## 3. Implement\n\n${list([ + 'When the required answers are in, state the resulting scope in two or three lines and start. Do not ask for another confirmation.', + 'Reuse the existing VitNode features, permissions and UI conventions instead of building parallel ones.', + 'Complete the server part and the public UI integration the guide describes, for the chosen target.', + 'Keep the change focused on this task and preserve unrelated work, including uncommitted changes.', + "Run the project's existing checks that apply, such as type checking, linting, tests and the build.", + ])}`, + ...(constraints.length > 0 + ? [`Rules for this feature:\n\n${list(constraints)}`] + : []), + `## 4. Report\n\n${list([ + 'What changed, by file, and the behavior I can now see.', + 'Which checks you ran and their results.', + 'Anything unresolved, including differences between the guide and the installed VitNode version.', + ])}`, + ] + + return `${sections.join('\n\n')}\n` +} diff --git a/apps/web/src/docs/implementation/schema.ts b/apps/web/src/docs/implementation/schema.ts new file mode 100644 index 000000000..d2f7cfc43 --- /dev/null +++ b/apps/web/src/docs/implementation/schema.ts @@ -0,0 +1,42 @@ +import { pageSchema } from 'fumadocs-core/source/schema' +import { z } from 'zod' + +const hint = z.string().trim().min(1).max(300) + +/** + * Optional `implementation` frontmatter of a task-oriented guide. Its presence + * shows "Copy implementation prompt" on the page; the shared agent workflow + * lives in `prompt.ts`, so this holds only what is specific to the guide. + */ +export const docsImplementationSchema = z.strictObject({ + /** What the agent builds, in one line, without the guide's example names. */ + objective: z.string().trim().min(1).max(160), + /** + * Choices the agent settles with the user before editing, in the order they + * depend on each other. Say which project evidence offers the choices. + */ + decisions: z.array(hint).min(1).max(6), + /** Feature rules the agent must keep, such as the API to reuse. */ + constraints: z.array(hint).max(6).optional(), + /** Other public docs pages the agent may need, as `/docs/...` paths. */ + related: z + .array( + z.strictObject({ + href: z + .string() + .regex( + /^\/docs(?:\/[a-z0-9-]+)+(?:#[a-z0-9-]+)?$/, + 'Use a docs path such as /docs/dev/fetcher or /docs/dev/fetcher#errors.', + ), + title: z.string().trim().min(1).max(80), + }), + ) + .max(4) + .optional(), +}) + +export type DocsImplementation = z.infer + +export const docsPageSchema = pageSchema.extend({ + implementation: docsImplementationSchema.optional(), +}) diff --git a/apps/web/src/docs/page-content.tsx b/apps/web/src/docs/page-content.tsx index 6004ffbf5..4bd5bb36a 100644 --- a/apps/web/src/docs/page-content.tsx +++ b/apps/web/src/docs/page-content.tsx @@ -24,6 +24,7 @@ export const DocsPageContent = ({ githubUrl, markdownUrl, path, + url, }: DocsPageData) => { const pathname = usePathname() const page = docs.getPage(path) @@ -45,8 +46,10 @@ export const DocsPageContent = ({ diff --git a/apps/web/src/docs/page-header.tsx b/apps/web/src/docs/page-header.tsx index 88ada4ef5..626a714ef 100644 --- a/apps/web/src/docs/page-header.tsx +++ b/apps/web/src/docs/page-header.tsx @@ -11,6 +11,12 @@ import { import { ChevronRight } from 'lucide-react' import { Fragment } from 'react' +import { SITE_ORIGIN } from '@/site/marketing/links' + +import type { DocsImplementation } from './implementation/schema' + +import { ImplementationPromptActions } from './implementation/prompt-actions' + const markdownCache = new Map() const loadMarkdown = async (markdownUrl: string) => { @@ -55,13 +61,17 @@ const IconArtwork = ({ icon }: { icon: React.ReactNode }) => ( export const DocsPageHeader = ({ description, githubUrl, + implementation, markdownUrl, title, + url, }: { description?: string githubUrl: string + implementation?: DocsImplementation markdownUrl: string title: string + url: string }) => { const path = useTreePath() const icon = path.at(-1)?.icon @@ -92,6 +102,15 @@ export const DocsPageHeader = ({ {description}
+ {implementation && ( + + )} await loadMarkdown(markdownUrl)} size="sm" diff --git a/apps/web/src/docs/source.ts b/apps/web/src/docs/source.ts index 5cdadbefc..b813b7eef 100644 --- a/apps/web/src/docs/source.ts +++ b/apps/web/src/docs/source.ts @@ -1,5 +1,7 @@ import { defineDocs } from 'fumadocs-mdx/macro' +import { docsPageSchema } from './implementation/schema' + export const docs = defineDocs({ dir: 'content/docs', docs: { @@ -7,5 +9,6 @@ export const docs = defineDocs({ postprocess: { includeProcessedMarkdown: true, }, + schema: docsPageSchema, }, }) diff --git a/apps/web/src/docs/transport.ts b/apps/web/src/docs/transport.ts index 3c7db9404..21b545fa7 100644 --- a/apps/web/src/docs/transport.ts +++ b/apps/web/src/docs/transport.ts @@ -31,6 +31,7 @@ export const getDocsPage = createServerFn({ method: 'GET' }) markdownUrl: encodeMarkdownUrl(page.slugs), metaTitle: [page.data.title, ...ancestors].join(' - '), path: page.path, + url: page.url, } }) diff --git a/apps/web/src/tests/implementation-prompt.test.ts b/apps/web/src/tests/implementation-prompt.test.ts new file mode 100644 index 000000000..a2d6eccc8 --- /dev/null +++ b/apps/web/src/tests/implementation-prompt.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { ImplementationPromptInput } from '../docs/implementation/prompt.ts' + +import { buildImplementationPrompt } from '../docs/implementation/prompt.ts' + +const origin = 'https://docs.example.com' + +const input: ImplementationPromptInput = { + implementation: { + constraints: [ + 'Use the `thread` option.', + 'Create the table with migrations.', + ], + decisions: [ + 'Which plugin owns the replies?', + 'Which items get replies?', + 'How deep can replies nest?', + ], + objective: 'Add threaded replies under an existing content type.', + related: [ + { href: '/docs/content-engine/public-write', title: 'Public write' }, + { + href: '/docs/content-engine/reference#thread', + title: 'Thread reference', + }, + ], + }, + markdownUrl: '/docs/content-engine/threads.md', + origin, + title: 'Threaded replies', + url: '/docs/content-engine/threads', +} + +const minimal: ImplementationPromptInput = { + ...input, + implementation: { + decisions: ['Which plugin owns the replies?'], + objective: 'Add threaded replies under an existing content type.', + }, +} + +const absoluteUrls = (prompt: string) => + (prompt.match(/https:\/\/[^\s)]+/g) ?? []).map((href) => new URL(href)) + +describe('buildImplementationPrompt', () => { + it('returns the same text for the same input', () => { + assert.equal( + buildImplementationPrompt(input), + buildImplementationPrompt(structuredClone(input)), + ) + }) + + it('lists the decisions numbered in order and the constraints', () => { + const prompt = buildImplementationPrompt(input) + + assert.ok( + prompt.includes( + '1. Which plugin owns the replies?\n2. Which items get replies?\n3. How deep can replies nest?', + ), + ) + assert.ok( + prompt.includes( + 'Rules for this feature:\n\n- Use the `thread` option.\n- Create the table with migrations.', + ), + ) + }) + + it('leaves out the rules block and related links when the page has none', () => { + const prompt = buildImplementationPrompt(minimal) + + assert.ok(!prompt.includes('Rules for this feature')) + assert.deepEqual( + absoluteUrls(prompt).map((url) => url.href), + [ + `${origin}/docs/content-engine/threads`, + `${origin}/docs/content-engine/threads.md`, + ], + ) + }) + + it('points every link at the given origin and drops the anchor only from Markdown links', () => { + const prompt = buildImplementationPrompt(input) + const urls = absoluteUrls(prompt) + + assert.deepEqual( + urls.map((url) => url.href), + [ + `${origin}/docs/content-engine/threads`, + `${origin}/docs/content-engine/threads.md`, + `${origin}/docs/content-engine/public-write`, + `${origin}/docs/content-engine/public-write.md`, + `${origin}/docs/content-engine/reference#thread`, + `${origin}/docs/content-engine/reference.md`, + ], + ) + for (const url of urls) { + assert.equal(url.origin, origin) + } + }) + + it('tells the agent to resolve ambiguity before editing files', () => { + const prompt = buildImplementationPrompt(input) + + for (const phrase of [ + 'Settle these decisions with me before you edit files', + 'Never pick an ambiguous target silently', + 'Skip a decision I already answered', + 'Otherwise ask in plain text with short numbered options', + ]) { + assert.ok(prompt.includes(phrase), `missing: ${phrase}`) + } + }) +}) diff --git a/apps/web/src/tests/implementation-schema.test.ts b/apps/web/src/tests/implementation-schema.test.ts new file mode 100644 index 000000000..993875c63 --- /dev/null +++ b/apps/web/src/tests/implementation-schema.test.ts @@ -0,0 +1,75 @@ +import { frontmatter } from 'fumadocs-core/content/md/frontmatter' +import assert from 'node:assert/strict' +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, it } from 'node:test' + +import { docsPageSchema } from '../docs/implementation/schema.ts' + +const docsDir = resolve(import.meta.dirname, '../../content/docs') + +const implementation = { + decisions: ['Which plugin owns the replies?'], + objective: 'Add threaded replies under an existing content type.', +} + +describe('docsPageSchema', () => { + it('accepts a page without implementation metadata', () => { + assert.ok(docsPageSchema.safeParse({ title: 'Plain page' }).success) + }) + + const invalid: [string, unknown][] = [ + ['an unknown key', { ...implementation, steps: ['Do it'] }], + ['an empty decisions list', { ...implementation, decisions: [] }], + [ + 'a related link outside /docs', + { + ...implementation, + related: [{ href: 'https://example.com/docs/x', title: 'X' }], + }, + ], + ] + + for (const [name, value] of invalid) { + it(`rejects ${name}`, () => { + assert.ok( + !docsPageSchema.safeParse({ implementation: value, title: 'Guide' }) + .success, + ) + }) + } +}) + +describe('guide implementation metadata', () => { + const guides = readdirSync(docsDir, { recursive: true, encoding: 'utf8' }) + .filter((path) => path.endsWith('.mdx')) + .map((path) => ({ + data: frontmatter(readFileSync(join(docsDir, path), 'utf8')).data, + path, + })) + .filter( + ({ data }) => + typeof data === 'object' && data !== null && 'implementation' in data, + ) + + it('is declared on several guides', () => { + assert.ok(guides.length >= 3, `found ${guides.length}`) + }) + + for (const { data, path } of guides) { + it(`${path} is valid and links only to existing docs pages`, () => { + const page = docsPageSchema.parse(data) + + assert.ok(page.implementation, 'implementation frontmatter is missing') + for (const { href } of page.implementation.related ?? []) { + const slug = href.replace(/^\/docs\//, '').replace(/#.*$/, '') + + assert.ok( + existsSync(join(docsDir, `${slug}.mdx`)) || + existsSync(join(docsDir, slug, 'index.mdx')), + `${href} has no matching .mdx page`, + ) + } + }) + } +}) diff --git a/packages/vitnode/src/framework/vite/ssr-externals.ts b/packages/vitnode/src/framework/vite/ssr-externals.ts index 0a9f5f794..698a3dac9 100644 --- a/packages/vitnode/src/framework/vite/ssr-externals.ts +++ b/packages/vitnode/src/framework/vite/ssr-externals.ts @@ -26,14 +26,7 @@ export const vitNodeSsrExternals = ({ config: async (_userConfig, { command }) => ({ nitro: command === "build" - ? { - // Writes `.gz`/`.br` next to every static asset and prerendered - // page at build time; Nitro's static handler serves the one the - // browser accepts, so no CPU is spent compressing per request. - // An app's own `nitro({ compressPublicAssets })` still wins. - compressPublicAssets: { brotli: true, gzip: true }, - traceDeps: [...NITRO_TRACED_DEPENDENCIES], - } + ? { traceDeps: [...NITRO_TRACED_DEPENDENCIES] } : undefined, ssr: { external: