← Commit history

Widgets: safe to render inline in a wiki README (adom/wiki#229)

Colby Knox ·4408302d9a ·7d ago ·parent 36b04ee
Frame-only code (parent postMessage, page overflow/margin, adom-3d's Blob/storage shims and head style) now runs only in the sandboxed widget frame route (/OWNER/SLUG/widget/NAME), not when the wiki page itself is framed in Hydrogen. Inline, adom-3d embeds the wiki's same-origin /viewer/3d route instead of running Babylon in the page. pinout, footprint-variants, heatsink-via and molecule follow a host page's pinned color-scheme (the wiki is dark-only). Viewer bundle imports are root-relative so staging works. No behavior change in the frame route.
9 files changed +63−10
widgets/adom-table.js+4
@@ -35,6 +35,10 @@   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; }
widgets/chip-thumbnail-widget.js+3−1
@@ -5,7 +5,7 @@ // BEGIN bundled opt-in runtime (generated from component-lod-preview.js) // Opt-in LOD inspection, built on the canonical Adom Babylon viewer. // No module, engine or model is loaded until the user enables this preview.-const VIEWER='https://wiki.adom.inc/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js?v=20260828a';+const VIEWER='/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js?v=20260828a'; async function loadAssetBytes(info,resolveAsset,signal) {  if(info.transport?.kind!=='wiki-sandbox-frame@1'){   const r=await fetch(resolveAsset(info.path),{credentials:'include',signal});@@ -128,6 +128,8 @@ async function mountLodPreview({host,manifest,resolveAsset,onState,signal}) {         // Measure content, never the iframe viewport: scrollHeight + 2 can grow         // its parent's iframe repeatedly and trigger scrolling/layout feedback.         const height=Math.ceil(this.getBoundingClientRect().height)+2;+        // Only the wiki's sandboxed widget frame reports to its parent; inline, the parent may be a Hydrogen webview.+        if(!/^\/[^/]+\/[^/]+\/widget\/[^/]+\/?$/.test(location.pathname))return;         if(height!==this._lastHeight){this._lastHeight=height;parent.postMessage({type:'readme-height',height},'*');}         parent.postMessage({type:'adom-chip-thumbnail-state',state:this.selfCheck()},'*');       });
widgets/component-lod-preview.js+1−1
@@ -1,6 +1,6 @@ // Opt-in LOD inspection, built on the canonical Adom Babylon viewer. // No module, engine or model is loaded until the user enables this preview.-const VIEWER='https://wiki.adom.inc/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js?v=20260828a';+const VIEWER='/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js?v=20260828a'; async function loadAssetBytes(info,resolveAsset,signal) {  if(info.transport?.kind!=='wiki-sandbox-frame@1'){   const r=await fetch(resolveAsset(info.path),{credentials:'include',signal});
widgets/footprint-variants-widget.js+5−1
@@ -102,6 +102,9 @@     static parseMod = parseMod; // exposed for tests / structured consumers      async connectedCallback() {+      // A host page that pins its color-scheme (the wiki is dark-only) decides the theme; inline, prefers-color-scheme+      // would follow the reader's OS instead (a frame inherits the embedding page's scheme).+      if (!this.hasAttribute('theme')) { const cs = getComputedStyle(document.documentElement).colorScheme.trim(); if (/^(only )?(dark|light)( only)?$/.test(cs)) this.setAttribute('theme', cs.replace(/only|\s/g, '')); }       if (this._init) return; this._init = true;       this.attachShadow({ mode: 'open' });       this._mods = Object.create(null); // suffix -> parsed mod (or Promise)@@ -299,7 +302,8 @@     _emitBus(n) {       const m = { type: 'lbr-hover', pin: (n === '' || n == null) ? null : String(n), mpn: (this._data && this._data.part) || '', src: this._self };       try { this._bus && this._bus.postMessage(m); } catch (_) { /*noop*/ }-      try { if (window.parent && window.parent !== window) window.parent.postMessage(m, '*'); } catch (_) { /*noop*/ }+      // parent relay only from the wiki's sandboxed widget frame; inline, BroadcastChannel already reaches every widget+      try { if (/^\/[^/]+\/[^/]+\/widget\/[^/]+\/?$/.test(location.pathname)) window.parent.postMessage(m, '*'); } catch (_) { /*noop*/ }     }      _css() {
widgets/heatsink-via-widget.js+5−1
@@ -42,6 +42,9 @@     get data() { return this._data; }      async connectedCallback() {+      // A host page that pins its color-scheme (the wiki is dark-only) decides the theme; inline, prefers-color-scheme+      // would follow the reader's OS instead (a frame inherits the embedding page's scheme).+      if (!this.hasAttribute('theme')) { const cs = getComputedStyle(document.documentElement).colorScheme.trim(); if (/^(only )?(dark|light)( only)?$/.test(cs)) this.setAttribute('theme', cs.replace(/only|\s/g, '')); }       if (this._init) return; this._init = true;       this.attachShadow({ mode: 'open' });       this._busInit();@@ -65,7 +68,8 @@     _post(n) {       const m = { type: 'lbr-hover', pin: n == null ? null : String(n), mpn: (this._data && this._data.mpn) || '', src: this._self };       try { this._bus && this._bus.postMessage(m); } catch (_) { /*noop*/ }-      try { if (window.parent && window.parent !== window) window.parent.postMessage(m, '*'); } catch (_) { /*noop*/ }+      // parent relay only from the wiki's sandboxed widget frame; inline, BroadcastChannel already reaches every widget+      try { if (/^\/[^/]+\/[^/]+\/widget\/[^/]+\/?$/.test(location.pathname)) window.parent.postMessage(m, '*'); } catch (_) { /*noop*/ }     }      _render() {
widgets/model-3d-widget.js+34−3
@@ -14,9 +14,16 @@  * two). Unload, scrolling it well offscreen or leaving the page disposes the  * viewer and releases the WebGL context; Load 3D model brings it back. The model is fetched as bytes and handed to loadModelBytes, and the  * GLB's embedded textures are given out as data: URLs, because the frame's img-src forbids blob:.+ *+ * Inline in a wiki README (registry "render": "inline") the document is the wiki page itself, so none of the+ * frame's shims run: the widget embeds the wiki's own same-origin 3D viewer route instead+ * (/viewer/3d/OWNER/TYPE/SLUG?model=PATH), which carries the reader's session and needs no page CSP change.+ * Inline, src must be a file of a wiki page: /OWNER/SLUG/render/PATH.glb.  */ (() => {-  const VIEWER = 'https://wiki.adom.inc/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js';+  const VIEWER = '/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js';+  // the wiki's sandboxed widget frame (/OWNER/SLUG/widget/NAME); anywhere else this script runs inline in a page+  const FRAME = /^\/[^/]+\/[^/]+\/widget\/[^/]+\/?$/.test(location.pathname);   const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));    // The glTF loader turns embedded images into blob: URLs, which this frame's CSP blocks. Remember each Blob's@@ -54,10 +61,10 @@       if (this._init) return; this._init = true;       this.attachShadow({ mode: 'open' });       // in a wiki frame: no page margin, and nothing the viewer adds (its tip bubble) may give the frame a scrollbar-      if (window.self !== window.top) { document.documentElement.style.overflow = 'hidden'; document.body.style.margin = '0'; }+      if (FRAME) { document.documentElement.style.overflow = 'hidden'; document.body.style.margin = '0'; }       // the viewer shows the same Shift+Alt hint twice on first load (its pivot chip and its help tip; in a frame the       // "seen" flag cannot persist, so both return every time): keep the chip only as the live "keys held" indicator-      if (!document.getElementById('adom-3d-hint')) { const st = document.createElement('style'); st.id = 'adom-3d-hint';+      if (FRAME && !document.getElementById('adom-3d-hint')) { const st = document.createElement('style'); st.id = 'adom-3d-hint';         st.textContent = '.viewer-container:not(.pivot-mode) .pivot-chip { display: none !important; }'; document.head.appendChild(st); }       this._src = this.getAttribute('src');       this._poster = this.getAttribute('poster');@@ -89,6 +96,7 @@         .foot button { font-size: 12px; padding: 5px 10px; }         .err { color: #f85149; }         ::slotted([slot=v]) { position: absolute; inset: 0; }+        .stage iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; }       </style>       <div class="card">${h ? `<div class="h">${esc(h)}</div>` : ''}<div class="stage">${stage}</div>       <div class="foot"><span>${esc(c || '')}</span><span class="sp"></span>${foot || ''}</div></div>`;@@ -103,6 +111,7 @@     async _load() {       const go = this.shadowRoot.getElementById('go'); go.disabled = true; go.textContent = 'Loading...';       this._loading = true;+      if (!FRAME) return this._loadInline();       try {         shimStorage(); shimBlobUrls();         const res = await fetch(new URL(this._src, location.href).href);@@ -120,6 +129,28 @@         console.warn(e); this._unload(); this._idle(String(e && e.message || e).slice(0, 160));       } finally { this._loading = false; }     }+    // Inline: the wiki's same-origin viewer route renders the model (its page type comes from the page API).+    async _loadInline() {+      try {+        const u = new URL(this._src, location.href);+        const m = u.origin === location.origin && decodeURIComponent(u.pathname).match(/^\/([^/]+)\/([^/]+)\/render\/(.+\.glb)$/i);+        if (!m) throw new Error('src must be a .glb on a wiki page: /OWNER/SLUG/render/PATH.glb');+        const res = await fetch(`/api/pages/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`, { credentials: 'same-origin', headers: { Accept: 'application/json' } });+        if (!res.ok) throw new Error('could not find the page ' + m[1] + '/' + m[2] + ' (' + res.status + ')');+        const type = ((await res.json()).page || {}).type;+        if (!type) throw new Error('the page ' + m[1] + '/' + m[2] + ' has no type');+        // the viewer route's ?model= check addresses the repo by the internal type name (hardware pages are component repos)+        const view = `/viewer/3d/${encodeURIComponent(m[1])}/${encodeURIComponent(type === 'hardware' ? 'component' : type)}/${encodeURIComponent(m[2])}?model=${encodeURIComponent(m[3])}`;+        this._frame(`<iframe src="${esc(view)}" title="${esc(this.getAttribute('heading') || '3D model')}" allow="fullscreen"></iframe>`,+          '<button id="off" type="button">Unload</button>');+        this._viewer = { destroy() {} };+        this.shadowRoot.getElementById('off').addEventListener('click', () => this._unload());+        this.shadowRoot.querySelector('iframe').addEventListener('load', () =>+          this.dispatchEvent(new CustomEvent('modelready', { detail: { view }, bubbles: true, composed: true })), { once: true });+      } catch (e) {+        console.warn(e); this._unload(); this._idle(String(e && e.message || e).slice(0, 160));+      } finally { this._loading = false; }+    }     _unload(why) {       if (this._viewer) { try { this._viewer.destroy(); } catch (e) {} this._viewer = null; }       if (this._host) { this._host.remove(); this._host = null; }
widgets/molecule-widget.js+4−1
@@ -28,6 +28,9 @@     set data(d) { this._data = d; if (this.shadowRoot) this._render(); }     get data() { return this._data; }     async connectedCallback() {+      // A host page that pins its color-scheme (the wiki is dark-only) decides the theme; inline, prefers-color-scheme+      // would follow the reader's OS instead (a frame inherits the embedding page's scheme).+      if (!this.hasAttribute('theme')) { const cs = getComputedStyle(document.documentElement).colorScheme.trim(); if (/^(only )?(dark|light)( only)?$/.test(cs)) this.setAttribute('theme', cs.replace(/only|\s/g, '')); }       if (this._init) return; this._init = true;       this.attachShadow({ mode: 'open' });       this._self = 'm' + Math.random().toString(36).slice(2);@@ -84,7 +87,7 @@         p.onmouseleave = clear;       });     }-    _post(ref) { const m = { type: 'lbr-hover', ref: ref, mpn: (this._data && this._data.name) || '', src: this._self }; try { this._bus && this._bus.postMessage(m); } catch (_) { /* */ } try { if (window.parent !== window) window.parent.postMessage(m, '*'); } catch (_) { /* */ } }+    _post(ref) { const m = { type: 'lbr-hover', ref: ref, mpn: (this._data && this._data.name) || '', src: this._self }; try { this._bus && this._bus.postMessage(m); } catch (_) { /* */ } try { if (/^\/[^/]+\/[^/]+\/widget\/[^/]+\/?$/.test(location.pathname)) window.parent.postMessage(m, '*'); } catch (_) { /* */ } }      _css() {       return `
widgets/pinout-widget.js+5−1
@@ -97,6 +97,9 @@     get data() { return this._data; }      async connectedCallback() {+      // A host page that pins its color-scheme (the wiki is dark-only) decides the theme; inline, prefers-color-scheme+      // would follow the reader's OS instead (a frame inherits the embedding page's scheme).+      if (!this.hasAttribute('theme')) { const cs = getComputedStyle(document.documentElement).colorScheme.trim(); if (/^(only )?(dark|light)( only)?$/.test(cs)) this.setAttribute('theme', cs.replace(/only|\s/g, '')); }       if (this._init) return; this._init = true;       this.attachShadow({ mode: 'open' });       this._busInit();@@ -265,7 +268,8 @@     _emitBus(n) {       const m = { type: 'lbr-hover', pin: (n === '' || n == null) ? null : String(n), mpn: (this._data && this._data.mpn) || '', src: this._self };       try { this._bus && this._bus.postMessage(m); } catch (_) { /*noop*/ }-      try { if (window.parent && window.parent !== window) window.parent.postMessage(m, '*'); } catch (_) { /*noop*/ }+      // parent relay only from the wiki's sandboxed widget frame; inline, BroadcastChannel already reaches every widget+      try { if (/^\/[^/]+\/[^/]+\/widget\/[^/]+\/?$/.test(location.pathname)) window.parent.postMessage(m, '*'); } catch (_) { /*noop*/ }     }      _wire(pins) {
widgets/prompt-widget.js+2−1
@@ -22,7 +22,8 @@     connectedCallback() {       if (this._init) return; this._init = true;       this.attachShadow({ mode: 'open' });-      if (window.self !== window.top) { document.documentElement.style.overflow = 'hidden'; document.body.style.margin = '0'; }+      // only in the wiki's sandboxed widget frame (/OWNER/SLUG/widget/NAME); inline, the document is the wiki page+      if (/^\/[^/]+\/[^/]+\/widget\/[^/]+\/?$/.test(location.pathname)) { document.documentElement.style.overflow = 'hidden'; document.body.style.margin = '0'; }       this._text = this.getAttribute('text') || '';       this._render(this._text ? null : 'Loading prompt...');       if (!this._text && this.getAttribute('src')) this._fetch();