~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
160 lines · 6.3 KB Raw History
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)
}