every plugin route renders a workspace, and it is not a rule you can forget
an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.
web/panels.ts appRegistryMetas — at least one panel
web/layout.ts defaultLayout — how they are arranged
both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:
probeplug: has a web/ directory but is missing web/layout.ts.
Every plugin route renders a Workspace: contribute panels and a layout,
not a screen.
there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.
the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.
the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,8 +19,8 @@ import { manifestProblems, type DiscoveredPlugin, type PluginManifest } from './
|
||||
// api/router.ts a backend router
|
||||
// db/schema.ts tables
|
||||
// sidecar/index.ts a process (`.mjs` instead means node — see below)
|
||||
// web/Router.tsx a frontend
|
||||
// web/panels.ts panel apps
|
||||
// web/panels.ts panel apps — REQUIRED with web/
|
||||
// web/layout.ts how they are arranged — REQUIRED with web/
|
||||
//
|
||||
// Nothing here reads the database. This answers "what is on disk", which is a different question from
|
||||
// "what is installed" — the install table answers that, and the two disagreeing is a state the app store
|
||||
@@ -45,11 +45,29 @@ function findSidecar(dir: string): DiscoveredPlugin['sidecar'] {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findWeb(dir: string): DiscoveredPlugin['web'] {
|
||||
const router = join(dir, 'web', 'Router.tsx');
|
||||
if (!existsSync(router)) return null;
|
||||
const panels = join(dir, 'web', 'panels.ts');
|
||||
return { router, panels: existsSync(panels) ? panels : null };
|
||||
/**
|
||||
* A plugin's frontend, or a reason it is refused.
|
||||
*
|
||||
* `web/` present means BOTH `panels.ts` and `layout.ts` must be. That is how "every route renders a
|
||||
* workspace with at least one panel" is enforced: there is no way to ship a frontend that is not a
|
||||
* workspace, because the plugin never renders the screen — it hands over panels and an arrangement.
|
||||
*/
|
||||
function findWeb(dir: string, appName: string): { web: DiscoveredPlugin['web']; error?: string } {
|
||||
const webDir = join(dir, 'web');
|
||||
if (!existsSync(webDir)) return { web: null };
|
||||
|
||||
const panels = join(webDir, 'panels.ts');
|
||||
const layout = join(webDir, 'layout.ts');
|
||||
const missing = [!existsSync(panels) && 'web/panels.ts', !existsSync(layout) && 'web/layout.ts'].filter(Boolean);
|
||||
if (missing.length) {
|
||||
return {
|
||||
web: null,
|
||||
error:
|
||||
`${appName}: has a web/ directory but is missing ${missing.join(' and ')}. ` +
|
||||
`Every plugin route renders a Workspace: contribute panels and a layout, not a screen.`,
|
||||
};
|
||||
}
|
||||
return { web: { panels, layout } };
|
||||
}
|
||||
|
||||
const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
|
||||
@@ -75,6 +93,9 @@ export async function loadPlugin(dir: string, appName: string): Promise<Discover
|
||||
const problems = manifestProblems(appName, manifest);
|
||||
if (problems.length) throw new Error(`${appName}: ${problems.join('; ')}`);
|
||||
|
||||
const web = findWeb(dir, appName);
|
||||
if (web.error) throw new Error(web.error);
|
||||
|
||||
return {
|
||||
appName,
|
||||
dir,
|
||||
@@ -82,7 +103,7 @@ export async function loadPlugin(dir: string, appName: string): Promise<Discover
|
||||
api: fileOrNull(join(dir, 'api', 'router.ts')),
|
||||
schema: fileOrNull(join(dir, 'db', 'schema.ts')),
|
||||
sidecar: findSidecar(dir),
|
||||
web: findWeb(dir),
|
||||
web: web.web,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user