This wiki is a minimal collaborative website. It maintains a list of authors (selected by the wiki owner) who set the content of the wiki.

Technical design

This wiki uses a frontend to virtually construct pages. As a result, when you visit a page, you are viewing a constructed result rather than a file that lives on this drive.

Each author maintains a folder under /beaker-wiki/{wiki-drive-key}/. When a page is visited at some path, the wiki’s frontend runs the following query:

/users/*/beaker-wiki/{wiki-drive-key}/{path}

The matching file with the highest mtime is then chosen for rendering.

Source

<!doctype html>
<html>
  <head>
    <link rel="icon" type="image/png" sizes="32x32" href="/thumb">
  </head>
  <body>
    <page-controls></page-controls>
    <page-content></page-content>
  </body>
  <script type="module" src="/.ui/ui.js"></script>
  <style>
    body {
      max-width: 800px;
      margin: 8px auto;
    }
    main {
      padding: 0 2px;
      min-height: calc(100vh - 92px);
    }
    main.loading:before {
      content: 'Loading...';
      display: block;
      padding: 20px 10px;
    }
    textarea.editor {
      width: 100%;
      min-height: calc(100vh - 130px);
      outline: 0;
      box-sizing: border-box;
      padding: 6px;
      font-size: 15px;
    }
    footer {
      font-size: 85%;
      padding: 10px;
      border-top: 1px solid #bbb;
    }
  </style>
</html>
import { getSiteMeta, queryForPage, writeFile, slugify } from './util.js'

const EDIT_MODE = location.search.includes('?edit')

customElements.define('page-controls', class extends HTMLElement {
  async connectedCallback () {
    var siteMeta = this.siteMeta = await getSiteMeta()
    console.debug('Site meta:', siteMeta)

    if (EDIT_MODE) {
      this.append(
        h('button', {click: this.onSaveChanges.bind(this)}, 'Save'),
        ' ',
        h('button', {click: this.onDiscardChanges.bind(this)}, 'Discard changes'),
      )
    } else {
      if (siteMeta.userIsAuthor) {
        this.append(
          h('button', {click: this.onEdit.bind(this)}, 'Edit page'),
          ' ',
          h('button', {click: this.onDelete.bind(this)}, 'Delete page'),
        )
      }
      this.append(
        ' Authors: ',
        h('select',
          ...siteMeta.authors.map(a => h('option', {value: a.name}, a.name))
        )
      )
      if (siteMeta.userIsAdmin) {
        this.append(
          ' ',
          h('button', {click: e => this.onRemoveAuthor(e)}, 'Remove author'),
          ' ',
          h('button', {click: e => this.onAddAuthor(e)}, 'Add author')
        )
      }
    }
  }

  async onEdit (e) {
    location.search = '?edit'
  }

  async onSaveChanges (e) {
    var author = this.siteMeta.authors.find(a => a.writable)
    if (!author) {
      return alert('You are not an author')
    }
    var value = document.querySelector('textarea.editor').value
    await writeFile(author, location.pathname, value)
    location.search = ''
  }

  onDiscardChanges (e) {
    if (!confirm('Discard changes?')) {
      return
    }
    location.search = ''
  }

  async onDelete (e) {
    if (!confirm('Delete page?')) {
      return
    }
    var author = this.siteMeta.authors.find(a => a.writable)
    if (!author) {
      return alert('You are not an author')
    }
    await writeFile(author, location.pathname, '')
    location.reload()
  }

  async onAddAuthor (e) {
    var contact = await beaker.contacts.requestContact()
    var key = contact.url.slice('hyper://'.length)
    if (this.siteMeta.authors.find(a => a.key === key)) {
      alert('That user is already an author')
      return
    }
    var name = slugify(contact.title)
    do {
      name = prompt('Select a username for this author', name)
      if (name.includes('/')) {
        alert('Please dont include slashes in the username')
        continue
      }
      if (this.siteMeta.authors.find(a => a.name === name)) {
        alert('That username is already taken')
        continue
      }
      break
    } while(true)
    await beaker.hyperdrive.mkdir('/users').catch(e => undefined)
    await beaker.hyperdrive.mount(`/users/${name}`, contact.url)
    location.reload()
  }

  async onRemoveAuthor (e) {
    var name = this.querySelector('select').value
    if (!name) return
    if (!confirm(`Remove ${name} from authors?`)) {
      return
    }
    await beaker.hyperdrive.unmount(`/users/${name}`)
    location.reload()
  }
})

customElements.define('page-content', class extends HTMLElement {
  constructor () {
    super()
    this.render()
  }

  async render () {
    var main = h('main', {class: 'loading'})
    this.append(main)

    var file = await queryForPage(location.pathname)

    if (EDIT_MODE) {
      let content = file ? await beaker.hyperdrive.readFile(file.path).catch(e => '') : ''
      main.append(h('h1', `Editing ${location.pathname}`))
      main.append(h('textarea', {class: 'editor'}, content))
      main.classList.remove('loading')
      return
    }

    console.debug('Page found:', file)
    if (!file) {
      let st = await beaker.hyperdrive.stat('/users').catch(e => undefined)
      if (!st) {
        // no authors yet
        main.classList.remove('loading')
        main.append(h('h2', 'Welcome to your Wiki'))
        main.append(h('p', 'To get started, add yourself as an author. You will then be able to edit pages.'))
        return
      }

      // 404
      main.classList.remove('loading')
      main.append(h('h2', '404 Not Found'))
      return
    }

    // embed content
    if (/\.(png|jpe?g|gif|svg)$/i.test(file.path)) {
      main.append(h('img', {src: file.path}))
    } else if (/\.(mp4|webm|mov)/i.test(file.path)) {
      main.append(h('video', {controls: true}, h('source', {src: file.path})))
    } else if (/\.(mp3|ogg)/i.test(file.path)) {
      main.append(h('audio', {controls: true}, h('source', {src: file.path})))
    } else {
      let content = await beaker.hyperdrive.readFile(file.path)
      // treat empty file as a tombstone
      if (!content) {
        main.classList.remove('loading')
        main.append(h('h2', '404 Not Found'))
        return
      }
      // render content
      if (/.md$/i.test(file.path)) {
        main.innerHTML = beaker.markdown.toHTML(content)
      } else {
        main.append(h('pre', content))
      }
    }
    main.classList.remove('loading')

    // render metadata
    var author = file.path.split('/')[2]
    var mtime = (new Date(file.stat.mtime)).toLocaleString()
    this.append(h('footer', `Last updated by ${author} at ${mtime}`))
  }
})

function h (tag, attrs, ...children) {
  var el = document.createElement(tag)
  if (isPlainObject(attrs)) {
    for (let k in attrs) {
      if (typeof attrs[k] === 'function') el.addEventListener(k, attrs[k])
      else el.setAttribute(k, attrs[k])
    }
  } else if (attrs) {
    children = [attrs].concat(children)
  }
  for (let child of children) el.append(child)
  return el
}

function isPlainObject (v) {
  return v && typeof v === 'object' && Object.prototype === v.__proto__
}
const WIKI_KEY = location.hostname
const ROOT_PATH = (user) => `/users/${user}/beaker-wiki/${WIKI_KEY}/`
const QUERY_ROOT_PATH = `/users/*/beaker-wiki/${WIKI_KEY}/`

export function joinPath (...args) {
  var str = args[0]
  for (let v of args.slice(1)) {
    v = v && typeof v === 'string' ? v : ''
    let left = str.endsWith('/')
    let right = v.startsWith('/')
    if (left !== right) str += v
    else if (left) str += v.slice(1)
    else str += '/' + v
  }
  return str
}

const reservedChars = /[ <>:"/\\|?*\x00-\x1F]/g
const endingDashes = /([-]+$)/g
export function slugify (str = '') {
  return str.replace(reservedChars, '-').replace(endingDashes, '')
}

export async function getSiteMeta () {
  var [info, authors] = await Promise.all([
    beaker.hyperdrive.getInfo(),
    beaker.hyperdrive.readdir('/users', {includeStats: true}).catch(e => ([]))
  ])
  var userIsAuthor = false
  for (let author of authors) {
    if (!author.stat.mount?.key) continue
    let info = await beaker.hyperdrive.getInfo(author.stat.mount.key).catch(e => undefined)
    if (info?.writable) {
      author.writable = true
      userIsAuthor = true
    }
  }
  return {
    authors: authors.map(a => ({name: a.name, key: a.stat.mount?.key, writable: !!a.writable})),
    userIsAdmin: info.writable,
    userIsAuthor
  }
}

export async function queryForPage (path) {
  if (path.endsWith('/')) {
    path = joinPath(path, 'index.md')
  }
  console.debug('Querying', joinPath(QUERY_ROOT_PATH, path))
  var files = await beaker.hyperdrive.query({
    path: joinPath(QUERY_ROOT_PATH, path),
    sort: 'mtime',
    reverse: true
  })
  return files[0]
}

export async function writeFile (author, path, content) {
  if (path.endsWith('/')) {
    path = joinPath(path, 'index.md')
  }
  console.log('writing to', joinPath(ROOT_PATH(author.name), path))
  var filePath = joinPath('beaker-wiki', WIKI_KEY, path)
  await beaker.hyperdrive.drive(author.key).writeFile(filePath, content, {ensureParent: true})
}