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
@@ -872,6 +872,65 @@
name="{{ full_key }}"
value="{{ str_value }}"
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500">
{% if str_widget %}
{# An x-widget the core does not ship may be supplied by the
plugin itself (manifest "widgets", served from its widgets/
directory). Ask the loader for it; the text input above is
the fallback and stays put unless the widget really renders,
so a missing or broken widget degrades to an editable field
rather than dropping the value on save. #}
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
<script>
(function() {
var WIDGET = {{ str_widget|tojson|safe }};
var PLUGIN = {{ plugin_id|tojson|safe }};
function initPluginWidget() {
if (!window.LEDMatrixWidgets || !window.LEDMatrixWidgets.ensureWidget) {
setTimeout(initPluginWidget, 50);
return;
}
var container = document.getElementById('{{ field_id }}_container');
var fallback = document.getElementById('{{ field_id }}');
if (!container) { return; }
window.LEDMatrixWidgets.ensureWidget(WIDGET, PLUGIN).then(function(available) {
if (!available) { container.remove(); return; }
var widget = window.LEDMatrixWidgets.get(WIDGET);
if (!widget || typeof widget.render !== 'function') { container.remove(); return; }
var value = {{ str_value|tojson|safe }};
var config = {
'type': '{{ field_type }}',
'enum': {{ (prop.enum or [])|tojson|safe }},
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }},
'x-widget-config': {{ (prop.get('x-widget-config') or prop.get('x_widget_config') or {})|tojson|safe }}
};
try {
widget.render(container, config, value, {
fieldId: '{{ field_id }}',
name: '{{ full_key }}',
pluginId: PLUGIN
});
} catch (e) {
console.error('[PluginWidget] render failed for ' + WIDGET, e);
container.remove();
return;
}
// Only now is the widget authoritative for this
// field; drop the fallback so the form posts one
// value rather than two under the same name.
if (fallback) { fallback.remove(); }
}).catch(function(e) {
console.warn('[PluginWidget] could not load ' + WIDGET, e);
container.remove();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPluginWidget);
} else {
setTimeout(initPluginWidget, 50);
}
})();
</script>
{% endif %}
{% endif %}
{% endif %}
</div>