a plugin screen can declare its routes
Every screen got `/<route>` and `/<route>/:section` and nothing deeper, so a section had to live in the query string. A screen may now name its routes in the manifest, and give one its own arrangement of the screen's panels. Absent, the old pair is unchanged — which is every plugin but fofos-admin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -101,17 +101,52 @@ export function App() {
|
|||||||
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
|
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
|
||||||
{/* One pair per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
|
{/* One pair per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
|
||||||
screens (caldav: Calendar and Contacts) contributes two entries sharing one `appName`. */}
|
screens (caldav: Calendar and Contacts) contributes two entries sharing one `appName`. */}
|
||||||
{installedPlugins.flatMap((plugin) => [
|
{installedPlugins.flatMap((plugin) =>
|
||||||
<Route key={plugin.screenId} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
|
/*
|
||||||
// The section pair, exactly as the core screens do it (`/soulseek/:section`): the plugin's
|
A plugin that declared a route table gets exactly those routes.
|
||||||
// panels read `useParams` themselves, so which section is open is the URL rather than state
|
|
||||||
// passed between them.
|
It REPLACES the pair below rather than adding to it: the plugin has said what its URLs
|
||||||
|
are, so a guessed `/:section` alongside them would be a route nothing links to, matching
|
||||||
|
ahead of a declared one and rendering the wrong layout.
|
||||||
|
|
||||||
|
The layout key carries the path pattern, so two routes arranged differently remember
|
||||||
|
their own dividers. A route on the screen's own layout keeps the screen's own key — its
|
||||||
|
sizes are the ones the user already set.
|
||||||
|
*/
|
||||||
|
plugin.routes.length
|
||||||
|
? plugin.routes.map((route) => (
|
||||||
|
<Route
|
||||||
|
key={`${plugin.screenId}:${route.path}`}
|
||||||
|
path={route.path ? `${plugin.route}/${route.path}` : plugin.route}
|
||||||
|
element={
|
||||||
|
<Dashboard.PluginScreen
|
||||||
|
{...plugin}
|
||||||
|
layout={route.layout}
|
||||||
|
layoutKey={
|
||||||
|
route.layout === plugin.layout || !route.path
|
||||||
|
? plugin.screenId
|
||||||
|
: `${plugin.screenId}/${route.path}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: [
|
||||||
|
<Route
|
||||||
|
key={plugin.screenId}
|
||||||
|
path={plugin.route}
|
||||||
|
element={<Dashboard.PluginScreen {...plugin} />}
|
||||||
|
/>,
|
||||||
|
// The section pair, exactly as the core screens do it (`/soulseek/:section`): the
|
||||||
|
// plugin's panels read `useParams` themselves, so which section is open is the URL
|
||||||
|
// rather than state passed between them.
|
||||||
<Route
|
<Route
|
||||||
key={`${plugin.screenId}-section`}
|
key={`${plugin.screenId}-section`}
|
||||||
path={`${plugin.route}/:section`}
|
path={`${plugin.route}/:section`}
|
||||||
element={<Dashboard.PluginScreen {...plugin} />}
|
element={<Dashboard.PluginScreen {...plugin} />}
|
||||||
/>,
|
/>,
|
||||||
])}
|
],
|
||||||
|
)}
|
||||||
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
|
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
|
||||||
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
|
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
|
||||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function PluginScreen({
|
|||||||
screenId,
|
screenId,
|
||||||
panels,
|
panels,
|
||||||
layout,
|
layout,
|
||||||
|
layoutKey,
|
||||||
}: {
|
}: {
|
||||||
/**
|
/**
|
||||||
* This SCREEN's id, not the plugin's — `caldav/calendar`, not `caldav`.
|
* This SCREEN's id, not the plugin's — `caldav/calendar`, not `caldav`.
|
||||||
@@ -32,6 +33,14 @@ export function PluginScreen({
|
|||||||
screenId: string;
|
screenId: string;
|
||||||
panels: AppRegistryMeta[];
|
panels: AppRegistryMeta[];
|
||||||
layout: LayoutNode;
|
layout: LayoutNode;
|
||||||
|
/**
|
||||||
|
* What to persist sizes under, when this route's arrangement is not the screen's default.
|
||||||
|
*
|
||||||
|
* A plugin with a route table can give one route a different layout, and two arrangements sharing one
|
||||||
|
* key would each remember the other's dividers — which reads as a layout that will not stay put. The
|
||||||
|
* route's own path pattern is the suffix, so it is stable across every URL that matches it.
|
||||||
|
*/
|
||||||
|
layoutKey?: string;
|
||||||
}) {
|
}) {
|
||||||
// Per-user and per-plugin, so two plugins never share a layout and a user's arrangement is their own.
|
// Per-user and per-plugin, so two plugins never share a layout and a user's arrangement is their own.
|
||||||
//
|
//
|
||||||
@@ -39,7 +48,7 @@ export function PluginScreen({
|
|||||||
// `layout` on every render, while only the sizes are the user's and persisted. That is what lets a plugin
|
// `layout` on every render, while only the sizes are the user's and persisted. That is what lets a plugin
|
||||||
// ship a new layout and have it reach people who have already opened the screen — and it is what stops
|
// ship a new layout and have it reach people who have already opened the screen — and it is what stops
|
||||||
// the `appTypes` repair below from being written back as though the user had chosen it. See the hook.
|
// the `appTypes` repair below from being written back as though the user had chosen it. See the hook.
|
||||||
const workspace = useAuthoredLayout(`screens/plugin/${screenId}`, layout);
|
const workspace = useAuthoredLayout(`screens/plugin/${layoutKey ?? screenId}`, layout);
|
||||||
const allowed = panels.map((panel) => panel.key);
|
const allowed = panels.map((panel) => panel.key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { existsSync, readdirSync, statSync } from 'node:fs';
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join, sep } from 'node:path';
|
||||||
import { PLATFORM_DIR } from '../data-path';
|
import { PLATFORM_DIR } from '../data-path';
|
||||||
import {
|
import {
|
||||||
manifestProblems,
|
manifestProblems,
|
||||||
@@ -93,10 +93,14 @@ function findWeb(
|
|||||||
`Every plugin route renders a Workspace: contribute panels and a layout, not a screen.`,
|
`Every plugin route renders a Workspace: contribute panels and a layout, not a screen.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const problems: string[] = [];
|
||||||
|
const routes = routesOf(manifest?.routes, webDir, layout, problems);
|
||||||
|
if (problems.length) return { web: null, error: `${appName}: ${problems.join('; ')}.` };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
web: {
|
web: {
|
||||||
screens: [
|
screens: [
|
||||||
{ segment: null, label: manifest?.label ?? appName, icon: manifest?.icon, art: null, panels, layout },
|
{ segment: null, label: manifest?.label ?? appName, icon: manifest?.icon, art: null, panels, layout, routes },
|
||||||
],
|
],
|
||||||
// Optional and unannounced — presence is the declaration, as it is for panels and layout.
|
// Optional and unannounced — presence is the declaration, as it is for panels and layout.
|
||||||
fileActions: fileOrNull(join(webDir, 'file-actions.ts')),
|
fileActions: fileOrNull(join(webDir, 'file-actions.ts')),
|
||||||
@@ -145,6 +149,8 @@ function findWeb(
|
|||||||
art: fileOrNull(join(dir, 'assets', `${screen.segment}.png`)),
|
art: fileOrNull(join(dir, 'assets', `${screen.segment}.png`)),
|
||||||
panels,
|
panels,
|
||||||
layout,
|
layout,
|
||||||
|
// On the screen entry, not the manifest root: a route belongs to the screen it renders inside.
|
||||||
|
routes: routesOf(screen.routes, join(webDir, screen.segment), layout, problems),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +168,52 @@ function findWeb(
|
|||||||
return { web: { screens, fileActions: fileOrNull(join(webDir, 'file-actions.ts')) } };
|
return { web: { screens, fileActions: fileOrNull(join(webDir, 'file-actions.ts')) } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A screen's route table, resolved against the plugin directory.
|
||||||
|
*
|
||||||
|
* ── Every layout is checked HERE ──
|
||||||
|
*
|
||||||
|
* A route naming a file that is not there would be a build error in generated code — a stack trace
|
||||||
|
* pointing at `Plugins.gen.tsx`, which nobody wrote. Refused at discovery it is a sentence naming the
|
||||||
|
* plugin and the missing file, which is the same trade every other check in this file makes.
|
||||||
|
*
|
||||||
|
* A path is not validated beyond being a string: it is react-router's syntax, not ours, and a
|
||||||
|
* half-understood copy of their grammar would refuse patterns that work.
|
||||||
|
*/
|
||||||
|
function routesOf(
|
||||||
|
declared: { path: string; layout?: string }[] | undefined,
|
||||||
|
webDir: string,
|
||||||
|
fallbackLayout: string,
|
||||||
|
problems: string[],
|
||||||
|
): { path: string; layout: string }[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const routes: { path: string; layout: string }[] = [];
|
||||||
|
|
||||||
|
for (const route of declared ?? []) {
|
||||||
|
// Two entries on one path is one of them never matching, silently — react-router takes the first.
|
||||||
|
if (seen.has(route.path)) {
|
||||||
|
problems.push(`route ${JSON.stringify(route.path)} is declared twice`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(route.path);
|
||||||
|
|
||||||
|
if (!route.layout) {
|
||||||
|
routes.push({ path: route.path, layout: fallbackLayout });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Under web/, like everything else a screen declares. `join` on a traversal would leave the plugin
|
||||||
|
// directory, so the resolved path is checked to still be inside it.
|
||||||
|
const resolved = join(webDir, route.layout.replace(/^web\//, ''));
|
||||||
|
if (!resolved.startsWith(webDir + sep) || !existsSync(resolved)) {
|
||||||
|
problems.push(`route ${JSON.stringify(route.path)} names layout ${JSON.stringify(route.layout)}, which is not in web/`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
routes.push({ path: route.path, layout: resolved });
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
|
const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -96,8 +96,25 @@ export function generatePluginsModule(states: PluginState[]): string {
|
|||||||
// would accept the other's panels.
|
// would accept the other's panels.
|
||||||
const screenId = screen.segment ? `${plugin.appName}/${screen.segment}` : plugin.appName;
|
const screenId = screen.segment ? `${plugin.appName}/${screen.segment}` : plugin.appName;
|
||||||
const route = screen.segment ? `/${screen.segment}` : mountPrefix(plugin);
|
const route = screen.segment ? `/${screen.segment}` : mountPrefix(plugin);
|
||||||
|
|
||||||
|
/*
|
||||||
|
The screen's route table, each entry carrying the layout it renders.
|
||||||
|
|
||||||
|
A route whose layout is the screen's own reuses `${alias}Layout` rather than importing the same
|
||||||
|
module twice — the specifier would be identical, and two imports of one file under two names is
|
||||||
|
two objects where the layout key assumes one.
|
||||||
|
*/
|
||||||
|
const routeEntries = screen.routes.map((route_, index) => {
|
||||||
|
let layoutRef = `${alias}Layout`;
|
||||||
|
if (route_.layout !== screen.layout) {
|
||||||
|
layoutRef = `${alias}Route${index}Layout`;
|
||||||
|
imports.push(`import { defaultLayout as ${layoutRef} } from '${specifier(route_.layout)}';`);
|
||||||
|
}
|
||||||
|
return `{ path: ${JSON.stringify(route_.path)}, layout: ${layoutRef} }`;
|
||||||
|
});
|
||||||
|
|
||||||
entries.push(
|
entries.push(
|
||||||
` { appName: '${plugin.appName}', screenId: '${screenId}', route: '${route}', panels: ${alias}Panels, layout: ${alias}Layout },`,
|
` { appName: '${plugin.appName}', screenId: '${screenId}', route: '${route}', panels: ${alias}Panels, layout: ${alias}Layout, routes: [${routeEntries.join(', ')}] },`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +150,17 @@ export type GeneratedPlugin = {
|
|||||||
panels: AppRegistryMeta[];
|
panels: AppRegistryMeta[];
|
||||||
/** How they are arranged. The shell renders \`WorkspaceView\` around them. */
|
/** How they are arranged. The shell renders \`WorkspaceView\` around them. */
|
||||||
layout: LayoutNode;
|
layout: LayoutNode;
|
||||||
|
/**
|
||||||
|
* The screen's own route table, when it declared one.
|
||||||
|
*
|
||||||
|
* Empty means the screen takes the pair every plugin has always had — \`/<route>\` and
|
||||||
|
* \`/<route>/:section\`. A table replaces that pair entirely: the plugin has said what its URLs are,
|
||||||
|
* so a guessed one alongside them would be a route nothing points at.
|
||||||
|
*
|
||||||
|
* \`path\` is react-router's, relative to \`route\`. It is also the LAYOUT KEY suffix, so two routes
|
||||||
|
* with different arrangements remember their own dividers.
|
||||||
|
*/
|
||||||
|
routes: { path: string; layout: LayoutNode }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const plugins: GeneratedPlugin[] = [
|
export const plugins: GeneratedPlugin[] = [
|
||||||
|
|||||||
@@ -323,7 +323,30 @@ export type PluginManifest = {
|
|||||||
* third parties to `/p/<publisher>/` — so a third-party plugin declaring `screens` is refused. Without
|
* third parties to `/p/<publisher>/` — so a third-party plugin declaring `screens` is refused. Without
|
||||||
* that check this field would be a way for any installed plugin to shadow a core route.
|
* that check this field would be a way for any installed plugin to shadow a core route.
|
||||||
*/
|
*/
|
||||||
screens?: { segment: string; label: string; icon?: string }[];
|
screens?: { segment: string; label: string; icon?: string; routes?: PluginRoute[] }[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The routes THIS screen has, when its sections deserve to be URLs rather than a query string.
|
||||||
|
*
|
||||||
|
* For the flat single-screen plugin. A plugin declaring `screens` puts its table on the screen entry
|
||||||
|
* instead, because a route belongs to the screen it renders inside.
|
||||||
|
*
|
||||||
|
* ── Why a table and not a wildcard ──
|
||||||
|
*
|
||||||
|
* Every plugin screen already gets `/<route>` and `/<route>/:section` — enough to put a section in the
|
||||||
|
* path and nothing more. What it cannot express is a SECOND segment (`/layout/header`, `/pages/home`)
|
||||||
|
* or the thing that follows from it: a route arranging its panels differently from its neighbour.
|
||||||
|
*
|
||||||
|
* The platform could have guessed a deeper wildcard, and that is the version this replaces. Guessing
|
||||||
|
* gives every plugin the same shape whether it wants it or not, and gives none of them a way to say
|
||||||
|
* which arrangement belongs to which URL. A declared table says both, in the plugin's own words.
|
||||||
|
*
|
||||||
|
* ── Absent means unchanged ──
|
||||||
|
*
|
||||||
|
* No table and the screen gets the pair it always had. That is every plugin but this one, and the
|
||||||
|
* reason nothing else had to change when this arrived — the same property `screens` was designed with.
|
||||||
|
*/
|
||||||
|
routes?: PluginRoute[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paths under this plugin's own mount that are served ABOVE the account gate.
|
* Paths under this plugin's own mount that are served ABOVE the account gate.
|
||||||
@@ -652,6 +675,35 @@ export type DiscoveredPlugin = {
|
|||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One route within a screen: a path, and optionally its own arrangement of the screen's panels.
|
||||||
|
*
|
||||||
|
* ── The path is react-router's, appended to the screen's own ──
|
||||||
|
*
|
||||||
|
* `'layout/:band?'` on a plugin mounted at `/fofos-admin` matches `/fofos-admin/layout` and
|
||||||
|
* `/fofos-admin/layout/header`. Optional segments are why one entry covers both; a plugin that wants
|
||||||
|
* them to differ writes two entries and gives them different layouts.
|
||||||
|
*
|
||||||
|
* `''` is the index — the bare screen route.
|
||||||
|
*
|
||||||
|
* ── The layout is optional, and usually absent ──
|
||||||
|
*
|
||||||
|
* Omitted, the route renders the screen's own `web/layout.ts`. That is the common case: a route table
|
||||||
|
* is worth declaring for the URLs alone, and most screens want one arrangement across all of them.
|
||||||
|
*
|
||||||
|
* Named, it is a path under `web/` to a module exporting `defaultLayout` — the same contract
|
||||||
|
* `web/layout.ts` has, so a per-route layout is a file of the kind the author already writes.
|
||||||
|
*
|
||||||
|
* A layout gets its OWN persisted sizes, keyed by this path pattern. Two arrangements sharing one key
|
||||||
|
* would have each remembering the other's dividers, which reads as a layout that will not stay put.
|
||||||
|
*/
|
||||||
|
export type PluginRoute = {
|
||||||
|
/** React-router path, relative to the screen's route. `''` is the index. */
|
||||||
|
path: string;
|
||||||
|
/** `web/…` module exporting `defaultLayout`. Omitted = the screen's own. */
|
||||||
|
layout?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One frontend screen: a route, the panels on it, and how they are arranged.
|
* One frontend screen: a route, the panels on it, and how they are arranged.
|
||||||
*
|
*
|
||||||
@@ -688,6 +740,14 @@ export type DiscoveredScreen = {
|
|||||||
art: string | null;
|
art: string | null;
|
||||||
panels: string;
|
panels: string;
|
||||||
layout: string;
|
layout: string;
|
||||||
|
/**
|
||||||
|
* The screen's route table, resolved.
|
||||||
|
*
|
||||||
|
* Empty for a screen that declared none, which is what keeps the old pair — `/<route>` and
|
||||||
|
* `/<route>/:section` — the default. Each `layout` here is an absolute path, already checked to
|
||||||
|
* exist at discovery, so the generator can import it without deciding anything.
|
||||||
|
*/
|
||||||
|
routes: { path: string; layout: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user