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:
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user