← Commit history

adom-table: bundle the adom-widget runtime into the served script

Ray ·69b8086f05 ·1mo ago ·parent cec8cdb
The wiki's widget frame loads exactly ONE script and provides no runtime, so a registered script has to be self-contained. All six pre-existing registered widgets are; adom-table was not, and its first line is a silent 'if (!window.AdomWidget) return', so <adom-table> defined nothing and rendered NOTHING on a real page. Measured in a replica of the live frame: before, defined=false hasRuntime=false rows=0. After, defined=true shadow=true rows=8 fetched through src.
1 file changed +484
widgets/adom-table.js+484
@@ -1,3 +1,487 @@+/*! adom-table -- bundled: adom-widget runtime + the widget.+ * The wiki's widget frame loads ONE script and provides no runtime, so a registered+ * script must be self-contained. Every other registered widget is. Shipping the widget+ * alone made <adom-table> render nothing at all on a real page.+ */+/*!+ * adom-widget - the runtime every Adom widget is built on.+ *+ * A widget is an embeddable component that renders on a page. It has two modes:+ *+ *   front-end mode  the output is a pure function of data already on the page.+ *                   No server. Cannot go stale, cannot break, works offline.+ *   back-end mode   the widget calls an app's back-end, because the work is+ *                   something a browser cannot do: run a binary, hold shared+ *                   consensus, or answer a question whose answer changes+ *                   without the page changing.+ *+ * Choose front-end mode unless one of those three is true. That is the whole+ * design rule, and this runtime makes front-end mode the path of least effort.+ *+ * Zero dependencies. Never bundles a framework. Works in three hosts:+ * a wiki readme frame, a wiki section frame, and a standalone page or+ * dashboard. Host-specific mechanics are detected, never assumed, so a widget+ * written for a wiki page also runs inside adom-lbr or a dock dashboard.+ */+(function (global) {+  'use strict';++  var VERSION = '1.0.0';++  /* ---------------------------------------------------------------- host --+   * Detected, not configured. A widget must never ask which host it is in+   * to decide what it renders; it may ask to decide how it talks outward.+   */+  var host = (function () {+    var framed = false;+    try { framed = global.parent && global.parent !== global; } catch (_) { framed = true; }+    // An opaque-origin frame (the readme sandbox) throws on storage access.+    var opaque = false;+    try { global.localStorage.getItem('_aw'); } catch (_) { opaque = true; }+    return {+      framed: !!framed,+      opaque: opaque,+      // The readme frame is sandboxed to an opaque origin; a section frame is not.+      kind: !framed ? 'standalone' : (opaque ? 'readme' : 'framed')+    };+  }());++  /* Storage that degrades instead of throwing. localStorage is unavailable at+   * the readme frame's opaque origin, and a bare access there is a hard throw+   * that kills widget startup before anything renders. */+  var store = (function () {+    var mem = {};+    var real = null;+    try {+      global.localStorage.setItem('_aw', '1');+      global.localStorage.removeItem('_aw');+      real = global.localStorage;+    } catch (_) { real = null; }+    return {+      persistent: !!real,+      get: function (k) { try { return real ? real.getItem(k) : (k in mem ? mem[k] : null); } catch (_) { return null; } },+      set: function (k, v) { try { if (real) real.setItem(k, v); else mem[k] = String(v); } catch (_) { mem[k] = String(v); } },+      del: function (k) { try { if (real) real.removeItem(k); else delete mem[k]; } catch (_) { delete mem[k]; } }+    };+  }());++  /* ----------------------------------------------------------- host bridge --+   * Height reporting. A framed widget must tell its host how tall it is or it+   * renders into a fixed-height box and clips. Every widget shipped before this+   * runtime carried a byte-identical copy of this; now none of them do.+   */+  var bridge = {+    reportHeight: function () {+      if (!host.framed) return;+      var h = 0;+      try { h = document.body ? document.body.scrollHeight : 0; } catch (_) { }+      h = Math.max(120, Math.min(16000, h || 200));+      try { global.parent.postMessage({ type: 'readme-height', height: h }, '*'); } catch (_) { }+    },+    // Zoom an image to the host's lightbox. Falls back to a new tab when the+    // host offers no lightbox, so this is safe to call from any host.+    lightbox: function (src, alt) {+      if (host.framed) {+        try { global.parent.postMessage({ type: 'readme-lightbox', src: src, alt: alt || '' }, '*'); return; } catch (_) { }+      }+      try { global.open(src, '_blank', 'noopener'); } catch (_) { }+    }+  };++  // Auto-grow. Observed rather than polled, with a settle timer for late assets.+  var autoHeight = (function () {+    var armed = false;+    return function () {+      if (armed || !host.framed) return;+      armed = true;+      var fire = function () { bridge.reportHeight(); };+      if (global.ResizeObserver && document.body) {+        try { new global.ResizeObserver(fire).observe(document.body); } catch (_) { }+      }+      global.addEventListener('load', fire);+      [0, 60, 250, 800, 2000].forEach(function (t) { setTimeout(fire, t); });+    };+  }());++  /* ------------------------------------------------------------------ bus --+   * The cross-highlight bus. Hovering pin 7 in a pinout lights pad 7 in the+   * footprint, pin 7 in the symbol, and lead 7 in the 3D view, even though+   * each of those is a separate frame. BroadcastChannel carries it same-origin;+   * a parent relay carries it across frames.+   */+  var bus = (function () {+    // Wire protocol, as deployed by adom-lbr's symbol/footprint/3D viewers:+    //   { type: 'lbr-hover', pin: <string|null>, mpn: <string>, src: <sender id> }+    // `src` is the sender's own id, so a sender ignores its own echo instead of+    // ping-ponging. Do not "improve" this shape; other frames already speak it.+    var TYPE = 'lbr-hover';+    var chan = null;+    try { chan = ('BroadcastChannel' in global) ? new global.BroadcastChannel(TYPE) : null; } catch (_) { chan = null; }+    var subs = [];+    var onMsg = function (ev) {+      var d = ev && ev.data;+      if (!d || d.type !== TYPE) return;+      subs.forEach(function (s) {+        if (d.src && d.src === s.id) return;   // our own echo+        try { s.fn(d); } catch (_) { }+      });+    };+    if (chan) chan.onmessage = onMsg;+    global.addEventListener('message', onMsg);+    var seq = 0;+    return {+      newId: function () { return 'w' + (++seq) + Math.random().toString(36).slice(2, 8); },+      emit: function (id, detail) {+        var msg = Object.assign({ type: TYPE, src: id }, detail || {});+        try { if (chan) chan.postMessage(msg); } catch (_) { }+        try { if (host.framed) global.parent.postMessage(msg, '*'); } catch (_) { }+      },+      on: function (id, fn) {+        var s = { id: id, fn: fn };+        subs.push(s);+        return function () { var i = subs.indexOf(s); if (i >= 0) subs.splice(i, 1); };+      },+      close: function () { try { if (chan) chan.close(); } catch (_) { } }+    };+  }());++  /* -------------------------------------------------------------- backend --+   * Back-end mode. The single most confusing failure in this system is a+   * blocked fetch: the browser reports a bare TypeError, which reads like the+   * server is down when in fact the page never allowed the origin. This turns+   * that into the sentence that names the fix.+   */+  function Backend(origin) {+    // Deliberately does not throw. Constructing this happens at define() time,+    // and a throw there kills the whole script before any element upgrades:+    // the widget then renders nothing at all, with no clue why. An unusable+    // origin is recorded and reported when something actually calls it.+    this.origin = String(origin || '').replace(/\/+$/, '') + '/';+    this.invalid = (!/^https:\/\//.test(this.origin) &&+                    !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?\//.test(this.origin))+      ? 'back-end origin must be https (got "' + origin + '"). A widget opened from ' ++        'a file:// page has no origin to infer; pass one explicitly.'+      : null;+  }+  Backend.prototype.url = function (path) { return this.origin + String(path || '').replace(/^\/+/, ''); };+  Backend.prototype.call = function (path, opts) {+    var self = this;+    var o = opts || {};+    if (this.invalid) return Promise.reject(new Error(this.invalid));+    return global.fetch(this.url(path), o).then(function (r) {+      if (!r.ok) {+        return r.text().then(function (t) {+          throw new Error('back-end ' + r.status + ' from ' + self.origin + (t ? ': ' + t.slice(0, 200) : ''));+        });+      }+      var ct = r.headers.get('content-type') || '';+      return ct.indexOf('json') >= 0 ? r.json() : r.text();+    }).catch(function (err) {+      // A CSP block and a dead server both surface as TypeError. Say which.+      if (err instanceof TypeError) {+        throw new Error(+          'Could not reach ' + self.origin + '. If the back-end is up, this page has not ' ++          'allowed the origin: add it to widget_connect in page.json.'+        );+      }+      throw err;+    });+  };+  Backend.prototype.health = function () { return this.call('health'); };++  /* ----------------------------------------------------------------- data --+   * How a widget receives input, in priority order:+   *   1. an inline <script type="application/json"> child   <- use this on a wiki page+   *   2. a src="" URL+   *   3. a data-* attribute holding JSON+   *+   * Inline is first because the readme frame's connect-src is 'self' plus the+   * declared back-ends: a widget that fetches its own data from elsewhere is+   * blocked, and it is the failure people hit first.+   */+  function readData(el) {+    // Set imperatively (el.data = {...}). Wins over markup, which is what a+    // dashboard host needs: it owns the data and re-feeds the same element.+    if (el._props && el._props.data != null) return { data: el._props.data, from: 'property' };+    var inline = el.querySelector('script[type="application/json"]');+    if (inline && inline.textContent.trim()) {+      try { return { data: JSON.parse(inline.textContent), from: 'inline' }; }+      catch (e) { return { error: 'inline JSON is malformed: ' + e.message }; }+    }+    var raw = el.getAttribute('data-json');+    if (raw) {+      try { return { data: JSON.parse(raw), from: 'attribute' }; }+      catch (e) { return { error: 'data-json is malformed: ' + e.message }; }+    }+    var src = el.getAttribute('src');+    if (src) return { src: src, from: 'src' };+    return { data: null, from: 'none' };+  }++  /* ---------------------------------------------------------------- theme --+   * One palette, so widgets from different apps sitting on one page look like+   * one product. Consumers override by setting these on any ancestor.+   */+  var THEME = [+    '--aw-bg:#0d1117', '--aw-panel:#161b22', '--aw-line:#30363d',+    '--aw-tx:#e6edf3', '--aw-tx2:#9198a1', '--aw-tx3:#6e7681',+    '--aw-teal:#00e6dc', '--aw-blue:#58a6ff', '--aw-purple:#bc8cff',+    '--aw-red:#f85149', '--aw-green:#3fb950', '--aw-amber:#d29922',+    '--aw-radius:10px',+    '--aw-font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',+    '--aw-mono:ui-monospace,SFMono-Regular,"JetBrains Mono",Menlo,monospace'+  ].join(';');++  var BASE_CSS =+    ':host{display:block;' + THEME + ';font-family:var(--aw-font);color:var(--aw-tx);' ++    'box-sizing:border-box;contain:content}' ++    '*,*::before,*::after{box-sizing:border-box}' ++    '.aw-card{background:var(--aw-panel);border:1px solid var(--aw-line);' ++    'border-radius:var(--aw-radius);overflow:hidden}' ++    '.aw-state{padding:18px;font-size:13px;line-height:1.5;color:var(--aw-tx2);' ++    'display:flex;align-items:center;gap:9px}' ++    '.aw-state.err{color:var(--aw-red)}' ++    '.aw-spin{width:13px;height:13px;border:2px solid var(--aw-line);' ++    'border-top-color:var(--aw-teal);border-radius:50%;animation:aw-sp .7s linear infinite;flex:none}' ++    '@keyframes aw-sp{to{transform:rotate(360deg)}}' ++    '@media (prefers-reduced-motion:reduce){.aw-spin{animation:none}}';++  function esc(s) {+    return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {+      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];+    });+  }++  /* --------------------------------------------------------------- define --+   * The whole authoring surface. A widget supplies css + render(), and gets+   * shadow DOM, data loading, states, the bus, height reporting, cleanup and+   * a re-registration guard for free.+   */+  function define(tag, spec) {+    if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(tag)) {+      throw new Error('adom-widget: "' + tag + '" is not a valid custom element name');+    }+    if (global.customElements && global.customElements.get(tag)) return global.customElements.get(tag);++    var backend = spec.backend ? new Backend(spec.backend.origin || spec.backend) : null;++    function Widget() { return Reflect.construct(HTMLElement, [], Widget); }+    Widget.prototype = Object.create(HTMLElement.prototype);+    Widget.prototype.constructor = Widget;+    Object.setPrototypeOf(Widget, HTMLElement);++    Object.defineProperty(Widget, 'observedAttributes', {+      get: function () { return (spec.attributes || []).concat(['src', 'data-json']); }+    });++    // Imperative properties. Assigning re-renders, and assigning before the+    // element is upgraded still works (the value is replayed on connect).+    ['data'].concat(spec.props || []).forEach(function (name) {+      Object.defineProperty(Widget.prototype, name, {+        configurable: true,+        get: function () { return this._props ? this._props[name] : undefined; },+        set: function (v) {+          if (!this._props) this._props = {};+          this._props[name] = v;+          if (this._aw) this._aw.reload();+        }+      });+    });++    Widget.prototype.attributeChangedCallback = function (name, oldV, newV) {+      if (this._aw && oldV !== newV) this._aw.reload();+    };++    Widget.prototype.connectedCallback = function () {+      var el = this;+      if (el._aw) return;+      // A property assigned before the definition loaded sits as an own+      // property, shadowing the prototype accessor. Replay it.+      ['data'].concat(spec.props || []).forEach(function (n) {+        if (Object.prototype.hasOwnProperty.call(el, n)) {+          var v = el[n];+          delete el[n];+          el[n] = v;+        }+      });+      var root = el.shadowRoot || el.attachShadow({ mode: 'open' });+      var teardown = [];++      var ctx = {+        version: VERSION,+        host: host,+        store: store,+        bus: (function () {+          var id = bus.newId();+          return {+            id: id,+            emit: function (detail) { bus.emit(id, detail); },+            on: function (fn) { var off = bus.on(id, fn); teardown.push(off); return off; }+          };+        }()),+        backend: backend,+        lightbox: bridge.lightbox,+        esc: esc,+        attr: function (n, dflt) { var v = el.getAttribute(n); return v == null ? dflt : v; },+        // --- back-end mode helpers -------------------------------------+        // Run a back-end call behind a button. Disables the button, shows+        // progress, renders failure in place, and always re-enables. Every+        // back-end widget was hand-rolling this, and the ones that forgot the+        // re-enable left a dead button after the first error.+        task: function (btn, outEl, fn, opts) {+          var o = opts || {};+          if (btn) { btn.disabled = true; }+          if (outEl) {+            outEl.innerHTML = '<div class="aw-state"><div class="aw-spin"></div><div>' ++              esc(o.busy || 'Working') + '</div></div>';+          }+          return Promise.resolve().then(fn).catch(function (e) {+            if (outEl) {+              outEl.innerHTML = '<div class="aw-state err">' + esc(e.message || String(e)) + '</div>';+            }+            throw e;+          }).then(function (v) {+            if (btn) { btn.disabled = false; if (o.again) btn.textContent = o.again; }+            return v;+          }, function (e) {+            if (btn) { btn.disabled = false; if (o.again) btn.textContent = o.again; }+            console.error('[' + tag + '] task:', e);+          });+        },++        // A wiki issue link, prefilled. Widgets are how most readers meet a+        // part, so they are also where a wrong part gets reported.+        reportIssueUrl: function (o) {+          o = o || {};+          var page = o.page || ctx.attr('page', '');+          if (!page) return null;+          return 'https://wiki.adom.inc/' + page + '/issues?category=' ++            encodeURIComponent(o.category || 'bug-report') ++            '&title=' + encodeURIComponent(o.title || '') ++            '&body=' + encodeURIComponent(o.body || '');+        },++        // Hand the reader a file. The readme frame grants allow-downloads but+        // blocks navigation, so a data: URL is the only route that works in+        // every host. Never a link to another origin.+        fileUrl: function (bytesOrB64, mime) {+          var b64 = typeof bytesOrB64 === 'string'+            ? bytesOrB64+            : global.btoa(String.fromCharCode.apply(null, new Uint8Array(bytesOrB64)));+          return 'data:' + (mime || 'application/octet-stream') + ';base64,' + b64;+        },++        bytes: function (n) {+          if (n == null) return '';+          return n < 1024 ? n + ' B' : (n / 1024).toFixed(1) + ' KB';+        },++        // Ask the host to resize after content changes height (images, 3D, tabs).+        resize: bridge.reportHeight,+        onTeardown: function (fn) { teardown.push(fn); }+      };++      var paint = function (bodyHtml) {+        root.innerHTML = '<style>' + BASE_CSS + (spec.css || '') + '</style>' + bodyHtml;+        bridge.reportHeight();+      };+      var state = function (msg, isErr) {+        paint('<div class="aw-card"><div class="aw-state' + (isErr ? ' err' : '') + '">' ++          (isErr ? '' : '<div class="aw-spin"></div>') + '<div>' + esc(msg) + '</div></div></div>');+      };++      var render = function (data) {+        try {+          var out = spec.render.call(el, data, ctx);+          var done = function (html) {+            paint(html == null ? '' : html);+            if (spec.mounted) {+              try { spec.mounted.call(el, root, data, ctx); }+              catch (e) { console.error('[' + tag + '] mounted:', e); }+            }+            bridge.reportHeight();+          };+          if (out && typeof out.then === 'function') out.then(done, function (e) { state(e.message || String(e), true); });+          else done(out);+        } catch (e) {+          console.error('[' + tag + '] render:', e);+          state(e.message || String(e), true);+        }+      };++      var load = function () {+        var d = readData(el);+        if (d.error) return state(d.error, true);+        if (d.from === 'src') {+          state(spec.loadingText || 'Loading');+          return global.fetch(d.src)+            .then(function (r) {+              if (!r.ok) throw new Error('could not load ' + d.src + ' (' + r.status + ')');+              return r.json();+            })+            .then(render)+            .catch(function (e) {+              var m = (e instanceof TypeError)+                ? 'Could not load ' + d.src + '. On a wiki page, put the data in an inline ' ++                  'JSON script tag instead: the frame may only fetch from its own origin.'+                : (e.message || String(e));+              state(m, true);+            });+        }+        if (d.data == null && spec.requiresData !== false) {+          return state(spec.emptyText || 'No data supplied to ' + tag, true);+        }+        render(d.data);+      };++      el._aw = {+        ctx: ctx, reload: load, state: state, paint: paint,+        destroy: function () { teardown.forEach(function (f) { try { f(); } catch (_) { } }); teardown = []; }+      };++      autoHeight();++      // The HTML parser fires connectedCallback on the OPENING tag, so an inline+      // <script type="application/json"> child does not exist yet: readData() finds+      // nothing and the widget reports "No data supplied" for data sitting right+      // there in the markup. Only reachable while the document is still parsing,+      // which is why nobody hit it -- the wiki injects widget scripts after parse,+      // and test/framed.html happens to put its <script> tags AFTER the element.+      // Put them in <head> and every inline-data widget breaks.+      //+      // Defer to DOMContentLoaded, but ONLY when nothing is resolvable yet, so a+      // widget whose data is already in hand still paints at parse time.+      if (document.readyState === 'loading' && readData(el).from === 'none') {+        document.addEventListener('DOMContentLoaded', function () {+          if (el._aw) load();+        }, { once: true });+      } else {+        load();+      }+    };++    Widget.prototype.disconnectedCallback = function () {+      if (this._aw) { this._aw.destroy(); this._aw = null; }+    };++    global.customElements.define(tag, Widget);+    return Widget;+  }++  global.AdomWidget = {+    version: VERSION,+    define: define,+    host: host,+    store: store,+    bus: bus,+    backend: function (origin) { return new Backend(origin); },+    lightbox: bridge.lightbox,+    reportHeight: bridge.reportHeight,+    esc: esc,+    theme: THEME+  };+}(typeof window !== 'undefined' ? window : this));+ /*!  * <adom-table> — render a JSON array as a filterable, sortable table.  *