Multi-user Wiki Template
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
Section titled “Technical design”This wiki uses a frontend to virtually construct pages: /index.html is the app shell, declared as the manifest fallback, so every page path (which has no real file in this drive) serves the shell. 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 /nomad-wiki/{wiki-drive-key}/. When a page is visited at some path, the wiki’s frontend runs the following query:
/users/*/nomad-wiki/{wiki-drive-key}/{path}The matching file with the highest mtime is then chosen for rendering.
Source
Section titled “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.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>{ "title": "My Wiki", "description": "A wiki written collaboratively by its authors.", "fallback": "/index.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 nomad.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 nomad.fs.mkdir('/users').catch(e => undefined) await nomad.fs.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 nomad.fs.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 nomad.fs.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 nomad.fs.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 nomad.fs.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 = nomad.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.hostnameconst ROOT_PATH = (user) => `/users/${user}/nomad-wiki/${WIKI_KEY}/`const QUERY_ROOT_PATH = `/users/*/nomad-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]/gconst endingDashes = /([-]+$)/gexport function slugify (str = '') { return str.replace(reservedChars, '-').replace(endingDashes, '')}
export async function getSiteMeta () { var [info, authors] = await Promise.all([ nomad.fs.getInfo(), nomad.fs.readdir('/users', {includeStats: true}).catch(e => ([])) ]) var userIsAuthor = false for (let author of authors) { if (!author.stat.mount?.key) continue let info = await nomad.fs.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 nomad.fs.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('nomad-wiki', WIKI_KEY, path) await nomad.fs.drive(author.key).writeFile(filePath, content, {ensureParent: true})}MIT License
Copyright (c) 2020 Blue Link Labs
Permission is hereby granted, free of charge, to any person obtaining a copyof this software and associated documentation files (the "Software"), to dealin the Software without restriction, including without limitation the rightsto use, copy, modify, merge, publish, distribute, sublicense, and/or sellcopies of the Software, and to permit persons to whom the Software isfurnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in allcopies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.