Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .agents/skills/btst-client-plugin-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,30 @@ description: Patterns for writing BTST client plugins inside the monorepo, inclu
src/plugins/{name}/
client/
plugin.tsx ← defineClientPlugin entry
hooks.ts ← "use client" React hooks only
components/
pages/
my-page.tsx ← wrapper: ComposedRoute + lazy import
my-page.internal.tsx ← actual UI: useSuspenseQuery
query-keys.ts ← React Query key factory
```

## Server/client module boundary

`client/plugin.tsx` must stay import-safe on the server. Next.js (including SSG build)
can execute `createStackClient()` on the server, which calls each `*ClientPlugin()`
factory. If that module is marked `"use client"` or imports a client-only module, build
can fail with "Attempted to call ... from the server".

Rules:

- Do **not** add `"use client"` to `client/plugin.tsx`.
- Keep `client/plugin.tsx` free of React hooks (`useState`, `useEffect`, etc.).
- Put hook utilities in a separate client-only module (`client/hooks.ts`) with
`"use client"`, and re-export them from `client/index.ts`.
- UI components can remain client components as needed; only the plugin factory entry
must stay server-import-safe.

## Route anatomy

Each route returns exactly three things:
Expand Down Expand Up @@ -110,6 +127,7 @@ type PluginOverrides = {
- **Next.js Link href undefined** — use `href={href || "#"}` pattern.
- **Suspense errors not caught** — add `if (error && !isFetching) throw error` in every suspense hook.
- **Missing ComposedRoute wrapper** — without it, errors crash the entire app instead of hitting ErrorBoundary.
- **Client directive on `client/plugin.tsx`** — can break SSG/SSR when plugin factories are invoked server-side.

## Full code patterns

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export default defineBuildConfig({
declaration: true,
clean: true,
outDir: "dist",
entries: ["./src/index.ts"],
entries: ["./src/index.ts", "./src/lib.ts"],
rollup: {
emitCJS: true,
esbuild: {
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./lib": {
"import": "./dist/lib.mjs",
"require": "./dist/lib.cjs"
}
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing types condition in package exports map

Medium Severity

The newly added exports map lacks types conditions for both "." and "./lib" entries. Before this PR, TypeScript resolved types via the top-level "types" field. Now that exports is present, TypeScript with moduleResolution: "bundler" or "node16" uses exports instead and ignores the top-level "types" fallback. The "./lib" export is especially at risk since it has no types fallback at all — TypeScript must infer the declaration file from ./dist/lib.mjs, which only works if unbuild produces a .d.ts (not .d.mts) that TypeScript's heuristic can discover.

Fix in Cursor Fix in Web

"files": [
"dist",
"src",
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ADAPTERS,
DEFAULT_PLUGIN_SELECTION,
PLUGINS,
PLUGIN_ROUTES,
} from "../utils/constants";
import { detectAlias } from "../utils/detect-alias";
import { detectCssFile } from "../utils/detect-css-file";
Expand Down Expand Up @@ -346,12 +347,20 @@ export function createInitCommand() {
? "yes"
: "manual action may be needed";

const scaffoldedRoutes = selectedPlugins.flatMap(
(p) => PLUGIN_ROUTES[p] ?? [],
);
const routesList =
scaffoldedRoutes.length > 0
? `\nAvailable routes:\n${scaffoldedRoutes.map((r) => ` ${r}`).join("\n")}\n`
: "";

outro(`BTST init complete.
Files written: ${writeResult.written.length}
Files skipped: ${writeResult.skipped.length}
CSS updated: ${cssPatch.updated ? "yes" : "no"}
Layout patched: ${layoutStatus}

${routesList}
Next steps:
- Verify routes under /pages/*
- Run your build
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Programmatic API for @btst/codegen scaffold utilities.
* Use this when consuming the CLI as a library (e.g. in the playground).
*/
export { buildScaffoldPlan } from "./utils/scaffold-plan";
export { PLUGINS, ADAPTERS } from "./utils/constants";
export { PLUGIN_ROUTES } from "./utils/plugin-routes";
export type {
PluginKey,
Adapter,
Framework,
FileWritePlanItem,
ScaffoldPlan,
} from "./types";
export type { PluginMeta, AdapterMeta } from "./utils/constants";
42 changes: 42 additions & 0 deletions packages/cli/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,45 @@ export const PLUGINS: readonly PluginMeta[] = [
];

