master
Colby Knox Widgets: safe to render inline in a wiki README (adom/wiki#229) 4408302 7d ago
/*!
 * <adom-prompt> - a paste-ready AI prompt with a copy button, for any wiki page.
 *
 * Drop-in (wiki README):
 *   ::adom-prompt{src="/OWNER/SLUG/render/docs/prompt.txt" heading="Paste into Hydrogen"}
 *
 * - src: a plain-text file (or JSON {"text": "..."}) on the wiki, usually the page's own render route.
 *   Keeping the prompt in a file means the README shows it once and an AI can read the same file.
 * - text: the prompt inline instead of src (short prompts only: tag attributes are length-capped).
 * - heading: optional label over the prompt (default "AI prompt").
 *
 * Copy uses navigator.clipboard, falling back to a selected textarea + execCommand('copy') because the wiki
 * frame is a sandboxed opaque origin where the async clipboard API can be refused. If both fail the text is
 * left selected so Ctrl+C works, and the button says so.
 */
(() => {
  const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
  const ICON_COPY = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
  const ICON_OK = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';

  class AdomPrompt extends HTMLElement {
    connectedCallback() {
      if (this._init) return; this._init = true;
      this.attachShadow({ mode: 'open' });
      // 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();
      else if (!this._text) this._render('No prompt: give me src or text');
    }
    async _fetch() {
      try {
        const res = await fetch(new URL(this.getAttribute('src'), location.href).href);
        if (!res.ok) throw new Error('could not load the prompt (' + res.status + ')');
        const body = (await res.text()).trim();
        let t = body;
        if (body.startsWith('{')) { try { t = JSON.parse(body).text || body; } catch (e) {} }
        this._text = t.replace(/\r\n/g, '\n');
        this._render();
      } catch (e) { this._render(String(e && e.message || e).slice(0, 160)); }
    }
    _render(note) {
      const h = this.getAttribute('heading') || 'AI prompt';
      this.shadowRoot.innerHTML = `<style>
        :host { display: block; font: 13px/1.5 Satoshi, -apple-system, 'Segoe UI', sans-serif; color: #e6edf3; }
        .card { background: #0d1117; border: 1px solid #30363d; border-radius: 10px; overflow: hidden; }
        .bar { display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 14px; border-bottom: 1px solid #30363d; }
        .h { flex: 1; font: 600 13px/1.3 'Familjen Grotesk', -apple-system, sans-serif; color: #8b949e; letter-spacing: .02em; }
        button { display: inline-flex; align-items: center; gap: 6px; font: 600 12px/1 inherit; padding: 7px 11px; border-radius: 8px;
          border: 1px solid #00b8b1; background: transparent; color: #00b8b1; cursor: pointer; transition: background .12s ease, color .12s ease, transform .06s ease; }
        button:hover { background: #00b8b1; color: #0d1117; }
        button:active { transform: translateY(1px) scale(.98); }
        button:focus-visible { outline: 2px solid #00b8b1; outline-offset: 3px; }
        button.ok { background: #00b8b1; color: #0d1117; }
        pre { margin: 0; padding: 14px; white-space: pre-wrap; word-break: break-word; font: 13px/1.55 'JetBrains Mono', ui-monospace, Consolas, monospace; color: #e6edf3; }
        .note { padding: 14px; color: #8b949e; }
        textarea { position: absolute; left: -9999px; top: 0; opacity: 0; }
      </style>
      <div class="card"><div class="bar"><span class="h">${esc(h)}</span>${note ? '' : `<button id="cp" type="button" title="Copy the prompt">${ICON_COPY}<span>Copy</span></button>`}</div>
      ${note ? `<div class="note">${esc(note)}</div>` : `<pre id="t">${esc(this._text)}</pre>`}</div>`;
      const b = this.shadowRoot.getElementById('cp');
      if (b) b.addEventListener('click', () => this.copy());
    }
    async copy() {
      const b = this.shadowRoot.getElementById('cp');
      let ok = false;
      try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(this._text); ok = true; } } catch (e) {}
      if (!ok) {
        const ta = document.createElement('textarea'); ta.value = this._text; this.shadowRoot.appendChild(ta); ta.select();
        try { ok = document.execCommand('copy'); } catch (e) {}
        ta.remove();
      }
      if (!ok) {   // leave the prompt selected so Ctrl+C still works
        const r = document.createRange(); r.selectNodeContents(this.shadowRoot.getElementById('t'));
        const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
      }
      if (b) {
        b.classList.toggle('ok', ok);
        b.innerHTML = ok ? `${ICON_OK}<span>Copied</span>` : `${ICON_COPY}<span>Press Ctrl+C</span>`;
        clearTimeout(this._t); this._t = setTimeout(() => { b.classList.remove('ok'); b.innerHTML = `${ICON_COPY}<span>Copy</span>`; }, 1800);
      }
      this.dispatchEvent(new CustomEvent('promptcopy', { detail: { ok, length: this._text.length }, bubbles: true, composed: true }));
      return ok;
    }
    // for AIs and tests: what the widget is showing
    selfCheck() { return { src: this.getAttribute('src'), length: (this._text || '').length, text: this._text }; }
  }
  if (!customElements.get('adom-prompt')) customElements.define('adom-prompt', AdomPrompt);
})();