feat(web): serve plugin-declared widgets, and actually ask for them

LEDMatrixWidgets.loadPluginWidget has always fetched
/static/plugin-widgets/<plugin>/<widget>.js, and docs/widget-guide.md has
always documented that path, but nothing served it. soccer-scoreboard has
shipped a 17KB widgets/custom-leagues.js since August that could never
load. Both halves were missing, not just the route:

- serve_plugin_widget serves the script from the plugin's widgets/
  directory as text/javascript. The manifest is the allowlist -- only a
  widget the plugin declares is reachable -- so installing a plugin does
  not publish everything it ships. Path handling mirrors the sibling
  serve_plugin_web_ui: allowlist regexes, os.path.basename, resolve() +
  relative_to() containment, and the ledmatrix- prefix fallback. The
  declared script name is guarded too, since it comes from the plugin
  rather than the request.

- The config form never requested one. Its x-widget dispatch is a
  hardcoded list of core widget names, so a plugin's own widget fell
  through to a plain text input. An unrecognised x-widget on a string
  field now asks ensureWidget() for it. The text input stays as the
  fallback and is removed only once the widget has actually rendered, so
  a missing or broken widget costs the user an editor rather than their
  configured value on the next save.

- manifest_schema.json gains "widgets", so the declaration is validated
  rather than merely tolerated by additionalProperties.

Verified in a browser against the real partial: a declared widget loads,
registers and renders, and its field posts exactly one value; a field
whose widget 404s keeps its text input and still posts its value.

Not addressed: loadPluginWidgetsFromManifest still has no caller. The
per-field ensureWidget path is lazier and is what the form now uses, so
that bulk helper is dead weight -- worth removing, but left alone here
rather than inventing a call site for it.

Known limitation, documented: only string-typed fields take this path.
object/array/boolean/number fields and enums are dispatched by the
template's own branches, which still only know core widgets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-10 10:34:51 -04:00
co-authored by Claude Opus 5
parent 7216d254a9
commit a694b23cd1
6 changed files with 543 additions and 15 deletions
+30 -4
View File
@@ -280,14 +280,40 @@ In your plugin's `config_schema.json`:
}
```
### Step 3: Widget Loading
### Step 3: Declare the Widget in `manifest.json`
The widget will be automatically loaded when the plugin configuration form is rendered. The system will:
The manifest is the allowlist -- a widget is served only if the plugin declares
it, so shipping a file under `widgets/` does not by itself publish it:
1. Check if widget is registered in the core registry
2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js`
```json
{
"widgets": [
{ "name": "my-custom-widget", "script": "my-custom-widget.js" }
]
}
```
`script` is optional and defaults to `[name].js`. It must be a plain filename
directly inside the plugin's `widgets/` directory.
### Step 4: Widget Loading
The widget is loaded on demand when the config form renders a field that
references it. The system will:
1. Check if the widget is registered in the core registry
2. If not, fetch `/static/plugin-widgets/[plugin-id]/[widget-name].js`, which
serves the declared script from the plugin's `widgets/` directory
3. Render the widget using the registered `render` function
The fetch is a dynamic `import()`, so the file must parse as an ES module (a
plain IIFE does). If anything fails, the field falls back to a plain text input
holding the current value, so a broken widget never costs the user their
configured value.
Only `string`-typed fields take this path today; see `docs/widget-guide.md`
for the full details and limitations.
## Widget API Reference
### Widget Definition Object