export const DEFAULT_PLUGIN_SELECTION: PluginKey[] = [];

/**
* Maps each plugin key to the list of /pages/* route paths it registers.
* Paths are verified against each plugin's client/plugin.tsx createRoute() calls.
* All page routes are prefixed with /pages (matching siteBasePath="/pages").
* Non-page routes (API-only plugins) are listed separately.
*/
export const PLUGIN_ROUTES: Record<PluginKey, string[]> = {
blog: [
"/pages/blog",
"/pages/blog/drafts",
"/pages/blog/new",
"/pages/blog/:slug/edit",
"/pages/blog/tag/:tagSlug",
"/pages/blog/:slug",
],
"ai-chat": ["/pages/chat", "/pages/chat/:id"],
cms: [
"/pages/cms",
"/pages/cms/:typeSlug",
"/pages/cms/:typeSlug/new",
"/pages/cms/:typeSlug/:id",
],
"form-builder": [
"/pages/forms",
"/pages/forms/new",
"/pages/forms/:id/edit",
"/pages/forms/:id/submissions",
],
"ui-builder": [
"/pages/ui-builder",
"/pages/ui-builder/new",
"/pages/ui-builder/:id/edit",
],
kanban: ["/pages/kanban", "/pages/kanban/new", "/pages/kanban/:boardId"],
comments: ["/pages/comments/moderation", "/pages/comments"],
media: ["/pages/media"],
"route-docs": ["/pages/route-docs"],
/** open-api registers an API route, not a page route */
"open-api": ["/api/data/reference"],
"better-auth-ui": ["/pages/auth", "/pages/account/settings", "/pages/org"],
};
5 changes: 5 additions & 0 deletions packages/cli/src/utils/plugin-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Re-export PLUGIN_ROUTES from constants so it can be imported
* from either location without bundling issues.
*/
export { PLUGIN_ROUTES } from "./constants";
3 changes: 3 additions & 0 deletions packages/cli/src/utils/render-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export async function renderTemplate(
join(__dirname, "..", "templates"),
join(__dirname, "..", "src", "templates"),
join(__dirname, "src", "templates"),
// When bundled into dist/shared/, go up two levels to reach package root src/templates
join(__dirname, "..", "..", "src", "templates"),
join(__dirname, "..", "..", "templates"),
];
let source: string | null = null;

Expand Down
20 changes: 20 additions & 0 deletions packages/stack/src/plugins/route-docs/client/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"use client";

import { useEffect, useState } from "react";
import { getRegisteredRoutes, type RegisteredRoute } from "./plugin";

/**
* React hook that returns all registered client route paths.
* Updates whenever the component mounts (after client hydration).
*/
export function useRegisteredRoutes(): RegisteredRoute[] {
const [routes, setRoutes] = useState<RegisteredRoute[]>(() =>
getRegisteredRoutes(),
);

useEffect(() => {
setRoutes(getRegisteredRoutes());
}, []);

return routes;
}
3 changes: 3 additions & 0 deletions packages/stack/src/plugins/route-docs/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ export {
ROUTE_DOCS_QUERY_KEY,
generateSchema,
getStoredContext,
getRegisteredRoutes,
type RegisteredRoute,
} from "./plugin";
export { useRegisteredRoutes } from "./hooks";
43 changes: 43 additions & 0 deletions packages/stack/src/plugins/route-docs/client/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,49 @@ export function getStoredContext(): ClientStackContext | null {
return moduleStoredContext;
}

/**
* A registered route entry
*/
export interface RegisteredRoute {
/** The route path pattern (e.g., "/blog/:slug") */
path: string;
/** The plugin this route belongs to (e.g., "blog") */
plugin: string;
/** The route key within the plugin (e.g., "detail") */
key: string;
}

