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:
2026-08-14 23:47:48 +00:00
co-authored by Claude Opus 5
parent 2e3c935da6
commit 543e88a9a6
38 changed files with 1026 additions and 440 deletions
+15 -2
View File
@@ -31,12 +31,15 @@ plant('full', {
'api/router.ts': 'export const router = {};',
'db/schema.ts': 'export const t = {};',
'sidecar/index.ts': 'export {};',
'web/Router.tsx': 'export default () => null;',
'web/panels.ts': 'export const appRegistryMetas = [];',
'web/layout.ts': 'export const defaultLayout = {};',
});
plant('bare', { 'manifest.ts': MANIFEST() });
plant('nodeish', { 'manifest.ts': MANIFEST(), 'sidecar/index.mjs': 'export {};' });
plant('broken', { 'manifest.ts': 'export const manifest = { publisher: 1 };' });
// A frontend that is not a workspace. The rule: every plugin route renders a Workspace with at least one
// panel, enforced here rather than by review — a `web/` directory without both files is refused.
plant('halfweb', { 'manifest.ts': MANIFEST(), 'web/panels.ts': 'export const appRegistryMetas = [];' });
plant('nomanifest', { 'api/router.ts': 'export const router = {};' });
plant('_scratch', { 'manifest.ts': MANIFEST() });
@@ -48,6 +51,7 @@ describe('discoverPlugins', () => {
expect(full.schema).toContain('db/schema.ts');
expect(full.sidecar).toEqual({ script: join(root, 'full/sidecar/index.ts'), runtime: 'bun' });
expect(full.web?.panels).toContain('web/panels.ts');
expect(full.web?.layout).toContain('web/layout.ts');
});
it('a manifest alone is a valid plugin — every other part is optional', async () => {
@@ -71,11 +75,20 @@ describe('discoverPlugins', () => {
// ones beside it. "Broken, and here is why" is renderable; a failed boot is only greppable.
it('collects broken plugins instead of throwing', async () => {
const { plugins, broken } = await discoverPlugins(root);
expect(broken.map((b) => b.appName).sort()).toEqual(['broken', 'nomanifest']);
expect(broken.map((b) => b.appName).sort()).toEqual(['broken', 'halfweb', 'nomanifest']);
expect(broken.find((b) => b.appName === 'nomanifest')!.error).toContain('no manifest.ts');
expect(plugins.length).toBeGreaterThan(0);
});
// The exclusionary rule, and the reason it is a shape rather than a policy: a plugin cannot ship a
// frontend that is not a workspace, because it never renders the screen.
it('refuses a web/ directory that is not panels AND a layout', async () => {
const { broken } = await discoverPlugins(root);
const halfweb = broken.find((b) => b.appName === 'halfweb')!;
expect(halfweb.error).toContain('web/layout.ts');
expect(halfweb.error).toContain('contribute panels and a layout, not a screen');
});
it('skips underscore and dot directories, which are scratch space', async () => {
const { plugins, broken } = await discoverPlugins(root);
expect([...plugins, ...broken].map((p) => p.appName)).not.toContain('_scratch');
+29 -8
View File
@@ -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,
};
}
+18 -14
View File
@@ -65,32 +65,25 @@ export function generatePluginsModule(states: PluginState[]): string {
withWeb.forEach((state, i) => {
const { plugin } = state;
const alias = `Plugin${i}`;
imports.push(`import ${alias}Router from '${specifier(plugin.web!.router)}';`);
let panels = '[]';
if (plugin.web!.panels) {
imports.push(`import { appRegistryMetas as ${alias}Panels } from '${specifier(plugin.web!.panels)}';`);
panels = `${alias}Panels`;
}
imports.push(`import { appRegistryMetas as ${alias}Panels } from '${specifier(plugin.web!.panels)}';`);
imports.push(`import { defaultLayout as ${alias}Layout } from '${specifier(plugin.web!.layout)}';`);
entries.push(
` { appName: '${plugin.appName}', route: '${mountPrefix(plugin)}', Router: ${alias}Router, panels: ${panels} },`,
` { appName: '${plugin.appName}', route: '${mountPrefix(plugin)}', panels: ${alias}Panels, layout: ${alias}Layout },`,
);
});
const body = `${HEADER}
import type { ComponentType } from 'react';
import type { AppRegistryMeta } from 'officerdev';
import type { AppRegistryMeta, LayoutNode } from 'officerdev';
${imports.join('\n')}
export type GeneratedPlugin = {
appName: string;
/** Where it mounts, from \`mountPrefix\` — \`/offscale\` for ours, \`/p/<publisher>/<name>\` for others. */
route: string;
/** Mounted at \`\${route}/*\`, so the plugin's own router owns everything beneath it. */
Router: ComponentType;
/** Panel apps it contributes to the registry. */
/** The panels it contributes. At least one, or discovery refuses the plugin. */
panels: AppRegistryMeta[];
/** How they are arranged. The shell renders \`WorkspaceView\` around them. */
layout: LayoutNode;
};
export const plugins: GeneratedPlugin[] = [
@@ -160,6 +153,17 @@ export async function rebuildFrontend(): Promise<BuildResult> {
return { ok: false, ms: Date.now() - started, outputs: 0, error: `build produced no ${SHELL_FILE}` };
}
// Make the shell's asset URLs ABSOLUTE.
//
// Bun emits `./chunk-….js` for an HTML entrypoint and `publicPath` does not change it. A relative URL
// resolves against the CURRENT path, so it is correct at `/` and wrong at `/example/deeper`, where the
// browser asks for `/example/chunk-….js`. Every deep link more than one segment deep would fail to
// boot. Rewritten here rather than worked around in the server, because the wrong URL is in the file.
const shellPath = join(STAGING_DIR, SHELL_FILE);
const shell = readFileSync(shellPath, 'utf-8');
const absolute = shell.replace(/(src|href)="\.\//g, '$1="/');
if (absolute !== shell) writeFileSync(shellPath, absolute);
if (existsSync(BUILD_DIR)) rmSync(BUILD_DIR, { recursive: true, force: true });
renameSync(STAGING_DIR, BUILD_DIR);
+12 -2
View File
@@ -81,8 +81,18 @@ export type DiscoveredPlugin = {
schema: string | null;
/** `sidecar/index.{ts,mjs}` — a process for PM2. */
sidecar: { script: string; runtime: 'bun' | 'node' } | null;
/** `web/Router.tsx` — a frontend, mounted at `<mountPrefix>/*` by the generated Plugins.tsx. */
web: { router: string; panels: string | null } | null;
/**
* A frontend, as PANELS AND A LAYOUT — never a free-form component.
*
* The rule: every plugin route renders a Workspace with at least one panel. It is enforced by shape
* rather than by review — the plugin does not get to render the screen, it contributes panels and says
* how they are arranged, and the shell renders `WorkspaceView` around them. A plugin that wanted to draw
* something else has nowhere to put it.
*
* Both files are required when `web/` exists at all. Missing either is a broken plugin, not a plugin
* with a partial frontend.
*/
web: { panels: string; layout: string } | null;
};
/**