import { json } from '@codemirror/lang-json' import { BrowserOAuthClient } from '@atproto/oauth-client-browser' import { basicSetup, EditorView } from 'codemirror' import { pageClientId } from '@publicdomainrelay/atlibs/oauth/index.js' import * as lib from './lib.js' const SCOPE = `atproto repo:${lib.COMPOSITION} repo:${lib.BINDING}` const $ = (s) => document.querySelector(s) const pretty = (v) => JSON.stringify(v, null, '\t') const state = { opened: null, session: null } // opened: the published record the editor started from const editor = new EditorView({ parent: $('#editor'), extensions: [basicSetup, json(), EditorView.updateListener.of((u) => u.docChanged && schedule())] }) const text = () => editor.state.doc.toString() const setText = (t) => editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: t } }) const base = () => state.opened?.did ?? state.session?.did const el = (tag, props, ...children) => { const node = Object.assign(document.createElement(tag), props) node.append(...children) return node } function table(title, rows) { if (!rows.length) return [] const cols = Object.keys(rows[0]).filter((c) => c !== 'added') return [ el('h3', { textContent: title }), el('table', {}, el('tr', {}, ...cols.map((c) => el('th', { textContent: c }))), ...rows.map((r) => el('tr', { className: r.added ? 'added' : '', title: r.added ? 'added at load (a hook default)' : '' }, ...cols.map((c) => el('td', { textContent: String(r[c]) }))))), ] } const draw = (s) => $('#summary').replaceChildren( ...table('Instances', s.instances), ...table('Edges', s.edges), ...table('Hooks', s.hooks), ...table('Parameters', s.parameters), ...table('Exports', s.exports), ...(s.scope ? [el('h3', { textContent: 'Session scopes' }), el('code', { textContent: s.scope })] : []), ) let timer let run = 0 function schedule() { clearTimeout(timer) timer = setTimeout(validate, 300) } async function validate() { const mine = ++run const status = $('#status') status.className = '' status.textContent = 'checking…' try { const manifest = JSON.parse(text()) const summary = await lib.summarize(await lib.check(manifest, base()), manifest) if (mine !== run) return status.textContent = 'valid' draw(summary) } catch (e) { if (mine !== run) return status.className = 'error' status.textContent = e.message } } const say = (message, error = false) => Object.assign($('#out'), { textContent: message, className: error ? 'error' : '' }) const guard = (fn) => (e) => { e.preventDefault() return Promise.resolve(fn(new FormData(e.target))).catch((error) => say(error.message, true)) } function opened(record) { state.opened = record say('') $('#opened').textContent = '' if (!record) return lib.label(record.uri).then((l) => state.opened === record && ($('#opened').textContent = `${l} @ ${record.cid}`)) $('#publish [name=rkey]').value = record.rkey $('#bind [name=inputs]').value = pretty(lib.defaults(record.value)) } async function open(ref) { const record = await lib.openComposition(ref) opened(record) setText(pretty(record.value)) // keeps a #fragment: an OAuth callback's, for client.init() history.replaceState(null, '', `?ref=${encodeURIComponent(record.uri)}${location.hash}`) } async function example() { opened(null) setText(await (await fetch(new URL('example.json', import.meta.url))).text()) // only a ?ref: an OAuth callback's #code must stay for client.init() if (location.search) history.replaceState(null, '', location.pathname) } $('#open').onsubmit = guard((f) => open(f.get('ref'))) $('#example').onclick = () => example().catch((e) => say(e.message, true)) $('#add').onsubmit = guard(async (f) => { const { uri, pin } = await lib.moduleRef(f.get('ref')) const manifest = JSON.parse(text()) const names = new Set(manifest.instances?.map((i) => i.name)) let name = f.get('name') || uri.split('/').pop() for (let n = 2; names.has(name); n++) name = `${f.get('name') || uri.split('/').pop()}-${n}` manifest.instances = [...(manifest.instances ?? []), { name, module: uri, pin }] setText(pretty(manifest)) }) const signedIn = () => state.session ?? Promise.reject(new Error('sign in first')) $('#publish').onsubmit = guard(async (f) => { const session = await signedIn() const rkey = f.get('rkey') let manifest = JSON.parse(text()) if (base() !== session.did) { manifest = lib.withAbsoluteModules(manifest, base()) setText(pretty(manifest)) } await lib.check(manifest, session.did) const own = state.opened?.did === session.did && state.opened.rkey === rkey const { uri, cid } = await lib.putRecord(session, lib.COMPOSITION, rkey, manifest, own ? state.opened.cid : undefined) opened({ did: session.did, rkey, uri, cid, value: manifest }) history.replaceState(null, '', `?ref=${encodeURIComponent(uri)}`) say(`published ${await lib.label(uri)} @ ${cid}`) }) $('#bind').onsubmit = guard(async (f) => { const session = await signedIn() if (!state.opened) throw new Error('open or publish a composition first: a binding pins a published one') const record = lib.binding(state.opened, JSON.parse(f.get('inputs') || '{}')) const rkey = await lib.repoKey(session.did, f.get('repo')) const { uri } = await lib.putRecord(session, lib.BINDING, rkey, record) say(`bound: ${await lib.label(uri)} → ${await lib.label(state.opened.uri)} @ ${state.opened.cid}`) }) // The ref open when sign-in left (STASH), back after the callback. const STASH = 'atlibs-editor.ref' const stashed = sessionStorage.getItem(STASH) sessionStorage.removeItem(STASH) const ref = new URLSearchParams(location.search).get('ref') ?? stashed await (ref ? open(ref) : example()).catch((e) => say(e.message, true)) // Sign-in last: a page that cannot reach the OAuth client still opens and checks compositions. try { const client = await BrowserOAuthClient.load({ clientId: await pageClientId(SCOPE), handleResolver: 'https://bsky.social' }) state.session = (await client.init())?.session ?? null $('#login').hidden = Boolean(state.session) $('#who').hidden = !state.session $('#who span').textContent = state.session?.did ?? '' $('#login').onsubmit = guard((f) => { if (state.opened) sessionStorage.setItem(STASH, state.opened.uri) return client.signIn(f.get('handle')) }) $('#logout').onclick = () => state.session.signOut().then(() => location.reload()) if (state.session) schedule() } catch (e) { say(`sign-in unavailable: ${e.message}`, true) }