From b86fd245f402dc5bb47edd091d21115efeea2b6b Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:40:41 -0400 Subject: [PATCH 1/3] docs: add plugin evaluator decision surfaces (#262) --- docs/content/docs/plugins/blog.mdx | 14 +- docs/content/docs/plugins/form-builder.mdx | 21 +- docs/content/docs/plugins/index.mdx | 44 +- docs/content/docs/plugins/open-api.mdx | 21 +- docs/package.json | 7 +- docs/src/components/plugin-evaluator.tsx | 482 ++++++++++++++++++ packages/cli/src/lib.ts | 2 +- packages/cli/src/meta.ts | 2 +- packages/cli/src/plugin-meta.ts | 2 + .../src/utils/__tests__/plugin-meta.test.ts | 83 +++ packages/cli/src/utils/constants.ts | 6 + packages/cli/src/utils/plugin-decision.ts | 129 +++++ pnpm-lock.yaml | 3 + 13 files changed, 742 insertions(+), 74 deletions(-) create mode 100644 docs/src/components/plugin-evaluator.tsx create mode 100644 packages/cli/src/plugin-meta.ts create mode 100644 packages/cli/src/utils/__tests__/plugin-meta.test.ts create mode 100644 packages/cli/src/utils/plugin-decision.ts diff --git a/docs/content/docs/plugins/blog.mdx b/docs/content/docs/plugins/blog.mdx index 79d592a57..128ce9c5b 100644 --- a/docs/content/docs/plugins/blog.mdx +++ b/docs/content/docs/plugins/blog.mdx @@ -5,19 +5,9 @@ description: Content management, editor, drafts, publishing, SEO and more import { Tabs, Tab } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; -import Image from "next/image"; +import { PluginEvaluatorHeader } from "@/components/plugin-evaluator"; -import blogProof from "../../../assets/product-proof/blog-proof.webp"; - -
- - Real BTST Blog page with three published product-update posts in the generated Next.js application. - -
- -Blog is the canonical full-stack proof: routes, backend behavior, client UI, and a visible published result arrive as one plugin. - -[View interactive demo →](https://www.better-stack.ai/playground?plugins=blog&view=preview) + ## Installation diff --git a/docs/content/docs/plugins/form-builder.mdx b/docs/content/docs/plugins/form-builder.mdx index 7c60dfd76..9dc116efa 100644 --- a/docs/content/docs/plugins/form-builder.mdx +++ b/docs/content/docs/plugins/form-builder.mdx @@ -5,26 +5,9 @@ description: Visual drag-and-drop form builder with JSON Schema storage and publ import { Tabs, Tab } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; -import Image from "next/image"; +import { PluginEvaluatorHeader } from "@/components/plugin-evaluator"; -import formBuilderProof from "../../../assets/product-proof/form-builder-proof.webp"; - -
- - Real BTST Form Builder with Email, Text Area, and Select fields on the canvas and in the live form preview. - -
- -The Form Builder plugin provides a visual drag-and-drop form creation interface where administrators can create forms that are serialized and stored as JSON Schema. This is distinct from the CMS plugin - while CMS uses developer-defined Zod schemas, Form Builder allows non-technical administrators to create forms dynamically. - -**Key Features:** -- **Visual Form Builder** - Drag-and-drop interface for creating forms with various field types -- **JSON Schema Storage** - Forms are serialized to JSON Schema for database persistence -- **Public Form Rendering** - Render forms by slug on the frontend with automatic validation -- **Submission Tracking** - Store and view form submissions with IP address and user agent logging -- **Backend Hooks** - Typed lifecycle hooks for domain validation, rate limiting, and integrations - -[View interactive demo →](https://www.better-stack.ai/playground?plugins=form-builder&view=preview) + ## Installation diff --git a/docs/content/docs/plugins/index.mdx b/docs/content/docs/plugins/index.mdx index 2bf5655bf..ead7c91b9 100644 --- a/docs/content/docs/plugins/index.mdx +++ b/docs/content/docs/plugins/index.mdx @@ -4,7 +4,8 @@ description: Available plugins and features for BTST --- import { Card, Cards } from "fumadocs-ui/components/card"; -import { BookOpen, Database, Hammer, Bot, FileText, FileCode, Route, Layout, Columns3, MessageSquare, ImageIcon, ShieldCheck } from "lucide-react"; +import { Database, Hammer, Bot, Route, Layout, Columns3, MessageSquare, ImageIcon, ShieldCheck } from "lucide-react"; +import { PluginCatalogCard } from "@/components/plugin-evaluator"; BTST ships installable capabilities with different boundaries. Full-stack @@ -16,13 +17,20 @@ Every capability below is released and installable. Choose a page to see its actual payload and prerequisites; the catalog does not mix roadmap ideas into the available inventory. +## Representative decision guides + +These three entries establish the decision pattern for a complete feature, a +workflow-heavy feature, and an intentionally one-sided plugin. + +
+ + + +
+ +## More released plugins + - } - description="Content management, editor, drafts, publishing, SEO, RSS feeds." - /> } description="Headless CMS with custom content types, Zod schemas, and auto-generated forms." /> - } - description="Dynamic form builder with drag-and-drop editor, submissions, and validation." - /> } description="Media library with uploads, folders, picker UI, URL registration, and reusable image inputs." /> - } - description="Auto-generated API documentation with interactive Scalar UI." - /> + +## Versioned technical metadata + +Build-time consumers can import `PLUGINS` and the `PluginDecisionMeta` type from +`@btst/codegen/meta`. The contract carries technical inventory such as topology, +relationship, maturity, dependencies, maintained integrations, and canonical +docs, demo, and source identifiers. Audience copy, workflow wording, and calls +to action remain presentation-owned. + + diff --git a/docs/content/docs/plugins/open-api.mdx b/docs/content/docs/plugins/open-api.mdx index 54e4103c9..3e1c22f6f 100644 --- a/docs/content/docs/plugins/open-api.mdx +++ b/docs/content/docs/plugins/open-api.mdx @@ -5,26 +5,9 @@ description: Auto-generated API documentation with interactive Scalar UI import { Tabs, Tab } from "fumadocs-ui/components/tabs"; import { Callout } from "fumadocs-ui/components/callout"; -import Image from "next/image"; +import { PluginEvaluatorHeader } from "@/components/plugin-evaluator"; -import openApiProof from "../../../assets/product-proof/openapi-proof.webp"; - -
- - Real Scalar API reference generated by the backend-only BTST OpenAPI plugin. - -
- -The OpenAPI plugin automatically generates OpenAPI 3.1 documentation for all your BTST plugins. It provides both a JSON schema endpoint and an interactive API reference UI powered by [Scalar](https://scalar.com/). - -## Features - -- **Automatic Schema Generation** - Traverses all registered plugins and extracts endpoint metadata -- **OpenAPI 3.1 Compliant** - Generates valid OpenAPI 3.1 schemas from Zod definitions -- **Authorization Metadata** - Documents explicit public access or the stable permission ID enforced by each operation -- **Interactive UI** - Beautiful API reference page powered by Scalar -- **Multiple Themes** - Choose from 10+ Scalar themes to match your brand -- **Zero Configuration** - Works out of the box with sensible defaults + ## Installation diff --git a/docs/package.json b/docs/package.json index ce630d1df..2ed9a9e5f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -3,13 +3,14 @@ "version": "0.0.0", "private": true, "scripts": { - "build": "next build", - "build:vercel": "next build", - "dev": "next dev", + "build": "pnpm --filter @btst/codegen build && next build", + "build:vercel": "pnpm --filter @btst/codegen build && next build", + "dev": "pnpm --filter @btst/codegen build && next dev", "start": "next start", "postinstall": "fumadocs-mdx" }, "dependencies": { + "@btst/codegen": "workspace:*", "@btst/stack": "workspace:*", "@vercel/analytics": "^1.6.1", "framer-motion": "^12.19.1", diff --git a/docs/src/components/plugin-evaluator.tsx b/docs/src/components/plugin-evaluator.tsx new file mode 100644 index 000000000..a74444e57 --- /dev/null +++ b/docs/src/components/plugin-evaluator.tsx @@ -0,0 +1,482 @@ +import { ArrowRight, ExternalLink } from "lucide-react"; +import Image, { type StaticImageData } from "next/image"; +import Link from "next/link"; +import type { ReactNode } from "react"; +import blogProof from "../../assets/product-proof/blog-proof.webp"; +import formBuilderProof from "../../assets/product-proof/form-builder-proof.webp"; +import productProofManifest from "../../assets/product-proof/manifest.json"; +import openApiProof from "../../assets/product-proof/openapi-proof.webp"; +import { PLUGINS } from "@btst/codegen/meta"; + +type RepresentativePluginKey = "blog" | "form-builder" | "open-api"; +type PluginDecisionMeta = NonNullable< + (typeof PLUGINS)[number]["decision"] +>; +type DecisionActionTarget = "demo" | "docs" | "installation" | "workflow"; + +interface WorkflowStepCopy { + label: string; + detail: string; +} + +interface DecisionActionCopy { + label: string; + target: DecisionActionTarget; +} + +interface ResolvedDecisionAction extends DecisionActionCopy { + href: string; + external: boolean; +} + +interface RepresentativeCopy { + outcome: string; + audience: string; + ownership: string; + workflow: readonly WorkflowStepCopy[]; + catalogAction: DecisionActionCopy; + actions: readonly [DecisionActionCopy, DecisionActionCopy]; +} + +const representativeCopy = { + blog: { + outcome: + "Publish and manage a content section inside the React application you already run.", + audience: + "React teams that need editorial workflows and public, indexable content without adopting a separate hosted CMS.", + ownership: + "Posts stay in your database, routes run in your application, and ejected Blog views become editable application code.", + workflow: [ + { + label: "Register", + detail: "Add the Blog backend and client halves to the existing stack.", + }, + { + label: "Write", + detail: "Create and edit drafts with the supplied authoring routes.", + }, + { + label: "Store", + detail: + "Persist posts and tags through your selected database adapter.", + }, + { + label: "Publish", + detail: + "Serve the SSR-aware post route with metadata and sitemap output.", + }, + ], + catalogAction: { label: "Explore Blog", target: "docs" }, + actions: [ + { label: "View live Blog", target: "demo" }, + { label: "Install Blog", target: "installation" }, + ], + }, + "form-builder": { + outcome: + "Let operators design forms visually, publish them inside your app, and collect validated submissions.", + audience: + "Product teams that need runtime-created forms rather than developer-defined CMS content models.", + ownership: + "Form schemas and submissions stay in your database; public rendering, field overrides, hooks, and deployment remain in your application.", + workflow: [ + { + label: "Build", + detail: + "Arrange fields in the visual editor and check the live preview.", + }, + { + label: "Store schema", + detail: "Save the generated JSON Schema with the form record.", + }, + { + label: "Render", + detail: "Mount FormRenderer on a public route owned by your app.", + }, + { + label: "Collect", + detail: + "Validate submitted data against the schema and store the record.", + }, + ], + catalogAction: { label: "Explore Form Builder", target: "docs" }, + actions: [ + { label: "Understand the workflow", target: "workflow" }, + { label: "Install Form Builder", target: "installation" }, + ], + }, + "open-api": { + outcome: + "Expose an OpenAPI 3.1 contract for registered BTST routes and, when useful, an interactive API reference.", + audience: + "Backend teams that need machine-readable API documentation without adding a matching BTST client plugin.", + ownership: + "The schema stays at your backend base path plus the fixed /open-api/schema suffix. The optional reference defaults to /reference, title and version have defaults, and your deployment and access boundary remain yours.", + workflow: [ + { + label: "Register", + detail: + "Add the backend-only plugin to the existing BTST backend stack.", + }, + { + label: "Inspect", + detail: "Read registered endpoint metadata and Zod request schemas.", + }, + { + label: "Generate 3.1", + detail: "Serve a deterministic OpenAPI 3.1 document as JSON.", + }, + { + label: "Optionally render", + detail: "Keep JSON only or expose the Scalar reference page.", + }, + ], + catalogAction: { label: "Explore OpenAPI", target: "docs" }, + actions: [ + { label: "Inspect the output", target: "workflow" }, + { label: "Install OpenAPI", target: "installation" }, + ], + }, +} as const satisfies Record; + +const representativeAssets = { + blog: { file: "blog-proof.webp", image: blogProof }, + "form-builder": { + file: "form-builder-proof.webp", + image: formBuilderProof, + }, + "open-api": { file: "openapi-proof.webp", image: openApiProof }, +} as const satisfies Record< + RepresentativePluginKey, + { file: string; image: StaticImageData } +>; + +function getRepresentativePlugin(key: RepresentativePluginKey) { + const plugin = PLUGINS.find((candidate) => candidate.key === key); + if (!plugin?.decision) { + throw new Error(`Missing evaluator metadata for ${key}`); + } + return { plugin, decision: plugin.decision, copy: representativeCopy[key] }; +} + +function getProofAsset(key: RepresentativePluginKey) { + const asset = representativeAssets[key]; + const copy = productProofManifest.assets.find( + (candidate) => candidate.file === asset.file, + ); + if (!copy) throw new Error(`Missing product-proof manifest entry for ${key}`); + return { ...asset, alt: copy.alt, caption: copy.caption }; +} + +function DecisionLink({ + action, + className, + children, +}: { + action: ResolvedDecisionAction; + className: string; + children: ReactNode; +}) { + return action.external ? ( + + {children} + + ) : ( + + {children} + + ); +} + +function TopologyBadges({ decision }: { decision: PluginDecisionMeta }) { + return ( +
+ + {decision.topology} + + {decision.relationship ? ( + + {decision.relationship} + + ) : null} + + Released · {decision.releaseStatus} + +
+ ); +} + +function FactBlock({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +function FactList({ items }: { items: readonly string[] }) { + return ( +
    + {items.map((item) => ( +
  • +
  • + ))} +
+ ); +} + +function WorkflowStrip({ + id, + steps, +}: { + id: string; + steps: readonly WorkflowStepCopy[]; +}) { + return ( +
+
+

+ From registration to result +

+

+ A semantic workflow, not a setup shortcut +

+
+
    + {steps.map((step, index) => ( +
  1. +
    + + {index + 1} + + {step.label} +
    +

    + {step.detail} +

    +
  2. + ))} +
+
+ ); +} + +function resolveAction( + key: RepresentativePluginKey, + decision: PluginDecisionMeta, + action: DecisionActionCopy, +): ResolvedDecisionAction { + switch (action.target) { + case "demo": + if (!decision.demoPath) { + throw new Error(`Missing demo identifier for ${key}`); + } + return { ...action, href: decision.demoPath, external: true }; + case "docs": + return { ...action, href: decision.docsPath, external: false }; + case "installation": + return { ...action, href: "#installation", external: false }; + case "workflow": + return { ...action, href: `#${key}-workflow`, external: false }; + } +} + +export function PluginEvaluatorHeader({ + pluginKey, +}: { + pluginKey: RepresentativePluginKey; +}) { + const { decision, copy } = getRepresentativePlugin(pluginKey); + const proof = getProofAsset(pluginKey); + const actions = copy.actions.map((action) => + resolveAction(pluginKey, decision, action), + ); + + return ( +
+
+
+ +

+ Best for +

+

+ {copy.audience} +

+

+ {copy.outcome} +

+
+ {actions.map((action, index) => ( + + {action.label} + {action.external ? ( + + ))} +
+
+ +
+ {proof.alt} +
+ {proof.caption} +
+
+
+ +
+ + + + + + + +

{copy.ownership}

+
+ +

+ Maintained:{" "} + {decision.supportedFrameworks.join(", ")}. +

+

+ Requires:{" "} + {decision.dependencies.join("; ")}. +

+

+ External services:{" "} + {decision.externalServices.length > 0 + ? `${decision.externalServices.join("; ")}.` + : "None required."} +

+
+
+ + +
+ ); +} + +export function PluginCatalogCard({ + pluginKey, +}: { + pluginKey: RepresentativePluginKey; +}) { + const { plugin, decision, copy } = getRepresentativePlugin(pluginKey); + const proof = getProofAsset(pluginKey); + const catalogAction = resolveAction( + pluginKey, + decision, + copy.catalogAction, + ); + + return ( +
+ {proof.alt} +
+ +

+ + {plugin.label} + +

+

+ {copy.outcome} +

+
+
+
+ Requires +
+
+ {decision.dependencies.join("; ")} +
+
+
+
+ Maintained paths +
+
+ {decision.supportedFrameworks.join(" · ")} +
+
+
+ + {catalogAction.label} + {catalogAction.external ? ( + +
+
+ ); +} diff --git a/packages/cli/src/lib.ts b/packages/cli/src/lib.ts index 4e2509b68..482e51471 100644 --- a/packages/cli/src/lib.ts +++ b/packages/cli/src/lib.ts @@ -22,5 +22,5 @@ export type { FileWritePlanItem, ScaffoldPlan, } from "./types"; -export type { PluginMeta, AdapterMeta } from "./utils/constants"; +export type * from "./plugin-meta"; export type { SeedRouteFile } from "./utils/seed-plan"; diff --git a/packages/cli/src/meta.ts b/packages/cli/src/meta.ts index 2029007d9..5389b0bea 100644 --- a/packages/cli/src/meta.ts +++ b/packages/cli/src/meta.ts @@ -12,5 +12,5 @@ export type { FileWritePlanItem, ScaffoldPlan, } from "./types"; -export type { PluginMeta, AdapterMeta } from "./utils/constants"; +export type * from "./plugin-meta"; export type { SeedRouteFile } from "./utils/seed-plan"; diff --git a/packages/cli/src/plugin-meta.ts b/packages/cli/src/plugin-meta.ts new file mode 100644 index 000000000..e77f19cd4 --- /dev/null +++ b/packages/cli/src/plugin-meta.ts @@ -0,0 +1,2 @@ +export type { AdapterMeta, PluginMeta } from "./utils/constants"; +export type { PluginDecisionMeta } from "./utils/plugin-decision"; diff --git a/packages/cli/src/utils/__tests__/plugin-meta.test.ts b/packages/cli/src/utils/__tests__/plugin-meta.test.ts new file mode 100644 index 000000000..dc2884ebe --- /dev/null +++ b/packages/cli/src/utils/__tests__/plugin-meta.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { PLUGINS } from "../constants"; + +const maintainedFrameworks = [ + "Next.js 15+ App Router", + "React Router v7", + "TanStack Start", +] as const; + +function representativePlugin(key: "blog" | "form-builder" | "open-api") { + const plugin = PLUGINS.find((candidate) => candidate.key === key); + if (!plugin) throw new Error(`Missing plugin metadata for ${key}`); + if (!plugin.decision) throw new Error(`Missing decision metadata for ${key}`); + return { plugin, decision: plugin.decision }; +} + +describe("representative plugin decision metadata", () => { + it.each(["blog", "form-builder", "open-api"] as const)( + "publishes the shared evaluator contract for %s", + (key) => { + const { decision } = representativePlugin(key); + + expect(decision.releaseStatus).toBe("Preview"); + expect(decision.supportedFrameworks).toEqual(maintainedFrameworks); + expect(decision.docsPath).toBe(`/plugins/${key}`); + expect(decision.sourcePath).toContain(`/plugins/${key}`); + expect(decision.supplies.length).toBeGreaterThan(0); + expect(decision.adopterSupplies.length).toBeGreaterThan(0); + expect(decision).not.toHaveProperty("audience"); + expect(decision).not.toHaveProperty("ownership"); + expect(decision).not.toHaveProperty("workflow"); + expect(decision).not.toHaveProperty("actions"); + }, + ); + + it("describes Blog as the complete feature proof with a working live result", () => { + const { plugin, decision } = representativePlugin("blog"); + + expect(decision.topology).toBe("Full-stack"); + expect(plugin.backendImportPath).toBeDefined(); + expect(plugin.clientImportPath).toBeDefined(); + expect(decision.demoPath).toBe("https://www.better-stack.ai/p/blog"); + expect(decision.adopterSupplies).toContain( + "An authorization policy when protected authoring operations are enabled", + ); + }); + + it("states the complete Form Builder data workflow without inventing a demo", () => { + const { plugin, decision } = representativePlugin("form-builder"); + + expect(decision.topology).toBe("Full-stack"); + expect(plugin.backendImportPath).toBeDefined(); + expect(plugin.clientImportPath).toBeDefined(); + expect(decision.demoPath).toBeUndefined(); + expect(decision.dependencies).toContain( + "A database adapter with isolated transaction support", + ); + expect(decision.adopterSupplies).toContain( + "The public application route that mounts FormRenderer", + ); + expect(decision.adopterSupplies).toContain( + "An explicit public-access or permission policy for form reads and submissions when authorization is enabled", + ); + }); + + it("keeps OpenAPI backend-only and Scalar optional", () => { + const { plugin, decision } = representativePlugin("open-api"); + + expect(decision.topology).toBe("Backend-only"); + expect(plugin.backendImportPath).toBeDefined(); + expect(plugin.clientImportPath).toBeUndefined(); + expect(decision.demoPath).toBeUndefined(); + expect(decision.supplies).toContain( + "A JSON schema endpoint at the configured API base path plus the fixed /open-api/schema suffix", + ); + expect(decision.adopterSupplies).toContain( + "Optional overrides for the API title, version, and reference path; defaults are BTST API, 1.0.0, and /reference", + ); + expect(decision.externalServices).toEqual([ + "The optional Scalar reference loads @scalar/api-reference from jsDelivr", + ]); + }); +}); diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 30b6b9301..69e5f113b 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -1,4 +1,5 @@ import type { Adapter, PluginKey } from "../types"; +import { PLUGIN_DECISIONS, type PluginDecisionMeta } from "./plugin-decision"; export interface AdapterMeta { key: Adapter; @@ -27,6 +28,8 @@ export interface PluginMeta { extraInstallSpecs?: string[]; /** Whether this plugin has sample seed data available for the playground. */ hasSeedData?: boolean; + /** Optional evaluator facts for released plugins with a decision surface. */ + decision?: PluginDecisionMeta; } export const ADAPTERS: readonly AdapterMeta[] = [ @@ -79,6 +82,7 @@ export const PLUGINS: readonly PluginMeta[] = [ clientSymbol: "blogClientPlugin", configKey: "blog", hasSeedData: true, + decision: PLUGIN_DECISIONS.blog, }, { key: "ai-chat", @@ -112,6 +116,7 @@ export const PLUGINS: readonly PluginMeta[] = [ clientSymbol: "formBuilderClientPlugin", configKey: "formBuilder", hasSeedData: true, + decision: PLUGIN_DECISIONS["form-builder"], }, { key: "ui-builder", @@ -172,6 +177,7 @@ export const PLUGINS: readonly PluginMeta[] = [ backendImportPath: "@btst/stack/plugins/open-api/api", backendSymbol: "openApiBackendPlugin", configKey: "openApi", + decision: PLUGIN_DECISIONS["open-api"], }, { key: "better-auth-ui", diff --git a/packages/cli/src/utils/plugin-decision.ts b/packages/cli/src/utils/plugin-decision.ts new file mode 100644 index 000000000..a7c5fed5a --- /dev/null +++ b/packages/cli/src/utils/plugin-decision.ts @@ -0,0 +1,129 @@ +import type { PluginKey } from "../types"; + +/** Registration boundary of an installable BTST plugin. */ +type PluginTopology = "Full-stack" | "Backend-only" | "Client-only"; + +/** Optional relationship to another BTST plugin or external system. */ +type PluginRelationship = "Companion" | "Dependent"; + +/** Evidence-based release status shown on evaluator surfaces. */ +type PluginReleaseStatus = "Stable" | "Preview"; + +/** Framework integrations covered by the maintained v3 integration paths. */ +type MaintainedFramework = + | "Next.js 15+ App Router" + | "React Router v7" + | "TanStack Start"; + +/** + * Versioned technical facts used to evaluate an installable BTST plugin. + * Audience copy, workflow wording, calls to action, and visual presentation are + * owned by the surface that renders these facts. + */ +export interface PluginDecisionMeta { + /** Boundary relative to the registered BTST backend and client stacks. */ + topology: PluginTopology; + /** Relationship to another plugin or external system, when one exists. */ + relationship?: PluginRelationship; + /** Evidence-based support status for the released plugin. */ + releaseStatus: PluginReleaseStatus; + /** Concrete runtime, data, route, or UI capabilities shipped by BTST. */ + supplies: readonly string[]; + /** Application-specific infrastructure or policy the adopter provides. */ + adopterSupplies: readonly string[]; + /** Required BTST, database, or runtime dependencies. */ + dependencies: readonly string[]; + /** External services contacted by the plugin, including optional services. */ + externalServices: readonly string[]; + /** Maintained framework integration paths for this plugin. */ + supportedFrameworks: readonly MaintainedFramework[]; + /** Canonical documentation identifier. */ + docsPath: `/plugins/${string}`; + /** Canonical working demo identifier, when one exists. */ + demoPath?: `https://${string}`; + /** Canonical public source identifier. */ + sourcePath: `https://github.com/better-stack-ai/better-stack/${string}`; +} + +type RepresentativePluginKey = Extract< + PluginKey, + "blog" | "form-builder" | "open-api" +>; + +const MAINTAINED_FRAMEWORKS = [ + "Next.js 15+ App Router", + "React Router v7", + "TanStack Start", +] as const satisfies readonly MaintainedFramework[]; + +export const PLUGIN_DECISIONS = { + blog: { + topology: "Full-stack", + releaseStatus: "Preview", + supplies: [ + "Post and tag data models with typed CRUD APIs and lifecycle hooks", + "SSR-aware list, draft, editor, tag, and post routes", + "Published-page metadata and sitemap entries", + "Customizable Blog pages, hooks, and editor UI", + ], + adopterSupplies: [ + "A BTST database adapter", + "An image upload implementation when editor uploads are enabled", + "An authorization policy when protected authoring operations are enabled", + "The application shell, public origin, and deployment", + ], + dependencies: ["A BTST database adapter"], + externalServices: [], + supportedFrameworks: MAINTAINED_FRAMEWORKS, + docsPath: "/plugins/blog", + demoPath: "https://www.better-stack.ai/p/blog", + sourcePath: + "https://github.com/better-stack-ai/better-stack/tree/main/packages/stack/src/plugins/blog", + }, + "form-builder": { + topology: "Full-stack", + releaseStatus: "Preview", + supplies: [ + "Drag-and-drop editor with live preview and JSON Schema output", + "Form and submission data models with typed APIs and lifecycle hooks", + "Admin routes for forms, editing, and submission review", + "A FormRenderer component for adopter-owned public routes", + ], + adopterSupplies: [ + "A database adapter with isolated transaction support", + "The public application route that mounts FormRenderer", + "Authorization rules for admin operations when authorization is enabled", + "An explicit public-access or permission policy for form reads and submissions when authorization is enabled", + ], + dependencies: ["A database adapter with isolated transaction support"], + externalServices: [], + supportedFrameworks: MAINTAINED_FRAMEWORKS, + docsPath: "/plugins/form-builder", + sourcePath: + "https://github.com/better-stack-ai/better-stack/tree/main/packages/stack/src/plugins/form-builder", + }, + "open-api": { + topology: "Backend-only", + releaseStatus: "Preview", + supplies: [ + "Deterministic OpenAPI 3.1 generation from registered route metadata and Zod inputs", + "A JSON schema endpoint at the configured API base path plus the fixed /open-api/schema suffix", + "An optional Scalar HTML reference endpoint with theme and CSP nonce options", + "Public or permission-ID access metadata for documented operations", + ], + adopterSupplies: [ + "A registered BTST backend stack whose routes can be inspected", + "A configured API base path; the schema suffix remains /open-api/schema", + "Optional overrides for the API title, version, and reference path; defaults are BTST API, 1.0.0, and /reference", + "A framework-level access policy if the documentation must be private", + ], + dependencies: ["A registered BTST backend stack to inspect"], + externalServices: [ + "The optional Scalar reference loads @scalar/api-reference from jsDelivr", + ], + supportedFrameworks: MAINTAINED_FRAMEWORKS, + docsPath: "/plugins/open-api", + sourcePath: + "https://github.com/better-stack-ai/better-stack/tree/main/packages/stack/src/plugins/open-api", + }, +} as const satisfies Record; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f116f7ef4..6e279d6e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,6 +207,9 @@ importers: docs: dependencies: + '@btst/codegen': + specifier: workspace:* + version: link:../packages/cli '@btst/stack': specifier: workspace:* version: link:../packages/stack From a521368cee130e074dcdaa745e0e235994c876f6 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:56:48 -0400 Subject: [PATCH 2/3] build: publish plugin decision manifest (#262) --- docs/content/docs/plugins/index.mdx | 15 ++++ packages/cli/package.json | 8 +- packages/cli/plugin-decisions.json | 83 +++++++++++++++++++ .../cli/scripts/generate-plugin-decisions.ts | 53 ++++++++++++ .../plugin-decision-manifest.test.ts | 45 ++++++++++ 5 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 packages/cli/plugin-decisions.json create mode 100644 packages/cli/scripts/generate-plugin-decisions.ts create mode 100644 packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts diff --git a/docs/content/docs/plugins/index.mdx b/docs/content/docs/plugins/index.mdx index ead7c91b9..4f08779e8 100644 --- a/docs/content/docs/plugins/index.mdx +++ b/docs/content/docs/plugins/index.mdx @@ -96,3 +96,18 @@ docs, demo, and source identifiers. Audience copy, workflow wording, and calls to action remain presentation-owned. + +Consumers that cannot wait for a package release can read the checked-in +[`plugin-decisions.json`](https://raw.githubusercontent.com/better-stack-ai/better-stack/main/packages/cli/plugin-decisions.json) +during their build or content-validation step. Pin the raw URL to a commit when +reproducibility matters. The same artifact is published as +`@btst/codegen/plugin-decisions.json`; check `schemaVersion` before consuming its +three `plugins` records. Do not fetch it from the browser at runtime. + +After changing `PLUGIN_DECISIONS`, regenerate and verify the artifact from the +repository root: + +```bash +pnpm --filter @btst/codegen plugin-decisions:generate +pnpm --filter @btst/codegen plugin-decisions:check +``` diff --git a/packages/cli/package.json b/packages/cli/package.json index a41817cf6..db706a5c2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -54,12 +54,14 @@ "types": "./dist/meta.d.cts", "default": "./dist/meta.cjs" } - } + }, + "./plugin-decisions.json": "./plugin-decisions.json" }, "files": [ "dist", "src", - "scripts" + "scripts", + "plugin-decisions.json" ], "scripts": { "build": "unbuild --clean", @@ -67,6 +69,8 @@ "dev": "tsx src/index.ts", "legacy-next-hashes:check": "node scripts/generate-legacy-next-render-hashes.mjs --check", "legacy-next-hashes:generate": "node scripts/generate-legacy-next-render-hashes.mjs", + "plugin-decisions:check": "tsx scripts/generate-plugin-decisions.ts --check", + "plugin-decisions:generate": "tsx scripts/generate-plugin-decisions.ts --write", "typecheck": "tsc --project tsconfig.json", "test": "vitest run", "test:init": "bash scripts/test-init.sh", diff --git a/packages/cli/plugin-decisions.json b/packages/cli/plugin-decisions.json new file mode 100644 index 000000000..c6c7c2ac6 --- /dev/null +++ b/packages/cli/plugin-decisions.json @@ -0,0 +1,83 @@ +{ + "schemaVersion": 1, + "plugins": { + "blog": { + "topology": "Full-stack", + "releaseStatus": "Preview", + "supplies": [ + "Post and tag data models with typed CRUD APIs and lifecycle hooks", + "SSR-aware list, draft, editor, tag, and post routes", + "Published-page metadata and sitemap entries", + "Customizable Blog pages, hooks, and editor UI" + ], + "adopterSupplies": [ + "A BTST database adapter", + "An image upload implementation when editor uploads are enabled", + "An authorization policy when protected authoring operations are enabled", + "The application shell, public origin, and deployment" + ], + "dependencies": ["A BTST database adapter"], + "externalServices": [], + "supportedFrameworks": [ + "Next.js 15+ App Router", + "React Router v7", + "TanStack Start" + ], + "docsPath": "/plugins/blog", + "demoPath": "https://www.better-stack.ai/p/blog", + "sourcePath": "https://github.com/better-stack-ai/better-stack/tree/main/packages/stack/src/plugins/blog" + }, + "form-builder": { + "topology": "Full-stack", + "releaseStatus": "Preview", + "supplies": [ + "Drag-and-drop editor with live preview and JSON Schema output", + "Form and submission data models with typed APIs and lifecycle hooks", + "Admin routes for forms, editing, and submission review", + "A FormRenderer component for adopter-owned public routes" + ], + "adopterSupplies": [ + "A database adapter with isolated transaction support", + "The public application route that mounts FormRenderer", + "Authorization rules for admin operations when authorization is enabled", + "An explicit public-access or permission policy for form reads and submissions when authorization is enabled" + ], + "dependencies": ["A database adapter with isolated transaction support"], + "externalServices": [], + "supportedFrameworks": [ + "Next.js 15+ App Router", + "React Router v7", + "TanStack Start" + ], + "docsPath": "/plugins/form-builder", + "sourcePath": "https://github.com/better-stack-ai/better-stack/tree/main/packages/stack/src/plugins/form-builder" + }, + "open-api": { + "topology": "Backend-only", + "releaseStatus": "Preview", + "supplies": [ + "Deterministic OpenAPI 3.1 generation from registered route metadata and Zod inputs", + "A JSON schema endpoint at the configured API base path plus the fixed /open-api/schema suffix", + "An optional Scalar HTML reference endpoint with theme and CSP nonce options", + "Public or permission-ID access metadata for documented operations" + ], + "adopterSupplies": [ + "A registered BTST backend stack whose routes can be inspected", + "A configured API base path; the schema suffix remains /open-api/schema", + "Optional overrides for the API title, version, and reference path; defaults are BTST API, 1.0.0, and /reference", + "A framework-level access policy if the documentation must be private" + ], + "dependencies": ["A registered BTST backend stack to inspect"], + "externalServices": [ + "The optional Scalar reference loads @scalar/api-reference from jsDelivr" + ], + "supportedFrameworks": [ + "Next.js 15+ App Router", + "React Router v7", + "TanStack Start" + ], + "docsPath": "/plugins/open-api", + "sourcePath": "https://github.com/better-stack-ai/better-stack/tree/main/packages/stack/src/plugins/open-api" + } + } +} diff --git a/packages/cli/scripts/generate-plugin-decisions.ts b/packages/cli/scripts/generate-plugin-decisions.ts new file mode 100644 index 000000000..715f1cc0c --- /dev/null +++ b/packages/cli/scripts/generate-plugin-decisions.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import { PLUGIN_DECISIONS } from "../src/utils/plugin-decision"; + +const manifestPath = resolve(import.meta.dirname, "../plugin-decisions.json"); +const biomeCli = createRequire(import.meta.url).resolve( + "@biomejs/biome/bin/biome", +); +const output = execFileSync( + biomeCli, + ["format", "--stdin-file-path", manifestPath], + { + encoding: "utf8", + input: `${JSON.stringify( + { schemaVersion: 1, plugins: PLUGIN_DECISIONS }, + null, + 2, + )}\n`, + }, +); + +const shouldCheck = process.argv.includes("--check"); +const shouldWrite = process.argv.includes("--write"); + +if (shouldCheck === shouldWrite) { + throw new Error("Pass exactly one of --check or --write."); +} + +if (shouldCheck) { + let current: string; + try { + current = await readFile(manifestPath, "utf8"); + } catch { + console.error( + "Plugin decision manifest is missing. Run `pnpm plugin-decisions:generate`.", + ); + process.exitCode = 1; + current = ""; + } + + if (current && current !== output) { + console.error( + "Plugin decision manifest drifted. Run `pnpm plugin-decisions:generate` and review the JSON change.", + ); + process.exitCode = 1; + } +} else { + await writeFile(manifestPath, output, "utf8"); +} diff --git a/packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts b/packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts new file mode 100644 index 000000000..c8106b429 --- /dev/null +++ b/packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts @@ -0,0 +1,45 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; +import { PLUGIN_DECISIONS } from "../plugin-decision"; + +const packageRoot = resolve(import.meta.dirname, "../../.."); +const manifestPath = resolve(packageRoot, "plugin-decisions.json"); +const packageJsonPath = resolve(packageRoot, "package.json"); +const generatorPath = resolve( + packageRoot, + "scripts/generate-plugin-decisions.ts", +); +const tsxCli = createRequire(import.meta.url).resolve("tsx/cli"); +const execFileAsync = promisify(execFile); + +describe("published plugin decision manifest", () => { + it("matches the versioned core decision inventory byte for byte", async () => { + const contents = await readFile(manifestPath, "utf8"); + const manifest = JSON.parse(contents); + + expect(manifest).toEqual({ schemaVersion: 1, plugins: PLUGIN_DECISIONS }); + expect(Object.keys(manifest.plugins)).toEqual([ + "blog", + "form-builder", + "open-api", + ]); + await expect( + execFileAsync(process.execPath, [tsxCli, generatorPath, "--check"]), + ).resolves.toMatchObject({ stderr: "" }); + }); + + it("ships the JSON artifact through the public package contract", async () => { + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); + + expect(packageJson.files).toContain("plugin-decisions.json"); + expect(packageJson.exports["./plugin-decisions.json"]).toBe( + "./plugin-decisions.json", + ); + expect(packageJson.scripts["plugin-decisions:generate"]).toBeDefined(); + expect(packageJson.scripts["plugin-decisions:check"]).toBeDefined(); + }); +}); From 7fa4b68afc15d633f1553c96585e36e070c1f7b6 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:09:18 -0400 Subject: [PATCH 3/3] test: reject empty plugin decision manifests --- .../cli/scripts/generate-plugin-decisions.ts | 9 ++-- .../plugin-decision-manifest.test.ts | 46 ++++++++++++++++++- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/cli/scripts/generate-plugin-decisions.ts b/packages/cli/scripts/generate-plugin-decisions.ts index 715f1cc0c..64c31db6b 100644 --- a/packages/cli/scripts/generate-plugin-decisions.ts +++ b/packages/cli/scripts/generate-plugin-decisions.ts @@ -6,7 +6,9 @@ import { createRequire } from "node:module"; import { resolve } from "node:path"; import { PLUGIN_DECISIONS } from "../src/utils/plugin-decision"; -const manifestPath = resolve(import.meta.dirname, "../plugin-decisions.json"); +const manifestPath = + process.env.BTST_PLUGIN_DECISIONS_MANIFEST_PATH ?? + resolve(import.meta.dirname, "../plugin-decisions.json"); const biomeCli = createRequire(import.meta.url).resolve( "@biomejs/biome/bin/biome", ); @@ -31,7 +33,7 @@ if (shouldCheck === shouldWrite) { } if (shouldCheck) { - let current: string; + let current: string | undefined; try { current = await readFile(manifestPath, "utf8"); } catch { @@ -39,10 +41,9 @@ if (shouldCheck) { "Plugin decision manifest is missing. Run `pnpm plugin-decisions:generate`.", ); process.exitCode = 1; - current = ""; } - if (current && current !== output) { + if (current !== undefined && current !== output) { console.error( "Plugin decision manifest drifted. Run `pnpm plugin-decisions:generate` and review the JSON change.", ); diff --git a/packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts b/packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts index c8106b429..b417dfeaa 100644 --- a/packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts +++ b/packages/cli/src/utils/__tests__/plugin-decision-manifest.test.ts @@ -1,7 +1,8 @@ import { execFile } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; import { PLUGIN_DECISIONS } from "../plugin-decision"; @@ -42,4 +43,45 @@ describe("published plugin decision manifest", () => { expect(packageJson.scripts["plugin-decisions:generate"]).toBeDefined(); expect(packageJson.scripts["plugin-decisions:check"]).toBeDefined(); }); + + it("rejects an existing empty manifest instead of treating it as missing", async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), "btst-decisions-")); + const emptyManifestPath = join(temporaryRoot, "plugin-decisions.json"); + await writeFile(emptyManifestPath, "", "utf8"); + + try { + await expect( + execFileAsync(process.execPath, [tsxCli, generatorPath, "--check"], { + env: { + ...process.env, + BTST_PLUGIN_DECISIONS_MANIFEST_PATH: emptyManifestPath, + }, + }), + ).rejects.toMatchObject({ + stderr: expect.stringContaining("Plugin decision manifest drifted"), + }); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } + }); + + it("reports a missing manifest distinctly from an empty one", async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), "btst-decisions-")); + const missingManifestPath = join(temporaryRoot, "missing.json"); + + try { + await expect( + execFileAsync(process.execPath, [tsxCli, generatorPath, "--check"], { + env: { + ...process.env, + BTST_PLUGIN_DECISIONS_MANIFEST_PATH: missingManifestPath, + }, + }), + ).rejects.toMatchObject({ + stderr: expect.stringContaining("Plugin decision manifest is missing"), + }); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } + }); });