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
+51 -11
View File
@@ -285,7 +285,9 @@ Guidelines:
### Step 1: Create Widget File
Create a JavaScript file in your plugin directory. The recommended location is `widgets/[widget-name].js`:
Create a JavaScript file in your plugin's `widgets/` directory, named
`widgets/[widget-name].js`. The directory is not optional: it is the only
place the core will serve a widget from.
```javascript
// Ensure LEDMatrixWidgets registry is available
@@ -366,7 +368,29 @@ window.LEDMatrixWidgets.register('my-custom-widget', {
});
```
### Step 2: Reference Widget in Schema
### Step 2: Declare the Widget in `manifest.json`
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:
```json
{
"widgets": [
{
"name": "my-custom-widget",
"script": "my-custom-widget.js",
"description": "What this widget is for"
}
]
}
```
`name` is what you use in `x-widget` and in the URL. `script` is optional and
defaults to `[name].js`; it must be a plain filename directly inside
`widgets/` (no paths). Both are validated against
`schema/manifest_schema.json`.
### Step 3: Reference Widget in Schema
In your plugin's `config_schema.json`:
@@ -383,15 +407,30 @@ In your plugin's `config_schema.json`:
}
```
### Step 3: Widget Loading
### Step 4: Widget Loading
The widget will be automatically loaded when the plugin configuration form is rendered. The system will:
The widget is loaded on demand when the plugin's configuration form renders a
field that references it. The system will:
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`
3. Render the widget using the registered `render` function
1. Check whether the widget is already registered in the core registry.
2. If not, fetch it from `/static/plugin-widgets/[plugin-id]/[widget-name].js`.
That route serves the declared `script` from your plugin's `widgets/`
directory, as `text/javascript`.
3. Render it by calling the `render` function your script registered.
**Note:** Currently, widgets are server-side rendered via Jinja2 templates. Custom widgets registered via the registry will have their handlers available, but full client-side rendering is a future enhancement.
The fetch uses a dynamic `import()`, so the file must parse as an ES module.
A plain IIFE does — modules are strict mode, so avoid sloppy-mode constructs.
**If the widget fails to load** (not declared, file missing, script throws, or
it never calls `register`), the field falls back to a plain text input holding
the current value. This is deliberate: a broken widget costs the user an
editor, not their configured value.
**Limitation:** the on-demand path applies to `string`-typed fields (the
default branch of the config-form renderer). Fields typed `object`, `array`,
`boolean`, `integer` or `number`, and fields whose `enum` is set, are
dispatched by the server-side template to its own built-in renderers, so a
plugin-supplied `x-widget` on one of those is ignored today.
## Widget API Reference
@@ -497,10 +536,11 @@ See [`web_interface/static/v3/js/widgets/example-color-picker.js`](../web_interf
- ✅ Plugin widget loading system implemented
**Current Behavior:**
- Widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
- Core widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
- Widget handlers are registered and available globally
- Custom widgets can be created and registered
- Full client-side rendering is a future enhancement
- Custom widgets can be created, declared in `manifest.json`, and are served
and rendered on demand for `string`-typed fields
- Plugin widgets on non-string fields are not dispatched yet (see Step 4)
**Backwards Compatibility:**
- All existing plugins using widgets continue to work without changes
+24
View File
@@ -257,6 +257,30 @@
},
"description": "Web UI action definitions"
},
"widgets": {
"type": "array",
"description": "Custom web-UI widgets this plugin provides. Each entry is served at /static/plugin-widgets/<plugin-id>/<name>.js from the plugin's widgets/ directory; only declared widgets are served. Reference one from config_schema.json with \"x-widget\": \"<name>\".",
"items": {
"type": "object",
"required": ["name"],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{1,64}$",
"description": "Widget name, as used in x-widget and in the URL."
},
"script": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-]{1,64}\\.js$",
"description": "Filename inside widgets/. Defaults to <name>.js."
},
"description": {
"type": "string",
"description": "Human-readable summary shown to plugin authors."
}
}
}
},
"ledmatrix_version": {
"type": "string",
"description": "Deprecated: Use compatible_versions instead. LEDMatrix version this plugin targets"
@@ -0,0 +1,261 @@
"""The server half of the plugin-supplied widget feature.
``LEDMatrixWidgets.loadPluginWidget`` (static/v3/js/widgets/plugin-loader.js)
has always fetched ``/static/plugin-widgets/<plugin>/<widget>.js``, and
docs/widget-guide.md has always documented that path, but nothing served it --
so a plugin could declare a widget, ship the file, and still never load it.
soccer-scoreboard has shipped exactly that since August.
The load-bearing property here is that the manifest is the allowlist. A plugin
directory is attacker-influenced in the sense that matters -- plugins are
user-installed, and the store installs them -- so "serve files from the plugin
directory" would publish everything a plugin ships. Only a widget the manifest
declares is reachable, and only from that plugin's widgets/ directory.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
WIDGET_BODY = "(function(){ window.LEDMatrixWidgets.register('custom-leagues', {}); })();\n"
_UNSET = object()
def _make_plugin(plugins_dir, plugin_id="soccer-scoreboard", widgets=None,
files=None):
"""Write a plugin directory with a manifest and a widgets/ folder."""
d = plugins_dir / plugin_id
(d / "widgets").mkdir(parents=True)
manifest = {"id": plugin_id, "name": plugin_id, "version": "1.0.0"}
if widgets is not None:
manifest["widgets"] = widgets
(d / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
for name, body in (files or {}).items():
(d / "widgets" / name).write_text(body, encoding="utf-8")
return d
@pytest.fixture
def make_client(tmp_path):
"""Build a test client whose plugin manager points at a temp plugins dir.
pages_v3 is a module-level Blueprint singleton shared across the test
process, so the original plugin_manager is restored on teardown.
"""
from web_interface.blueprints import pages_v3 as pv
original_pm = getattr(pv.pages_v3, "plugin_manager", None)
def _build(plugins_dir=None, plugin_manager=_UNSET):
base = PROJECT_ROOT / "web_interface"
app = Flask(__name__,
template_folder=str(base / "templates"),
static_folder=str(base / "static"))
app.config["TESTING"] = True
if plugin_manager is _UNSET:
plugin_manager = MagicMock()
plugin_manager.plugins_dir = str(plugins_dir or tmp_path)
pv.pages_v3.plugin_manager = plugin_manager
app.register_blueprint(pv.pages_v3, url_prefix="")
return app.test_client()
try:
yield _build
finally:
pv.pages_v3.plugin_manager = original_pm
URL = "/static/plugin-widgets/{}/{}.js"
class TestDeclaredWidgetIsServed:
def test_a_declared_widget_is_served(self, tmp_path, make_client):
_make_plugin(tmp_path,
widgets=[{"name": "custom-leagues",
"script": "custom-leagues.js"}],
files={"custom-leagues.js": WIDGET_BODY})
r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues"))
assert r.status_code == 200
assert r.get_data(as_text=True) == WIDGET_BODY
def test_it_is_served_as_javascript(self, tmp_path, make_client):
"""The loader uses dynamic import(); a wrong MIME type is refused."""
_make_plugin(tmp_path,
widgets=[{"name": "custom-leagues",
"script": "custom-leagues.js"}],
files={"custom-leagues.js": WIDGET_BODY})
r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues"))
assert "javascript" in r.headers["Content-Type"]
def test_script_defaults_to_the_widget_name(self, tmp_path, make_client):
_make_plugin(tmp_path, widgets=[{"name": "custom-leagues"}],
files={"custom-leagues.js": WIDGET_BODY})
r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues"))
assert r.status_code == 200
def test_the_ledmatrix_prefix_fallback_resolves(self, tmp_path, make_client):
"""PluginManager resolves 'music' to 'ledmatrix-music'; so must this."""
_make_plugin(tmp_path, plugin_id="ledmatrix-music",
widgets=[{"name": "deck", "script": "deck.js"}],
files={"deck.js": WIDGET_BODY})
r = make_client().get(URL.format("music", "deck"))
assert r.status_code == 200
class TestTheManifestIsTheAllowlist:
def test_an_undeclared_file_in_widgets_is_not_served(self, tmp_path, make_client):
"""The whole point: shipping a file does not publish it."""
_make_plugin(tmp_path, widgets=[],
files={"secrets.js": "const KEY='hunter2';"})
r = make_client().get(URL.format("soccer-scoreboard", "secrets"))
assert r.status_code == 404
assert "hunter2" not in r.get_data(as_text=True)
def test_a_manifest_with_no_widgets_key_serves_nothing(self, tmp_path, make_client):
_make_plugin(tmp_path, files={"anything.js": WIDGET_BODY})
r = make_client().get(URL.format("soccer-scoreboard", "anything"))
assert r.status_code == 404
def test_a_declared_widget_whose_file_is_missing_is_404(self, tmp_path, make_client):
_make_plugin(tmp_path, widgets=[{"name": "ghost", "script": "ghost.js"}])
r = make_client().get(URL.format("soccer-scoreboard", "ghost"))
assert r.status_code == 404
def test_a_malformed_manifest_serves_nothing(self, tmp_path, make_client):
d = tmp_path / "broken"
(d / "widgets").mkdir(parents=True)
(d / "manifest.json").write_text("{not json", encoding="utf-8")
(d / "widgets" / "w.js").write_text(WIDGET_BODY, encoding="utf-8")
assert make_client().get(URL.format("broken", "w")).status_code == 404
class TestPathTraversal:
@pytest.mark.parametrize("plugin_id", ["../etc", "..%2f..", "a/b", "a\\b", ""])
def test_a_hostile_plugin_id_never_reaches_the_filesystem(
self, plugin_id, tmp_path, make_client):
r = make_client().get(URL.format(plugin_id, "custom-leagues"))
assert r.status_code in (400, 404), r.status_code
@pytest.mark.parametrize("widget", ["../manifest", "..%2fsecret", "a/b"])
def test_a_hostile_widget_name_never_reaches_the_filesystem(
self, widget, tmp_path, make_client):
_make_plugin(tmp_path, widgets=[{"name": "custom-leagues"}],
files={"custom-leagues.js": WIDGET_BODY})
r = make_client().get(URL.format("soccer-scoreboard", widget))
assert r.status_code in (400, 404), r.status_code
def test_a_manifest_cannot_escape_the_widgets_directory(self, tmp_path, make_client):
"""A hostile manifest is the traversal vector the URL allowlist can't
cover: the script name comes from the plugin, not the request."""
(tmp_path / "loot.js").write_text("const KEY='hunter2';", encoding="utf-8")
_make_plugin(tmp_path,
widgets=[{"name": "evil", "script": "../../loot.js"}])
r = make_client().get(URL.format("soccer-scoreboard", "evil"))
assert r.status_code == 404
assert "hunter2" not in r.get_data(as_text=True)
class TestDegradation:
def test_an_unknown_plugin_is_404(self, tmp_path, make_client):
assert make_client().get(URL.format("nope", "w")).status_code == 404
def test_no_plugin_manager_is_503(self, make_client):
r = make_client(plugin_manager=None).get(URL.format("any", "w"))
assert r.status_code == 503
def _schema_with_widget(widget_name):
return {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"leagues": {"type": "string", "default": "eng.1",
"x-widget": widget_name},
},
}
@pytest.fixture
def config_form(tmp_path):
"""Render a plugin's config partial with a temp plugin on disk."""
from web_interface.blueprints import pages_v3 as pv
orig_pm = getattr(pv.pages_v3, "plugin_manager", None)
orig_cm = getattr(pv.pages_v3, "config_manager", None)
def _render(plugin_id="soccer-scoreboard", schema=None, widgets=None,
files=None):
d = _make_plugin(tmp_path, plugin_id, widgets=widgets, files=files)
(d / "config_schema.json").write_text(
json.dumps(schema or {"type": "object", "properties": {}}),
encoding="utf-8")
pm = MagicMock()
pm.plugins_dir = str(tmp_path)
pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}
pm.get_plugin.return_value = None
pv.pages_v3.plugin_manager = pm
cm = MagicMock()
cm.load_config.return_value = {plugin_id: {"enabled": True}}
pv.pages_v3.config_manager = cm
base = PROJECT_ROOT / "web_interface"
app = Flask(__name__,
template_folder=str(base / "templates"),
static_folder=str(base / "static"))
app.config["TESTING"] = True
app.register_blueprint(pv.pages_v3, url_prefix="")
return app.test_client().get(f"/partials/plugin-config/{plugin_id}")
try:
yield _render
finally:
pv.pages_v3.plugin_manager = orig_pm
pv.pages_v3.config_manager = orig_cm
class TestTheFormRequestsPluginWidgets:
"""Without this the feature is still dead: the route can serve a widget,
but nothing ever asks for one. The server-side form only knows a hardcoded
list of core widget names, so a plugin's own x-widget fell through to a
plain text input and was never fetched."""
def test_an_unknown_widget_name_triggers_a_plugin_load(self, config_form):
r = config_form(schema=_schema_with_widget("custom-leagues"),
widgets=[{"name": "custom-leagues"}],
files={"custom-leagues.js": WIDGET_BODY})
assert r.status_code == 200
body = r.get_data(as_text=True)
assert "ensureWidget" in body
assert '"custom-leagues"' in body
def test_the_text_input_remains_as_the_fallback(self, config_form):
"""A widget that fails to load must not cost the user their value."""
r = config_form(schema=_schema_with_widget("custom-leagues"),
widgets=[{"name": "custom-leagues"}],
files={"custom-leagues.js": WIDGET_BODY})
body = r.get_data(as_text=True)
assert 'name="leagues"' in body
assert 'value="eng.1"' in body
def test_a_plain_string_field_asks_for_no_widget(self, config_form):
r = config_form(schema={"type": "object", "properties": {
"leagues": {"type": "string", "default": "eng.1"}}})
assert "ensureWidget" not in r.get_data(as_text=True)
def test_a_core_widget_does_not_take_the_plugin_path(self, config_form):
r = config_form(schema=_schema_with_widget("font-selector"))
body = r.get_data(as_text=True)
assert "ensureWidget" not in body
assert "LEDMatrixWidgets.get('font-selector')" in body
+118
View File
@@ -12,6 +12,8 @@ from pathlib import Path
# Strict allowlists for URL-derived values used in path and script operations.
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
_SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
from src.web_interface.secret_helpers import mask_secret_fields
logger = logging.getLogger(__name__)
@@ -358,6 +360,122 @@ def serve_plugin_web_ui(plugin_id, filename):
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
def _plugin_dir_for(safe_id):
"""Resolve a sanitised plugin id to its directory, or None.
Mirrors serve_plugin_web_ui: containment-guarded against the configured
plugins directory, with PluginManager's ``ledmatrix-`` prefix fallback.
"""
plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
plugin_dir = (plugins_base / safe_id).resolve()
plugin_dir.relative_to(plugins_base) # containment guard
if not plugin_dir.exists():
alt_id = os.path.basename(f'ledmatrix-{safe_id}')
alt = (plugins_base / alt_id).resolve()
try:
alt.relative_to(plugins_base)
plugin_dir = alt
except ValueError:
pass
return plugin_dir
def _declared_widget_script(plugin_dir, widget_name):
"""The script filename a plugin's manifest declares for ``widget_name``.
The manifest is the allowlist: only a widget the plugin actually declares
can be served, so this route never exposes arbitrary files under the
plugin directory even though the directory itself is attacker-influenced
(plugins are user-installed). Returns None when the widget is not
declared, the manifest is unreadable, or the declared script name is not
a plain ``<name>.js`` basename.
"""
manifest_path = plugin_dir / 'manifest.json'
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
except (OSError, ValueError):
return None
if not isinstance(manifest, dict):
return None
for entry in manifest.get('widgets') or ():
if not isinstance(entry, dict):
continue
if entry.get('name') != widget_name:
continue
script = entry.get('script') or f'{widget_name}.js'
if not isinstance(script, str) or not _SAFE_WIDGET_SCRIPT_RE.match(script):
return None
return script
return None
@pages_v3.route('/static/plugin-widgets/<plugin_id>/<widget_name>.js')
def serve_plugin_widget(plugin_id, widget_name):
"""Serve a plugin-declared widget script from its ``widgets/`` directory.
This is the server half of ``LEDMatrixWidgets.loadPluginWidget`` (see
static/v3/js/widgets/plugin-loader.js), which fetches exactly this path.
The loader uses a dynamic ``import()``, so the response must carry a
JavaScript MIME type or the browser refuses the module.
The route is deliberately narrower than the plugin directory: a script is
served only when the plugin's own manifest declares a widget by that name,
so installing a plugin does not publish everything it ships.
"""
if not _SAFE_PLUGIN_ID_RE.match(plugin_id):
return 'Invalid plugin ID', 400, {'Content-Type': 'text/plain'}
if not _SAFE_WIDGET_NAME_RE.match(widget_name):
return 'Invalid widget name', 400, {'Content-Type': 'text/plain'}
# os.path.basename() is the CodeQL-recognised path sanitizer used
# throughout this codebase; the allowlists above already exclude
# separators, this breaks the taint chain.
safe_id = os.path.basename(plugin_id)
safe_widget = os.path.basename(widget_name)
if not safe_id or not safe_widget:
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
if not pages_v3.plugin_manager:
return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'}
try:
plugin_dir = _plugin_dir_for(safe_id)
if not plugin_dir.exists():
return 'Not found', 404, {'Content-Type': 'text/plain'}
script = _declared_widget_script(plugin_dir, safe_widget)
if script is None:
# Undeclared is a 404 rather than a 403: whether a plugin happens
# to ship an undeclared file is not something to confirm.
return 'Not found', 404, {'Content-Type': 'text/plain'}
widgets_dir = (plugin_dir / 'widgets').resolve()
script_path = (widgets_dir / os.path.basename(script)).resolve()
script_path.relative_to(widgets_dir) # second containment guard
if not script_path.is_file():
return 'Not found', 404, {'Content-Type': 'text/plain'}
body = script_path.read_text(encoding='utf-8')
return body, 200, {
'Content-Type': 'text/javascript; charset=utf-8',
# Plugin updates replace this file in place; revalidate so a
# stale widget cannot outlive the plugin version that shipped it.
'Cache-Control': 'no-cache',
}
except ValueError:
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
except Exception:
logger.error('Error serving plugin widget %s/%s', plugin_id, widget_name,
exc_info=True)
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
def _load_overview_partial():
"""Load overview partial with system stats"""
try:
+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
@@ -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>