master
Colby Knox Widgets: safe to render inline in a wiki README (adom/wiki#229) 4408302 7d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
/*! 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; }
    // Inline in a wiki README the document is the wiki page, which is itself framed in a Hydrogen webview:
    // that parent is not this widget's host. The sandboxed widget frame is /OWNER/SLUG/widget/NAME.
    var wikiInline = !/^\/[^/]+\/[^/]+\/widget\/[^/]+\/?$/.test(location.pathname) && !!document.querySelector('.adom-widget[data-widget]');
    if (wikiInline) framed = false;
    // 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 () {
        // A custom element upgrades when its OPENING tag is parsed, so its children do not
        // exist yet. An inline <script type="application/json"> child is therefore invisible
        // at this moment, readData falls through to src=, and the fetch is committed to before
        // the data that was sitting right there ever appears.
        //
        // That is not theoretical: the wiki's ::tag widget frame emits BOTH an inlined data
        // child (so a PRIVATE page's data reaches an opaque-origin frame at all) and the
        // author's src=. The src won the race, the fetch 404'd from the opaque origin, and the
        // table rendered empty - the exact failure the inlining exists to prevent.
        // Measured on the real frame: with src= 0 rows, with the attribute removed 293.
        //
        // So when the parser is still running and we are about to choose src BECAUSE no inline
        // child is visible yet, wait for parsing to finish and look again. Nothing else is
        // delayed: data already present still renders immediately.
        if (document.readyState === 'loading'
            && el.getAttribute('src')
            && !el.querySelector('script[type="application/json"]')) {
          document.addEventListener('DOMContentLoaded', load, { once: true });
          return;
        }
        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.
 *
 * Written because the same table has now been hand-built three times as a bespoke section
 * frame (adom/stm32f103c8t6's pinout, adom/repo-ownership's ownership list, and the
 * footprint variant tables). Each copy re-implements filtering, sorting, height reporting
 * and theming, and each one drifts. This is that frame, once, on the runtime.
 *
 * Data (resolved by the runtime, in its own order of preference):
 *   1. an inline <script type="application/json"> child   <- preferred on a wiki README,
 *      because the readme frame is an opaque origin and a fetch from there is blocked
 *   2. src="/api/pages/<owner>/<slug>/files/<file>.json"  <- works in a SECTION frame
 *   3. el.data = [...]                                     <- dashboards / adom-lbr
 *
 * Accepted shapes:
 *   [ {col: value, ...}, ... ]                     columns inferred from the first row
 *   { columns: [...], rows: [ {...}, ... ] }       explicit column order
 *   { columns: [{key,label,align}], rows: [...] }  explicit labels
 *
 * Attributes:
 *   columns="a,b,c"   restrict and order columns (overrides the data's own order)
 *   heading="..."     caption above the table; {key} interpolates a top-level field of the
 *                     data envelope, so heading="Regenerated {generated}" keeps a freshness
 *                     stamp on the page without the host frame having to fetch the file
 *                     itself just to print one line
 *   filter            show the filter box (default on; filter="off" hides it)
 *   facet="owner"     add a dropdown that filters on one column's distinct values
 *   sort="col"        initial sort column; prefix "-" for descending
 *   dim="key:value"   render rows matching key=value at reduced opacity (e.g. archived)
 *   colorby="col:field"  colour the text (and a leading dot) of `col` using the CSS colour
 *                     carried in each row's `field`. The colour comes from the DATA, not from
 *                     a palette in here, so a page and its other renderings (an svg export,
 *                     say) cannot drift into disagreeing about who is which colour. Only
 *                     #rgb / #rrggbb is accepted; anything else is dropped rather than
 *                     written into a style attribute.
 *   mark="style:key:re[; ...]"  colour cells whose value matches a pattern, e.g.
 *                     mark="warn:rule:PR ONLY|ASK; accent:rule:^merge:"
 *                     styles: warn (red) accent (blue) ok (green). The cell text is still
 *                     HTML-escaped - only a class is added - so this cannot inject markup.
 *                     Added because escaping every cell flattened adom/repo-ownership's
 *                     "PR ONLY" stop-signal into ordinary grey text, which on a guardrail
 *                     page is the one thing the reader must not miss.
 */
(function () {
  'use strict';
  if (!window.AdomWidget) return;
  var esc = window.AdomWidget.esc;

  function normalise(data, attrColumns) {
    var rows = [], cols = null;
    if (Array.isArray(data)) rows = data;
    else if (data && Array.isArray(data.rows)) { rows = data.rows; cols = data.columns || null; }
    else if (data && typeof data === 'object') {
      // a bare object of arrays, e.g. {live:[...], dormant:[...]} - concatenate and tag
      var keys = Object.keys(data).filter(function (k) { return Array.isArray(data[k]); });
      if (keys.length) {
        keys.forEach(function (k) {
          data[k].forEach(function (r) { rows.push(Object.assign({ _group: k }, r)); });
        });
      }
    }
    if (attrColumns) cols = attrColumns.split(',').map(function (s) { return s.trim(); });
    if (!cols) cols = rows.length ? Object.keys(rows[0]).filter(function (k) { return k[0] !== '_'; }) : [];
    cols = cols.map(function (c) {
      return typeof c === 'string' ? { key: c, label: c.replace(/[_-]/g, ' ').toUpperCase() } : c;
    });
    return { cols: cols, rows: rows };
  }

  window.AdomWidget.define('adom-table', {
    attributes: ['columns', 'heading', 'filter', 'facet', 'sort', 'dim', 'mark', 'colorby'],
    loadingText: 'Loading table',
    emptyText: 'No data supplied to <adom-table>',
    css: [
      '.bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:0 0 9px}',
      '.bar input,.bar select{background:var(--bg,#0d1117);color:inherit;border:1px solid var(--line,#30363d);',
      'border-radius:6px;padding:6px 9px;font:inherit}',
      '.bar input{min-width:200px}',
      '.muted{opacity:.62;font-size:12px}',
      'table{border-collapse:collapse;width:100%;font-size:13px}',
      'th{text-align:left;font-size:10.5px;letter-spacing:.7px;opacity:.6;padding:6px 8px;',
      'border-bottom:1px solid var(--line,#30363d);cursor:pointer;user-select:none;white-space:nowrap}',
      'th[aria-sort]:after{content:"";opacity:.8}',
      'th[aria-sort="ascending"]:after{content:" \\2191"}',
      'th[aria-sort="descending"]:after{content:" \\2193"}',
      'td{padding:5px 8px;border-bottom:1px solid var(--line2,#161b22);vertical-align:top}',
      'tr.dim td{opacity:.45}',
      'td .cdot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px;',
      'vertical-align:baseline;flex:none}',
      'td.mk-warn{color:var(--aw-red,#f85149);font-weight:600}',
      'td.mk-accent{color:var(--aw-blue,#58a6ff);font-weight:600}',
      'td.mk-ok{color:var(--aw-green,#3fb950);font-weight:600}',
      'caption{text-align:left;font-weight:600;padding:0 0 8px}'
    ].join(''),
    render: function (data, ctx) {
      var n = normalise(data, this.getAttribute('columns'));
      this._n = n;
      var heading = this.getAttribute('heading');
      // Substitute BEFORE escaping, and only from the data envelope's own top-level scalars,
      // so an unknown {token} is left visible as written rather than silently vanishing.
      if (heading && data && !Array.isArray(data)) {
        heading = heading.replace(/\{(\w+)\}/g, function (m, k) {
          var v = data[k];
          return (v == null || typeof v === 'object') ? m : String(v);
        });
      }
      var showFilter = this.getAttribute('filter') !== 'off';
      var facet = this.getAttribute('facet');
      var bar = '';
      if (showFilter || facet) {
        bar = '<div class=bar>' +
          (showFilter ? '<input part=filter type=search placeholder="filter…">' : '') +
          (facet ? '<select part=facet><option value="">all ' + esc(facet) + '</option></select>' : '') +
          '<span class=muted data-count></span></div>';
      }
      return bar + '<table>' +
        (heading ? '<caption>' + esc(heading) + '</caption>' : '') +
        '<thead><tr>' + n.cols.map(function (c) {
          return '<th data-k="' + esc(c.key) + '">' + esc(c.label) + '</th>';
        }).join('') + '</tr></thead><tbody></tbody></table>';
    },
    mounted: function (root) {
      var el = this, n = el._n, tb = root.querySelector('tbody');
      var q = root.querySelector('input'), sel = root.querySelector('select');
      var count = root.querySelector('[data-count]');
      var dim = (el.getAttribute('dim') || '').split(':');
      // A malformed rule is dropped, never thrown: one bad pattern in an attribute must not
      // blank the whole table, which is the failure mode a guardrail page can least afford.
      // colorby="col:field" - never interpolate a raw cell value into a style attribute;
      // only a literal hex colour is allowed through.
      var cb = (el.getAttribute('colorby') || '').split(':');
      var colorCol = cb.length === 2 ? cb[0] : null, colorField = cb.length === 2 ? cb[1] : null;
      function safeColor(v) {
        return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(String(v || '')) ? String(v) : null;
      }
      var marks = (el.getAttribute('mark') || '').split(';').map(function (rule) {
        var m = rule.trim().match(/^(warn|accent|ok):([^:]+):([\s\S]+)$/);
        if (!m) return null;
        try { return { style: m[1], key: m[2], re: new RegExp(m[3]) }; } catch (e) { return null; }
      }).filter(Boolean);
      var s = (el.getAttribute('sort') || '').replace(/^-/, '');
      var dir = (el.getAttribute('sort') || '')[0] === '-' ? -1 : 1;

      if (sel) {
        var f = el.getAttribute('facet');
        var vals = {};
        n.rows.forEach(function (r) { if (r[f] != null) vals[r[f]] = 1; });
        Object.keys(vals).sort().forEach(function (v) {
          var o = document.createElement('option'); o.textContent = v; sel.appendChild(o);
        });
      }
      function draw() {
        var term = (q && q.value || '').toLowerCase();
        var fv = sel && sel.value;
        var f = el.getAttribute('facet');
        var out = n.rows.filter(function (r) {
          if (fv && String(r[f]) !== fv) return false;
          if (!term) return true;
          return n.cols.some(function (c) {
            return String(r[c.key] == null ? '' : r[c.key]).toLowerCase().indexOf(term) >= 0;
          });
        });
        if (s) out = out.slice().sort(function (a, b) {
          var A = a[s], B = b[s];
          if (typeof A === 'number' && typeof B === 'number') return (A - B) * dir;
          return String(A).localeCompare(String(B)) * dir;
        });
        tb.innerHTML = out.map(function (r) {
          var isDim = dim.length === 2 && String(r[dim[0]]) === dim[1];
          return '<tr' + (isDim ? ' class=dim' : '') + '>' + n.cols.map(function (c) {
            var v = r[c.key] == null ? '' : r[c.key], cls = '';
            for (var i = 0; i < marks.length; i++) {
              if (marks[i].key === c.key && marks[i].re.test(String(v))) {
                cls = ' class="mk-' + marks[i].style + '"'; break;
              }
            }
            var col = (colorCol && c.key === colorCol) ? safeColor(r[colorField]) : null;
            if (col) {
              return '<td' + cls + ' style="color:' + col + '">' +
                     '<span class=cdot style="background:' + col + '"></span>' + esc(v) + '</td>';
            }
            return '<td' + cls + '>' + esc(v) + '</td>';
          }).join('') + '</tr>';
        }).join('');
        if (count) count.textContent = out.length + ' of ' + n.rows.length;
        root.querySelectorAll('th').forEach(function (th) {
          if (th.dataset.k === s) th.setAttribute('aria-sort', dir > 0 ? 'ascending' : 'descending');
          else th.removeAttribute('aria-sort');
        });
      }
      root.querySelectorAll('th').forEach(function (th) {
        th.addEventListener('click', function () {
          dir = (s === th.dataset.k) ? -dir : 1; s = th.dataset.k; draw();
        });
      });
      if (q) q.addEventListener('input', draw);
      if (sel) sel.addEventListener('change', draw);
      draw();
    }
  });
}());