/**
* Returns all registered client route paths from the stored ClientStackContext.
* The context is populated when `createStackClient` is called (i.e. on first render).
* Returns an empty array if called before the stack client has been initialised.
*/
export function getRegisteredRoutes(): RegisteredRoute[] {
if (!moduleStoredContext) return [];
const result: RegisteredRoute[] = [];
for (const [pluginKey, plugin] of Object.entries(
moduleStoredContext.plugins,
)) {
if (pluginKey === "routeDocs" || plugin.name === "route-docs") continue;
try {
const routes = plugin.routes(moduleStoredContext);
for (const [routeKey, route] of Object.entries(routes)) {
const path = (route as any)?.path;
if (path) {
result.push({
path,
plugin: plugin.name || pluginKey,
key: routeKey,
});
}
}
} catch {
// silently skip plugins whose routes() throws during introspection
}
}
return result;
}

/**
* Generate the route docs schema from the stored context
* This can be called from both server and client
Expand Down
6 changes: 6 additions & 0 deletions playground/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
23 changes: 23 additions & 0 deletions playground/next.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
basePath: "/playground",
assetPrefix: "/playground",
serverExternalPackages: ["handlebars"],
async headers() {
return [
{
// COOP: same-origin + COEP: credentialless = cross-origin isolation.
// Required for SharedArrayBuffer, which WebContainers (template: "node")
// needs to boot. same-origin-allow-popups does NOT provide isolation.
source: "/(.*)",
headers: [
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Embedder-Policy", value: "credentialless" },
],
},
];
},
};

export default config;
27 changes: 27 additions & 0 deletions playground/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "btst-playground",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev --port 3002",
"start": "next start --port 3002",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@btst/codegen": "workspace:*",
"@stackblitz/sdk": "^1.9.0",
"next": "16.0.10",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.10",
"@types/node": "^24.0.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.10",
"typescript": "^5.8.3"
}
}
5 changes: 5 additions & 0 deletions playground/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
56 changes: 56 additions & 0 deletions playground/src/app/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use server";

import { buildScaffoldPlan, PLUGINS, PLUGIN_ROUTES } from "@btst/codegen/lib";
import type { PluginKey, FileWritePlanItem } from "@btst/codegen/lib";

export interface GenerateResult {
files: FileWritePlanItem[];
routes: string[];
cssImports: string[];
extraPackages: string[];
}

export async function generateProject(
plugins: PluginKey[],
): Promise<GenerateResult> {
// ui-builder requires cms
const selectedPlugins: PluginKey[] =
plugins.includes("ui-builder") && !plugins.includes("cms")
? ["cms", ...plugins]
: plugins;

// Always include route-docs so users can see all available routes
const withRouteDocs: PluginKey[] = selectedPlugins.includes("route-docs")
? selectedPlugins
: [...selectedPlugins, "route-docs"];

const plan = await buildScaffoldPlan({
framework: "nextjs",
adapter: "memory",
plugins: withRouteDocs,
alias: "@/",
cssFile: "app/globals.css",
});

const cssImports = PLUGINS.filter((p) =>
withRouteDocs.includes(p.key as PluginKey),
)
.map((p) => p.cssImport)
.filter((c): c is string => Boolean(c));

const routes = withRouteDocs.flatMap((p) => PLUGIN_ROUTES[p] ?? []);
const extraPackages = Array.from(
new Set(
PLUGINS.filter((p) => withRouteDocs.includes(p.key as PluginKey)).flatMap(
(p) => p.extraPackages ?? [],
),
),
);

return {
files: plan.files,
routes,
cssImports,
extraPackages,
};
}
1 change: 1 addition & 0 deletions playground/src/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import "tailwindcss";
22 changes: 22 additions & 0 deletions playground/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";

export const metadata: Metadata = {
title: {
default: "BTST Playground",
template: "%s | BTST Playground",
},
description:
"Build a BTST project in your browser — select plugins and see them live in a StackBlitz WebContainer.",
};

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className="min-h-screen bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 antialiased">
{children}
</body>
</html>
);
}
Loading
Loading