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:
2026-09-01 04:57:18 +01:00
co-authored by Claude Opus 5
parent 43f32a7440
commit 1fe767ea77
5 changed files with 200 additions and 16 deletions
+54 -2
View File
@@ -1,5 +1,5 @@
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 {
manifestProblems,
@@ -93,10 +93,14 @@ function findWeb(
`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 {
web: {
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.
fileActions: fileOrNull(join(webDir, 'file-actions.ts')),
@@ -145,6 +149,8 @@ function findWeb(
art: fileOrNull(join(dir, 'assets', `${screen.segment}.png`)),
panels,
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')) } };
}
/**
* 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);
/**