A module with tools for a readme, tests suite, demo, and build tools. Also includes webterm page commands for running tests, build scripts, and more.

Source

export function hello (who = 'world') {
  return `hello ${who}`
}
# My Module

This module was created with Beaker
/**
  * You can define a build step here.
  * Run this build by calling `@run build` in the browser terminal.
  * 
  * You can add more scripts by adding them in the /scripts directory.
  */
 
export default function (opts = {}, ...args) {
  return 'No build step required'
}
/**
* Define your tests here.
* 
* Reference:
*  - Mocha Test Runner: https://mochajs.org/
*  - Chai Assertion Library: https://www.chaijs.com/
*/
import * as myModule from '../index.js'
describe('My Module', () => {
  describe('hello()', () => {
    it('should accept an audience string', () => {
      chai.assert.equal(myModule.hello('friends'), 'hello friends')
    })
    it('should default to "world"', () => {
      chai.assert.equal(myModule.hello(), 'hello world')
    })
  })
})
<link rel="stylesheet" href="./vendor/mocha.css" />
<script src="./vendor/chai.js"></script>
<script src="./vendor/mocha.js"></script>
<div id="mocha"></div>
<script>
  mocha.setup('bdd')
  mocha.checkLeaks()
</script>
<script type="module" src="./index.js"></script>
<script>
  mocha.run()
</script>
<h1>Demo</h1>
<div id="demo-container"></div>
<script type="module">
  import * as myModule from '../index.js'
  document.getElementById('demo-container').innerHTML = `
    <h2><code>hello('friends') => '${myModule.hello('friends')}'</code></h2>
  `
</script>
<!doctype html>
<meta charset="utf8">
<html>
  <head>
    <script type="module" src="/.ui/ui.js"></script>
    <link rel="stylesheet" href="/.ui/ui.css">
    <link rel="stylesheet" href="/.ui/vendor/highlight.css">
    <script src="/.ui/vendor/highlight.pack.js"></script>
  </head>
  <body>
    <nav>
      <module-header></module-header>
    </nav>
    <main>
      <module-page></module-page>
    </main>
  </body>
</html>
var infoPromise = beaker.hyperdrive.getInfo()

function h (tag, attrs, ...children) {
  var el = document.createElement(tag)
  for (let k in attrs) el.setAttribute(k, attrs[k])
  for (let child of children) el.append(child)
  return el
}

function formatBytes (bytes, decimals = 2) {
  if (bytes === 0) return ''
  const k = 1024
  const dm = decimals < 0 ? 0 : decimals
  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}

customElements.define('module-header', class extends HTMLElement {
  constructor () {
    super()
    this.render()
  }
  async render () {
    var [info, hasTests, hasDemo] = await Promise.all([
       infoPromise,
       beaker.hyperdrive.stat('/tests/index.html').catch(e => false),
       beaker.hyperdrive.stat('/demo/index.html').catch(e => false)
     ])

    this.append(h('h1', {}, h('a', {href: '/'}, info.title)))
    if (info.writable) {
      let editProps = h('a', {title: 'Edit Properties', href: '#'}, 'Edit')
      editProps.addEventListener('click', async (e) => {
        e.preventDefault()
        await beaker.shell.drivePropertiesDialog(location.toString())
        location.reload()
      })
      this.append(h('p', {}, `${info.description} [ `, editProps, ` ]`))
    } else {
      this.append(h('p', {}, info.description))
    }

    var buttons = []
    if (hasDemo) {
      buttons.push(h('a', {class: 'button', href: '/demo/', title: 'View Demo'}, 'View Demo'))
    }
    if (hasTests) {
      buttons.push(h('a', {class: 'button', href: '/tests/', title: 'Run Tests'}, 'Run Tests'))
    }
    this.append(h('div', {class: 'admin'}, ...buttons))
  }
})

class ModuleBreadcrumbs extends HTMLElement {
  constructor () {
    super()
    this.render()
  }
  async render () {
    var info = await infoPromise
    var parts = location.pathname.split('/').filter(Boolean)
    var acc = []
    this.append(h('a', {href: '/'}, info.title))
    this.append(h('span', {}, '/'))
    for (let part of parts) {
      acc.push(part)
      let href = '/' + acc.join('/')
      this.append(h('a', {href}, part))
      this.append(h('span', {}, '/'))
    }
  }
}
customElements.define('module-breadcrumbs', ModuleBreadcrumbs)

class ModuleDirectoryView extends HTMLElement {
  constructor () {
    super()
    this.render()
  }

  async render () {
    var entries = await beaker.hyperdrive.readdir(location.pathname, {includeStats: true})
    entries.sort((a, b) => {
      if (a.stat.isDirectory() && !b.stat.isDirectory()) return -1
      if (!a.stat.isDirectory() && b.stat.isDirectory()) return 1
      return a.name.localeCompare(b.name)
    })
    
    var listing = h('div', {class: 'listing'})
    for (let entry of entries) {
      let isDir = entry.stat.isDirectory()
      let isMount = !!entry.stat.mount
      let href = isMount ? `hyper://${entry.stat.mount.key}` : `./${entry.name}${isDir ? '/' : ''}`
      listing.append(h('div', {class: 'entry'},
        h('img', {class: 'icon', src: `/.ui/img/${isMount ? 'mount' : isDir ? 'folder' : 'file'}.svg`}),
        h('a', {href}, entry.name),
        h('small', {}, formatBytes(entry.stat.size))
      ))
    }
    if (entries.length === 0) {
      listing.append(h('div', {class: 'entry'}, 'This folder is empty'))
    }
    this.append(listing)
  }
}
customElements.define('module-directory-view', ModuleDirectoryView)

class ModuleFileView extends HTMLElement {
  constructor (pathname, renderHTML = false) {
    super()
    this.render(pathname, renderHTML)
  }
  async render (pathname, renderHTML = false) {
    // check existence
    let stat = await beaker.hyperdrive.stat(pathname).catch(e => undefined)
    if (!stat) {
      // 404
      this.append(h('div', {class: 'empty'}, h('h2', {}, '404 File Not Found')))
      return
    }

    // embed content
    if (/\.(png|jpe?g|gif)$/i.test(pathname)) {
      this.append(h('img', {src: pathname}))
    } else if (/\.(mp4|webm|mov)/i.test(pathname)) {
      this.append(h('video', {controls: true}, h('source', {src: pathname})))
    } else if (/\.(mp3|ogg)/i.test(pathname)) {
      this.append(h('audio', {controls: true}, h('source', {src: pathname})))
    } else {
      // render content
      let content = await beaker.hyperdrive.readFile(pathname)
      if (renderHTML && /\.(md|html)$/i.test(pathname)) {
        if (pathname.endsWith('.md')) {
          content = beaker.markdown.toHTML(content)
        }
        let contentEl = h('div', {class: 'content'})
        contentEl.innerHTML = content
        this.append(contentEl)
        executeScripts(contentEl)
      } else {
        let codeBlock = h('pre', {class: 'content'}, content)
        hljs.highlightBlock(codeBlock)
        this.append(codeBlock)
      }
    }
  }
}
customElements.define('module-file-view', ModuleFileView)

class ModuleReadmeView extends HTMLElement {
  constructor () {
    super()
    this.render()
  }
  async render () {
    let files = await beaker.hyperdrive.readdir(location.pathname).catch(e => ([]))
    files = files.filter(f => ['index.html', 'index.md'].includes(f.toLowerCase()))
    if (files[0]) {
      this.append(new ModuleFileView(location.pathname + files[0], true))
    }
  }
}
customElements.define('module-readme-view', ModuleReadmeView)

customElements.define('module-page', class extends HTMLElement {
  constructor () {
    super()
    if (location.pathname !== '/') {
      this.append(new ModuleBreadcrumbs())
    }
    if (location.pathname.endsWith('/')) {
      this.append(new ModuleDirectoryView())
      this.append(new ModuleReadmeView())
    } else {
      this.append(new ModuleFileView(location.pathname))
    }
  }
})

async function executeScripts (el) {
  for (let scriptEl of Array.from(el.querySelectorAll('script'))) {
    let promise
    let newScriptEl = document.createElement('script')
    newScriptEl.setAttribute('type', scriptEl.getAttribute('type') || 'module')
    newScriptEl.textContent = scriptEl.textContent
    if (scriptEl.getAttribute('src')) {
      newScriptEl.setAttribute('src', scriptEl.getAttribute('src'))
      promise = new Promise((resolve, reject) => {
        newScriptEl.onload = resolve
        newScriptEl.onerror = resolve
      })
    }
    document.head.append(newScriptEl)
    await promise
  }
}

beaker.terminal.registerCommand({
  name: 'test',
  help: 'Run the module tests',
  handle () {
    if (location.pathname !== '/tests/') {
      location.pathname = '/tests/'
    } else {
      location.reload()
    }
  }
})
beaker.terminal.registerCommand({
  name: 'demo',
  help: 'View the module demo',
  handle () {
    if (location.pathname !== '/demo/') {
      location.pathname = '/demo/'
    } else {
      location.reload()
    }
  }
})
beaker.terminal.registerCommand({
  name: 'run',
  help: 'Run a script in the /scripts directory',
  usage: '@run {script} {...args}',
  async handle (opts = {}, ...args) {
    var scriptName = args[0]
    if (!scriptName) throw new Error('Must specify a script to run')
    if (!scriptName.endsWith('.js')) {
      scriptName += '.js'
    }
    var scriptPath = `/scripts/${scriptName}`
    try {
      var script = await import(scriptPath)
    } catch (e) {
      if (e.message.includes('Failed to fetch')) {
        throw new Error(`No script found in /scripts named ${scriptName}`)
      } else {
        throw e
      }
    }
    if (typeof script.default !== 'function') {
      throw new Error('The script must export a default function')
    }
    return script.default(opts, args.slice(1))
  }
})
body {
  --light-gray: #f7f7fc;
  --blue: #2864dc;
  --border-radius: 4px;

  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
  margin: 0;
}

a {
  color: var(--blue);
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

button, .button {
  display: inline-block;
  padding: 0.5rem 0.8rem;
  background: #fff;
  border: 1px solid #88f;
  border-radius: 4px;
  color: var(--blue);
  outline: 0;
  font-size: 12px;
  box-shadow: 0 0 0 #0003;
  transition: box-shadow 0.2s;
}

button:hover, .button:hover {
  box-shadow: 0 1px 2px #0003;
  text-decoration: none;
}

button:active {
  background: var(--light-gray);
}

button.primary {
  color: #fff;
  background: var(--blue);
  border-color: var(--blue);
}

button img {
  display: block;
  width: 16px;
}

nav {
  background: var(--light-gray);
}

nav > *,
main > * {
  max-width: 800px;
  margin: 0 auto;
  display: block;
}

module-header {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: center;
  height: 100px;
  padding: 0 20px;
}

module-header h1,
module-header p {
  margin: 0.25rem 0;
  line-height: 1;
}

module-header h1 {
  font-size: 2rem;
}

module-header h1 a {
  color: inherit;
}

module-header .admin {
  display: flex;
  position: absolute;
  top: 30px;
  right: 20px;
}

module-header .admin > * {
  margin-left: 5px;
}

module-breadcrumbs {
  display: flex;
  align-items: center;
  margin: 1rem 0;
  font-size: 15px;
}

module-breadcrumbs > * {
  margin-right: 5px;
}

module-breadcrumbs a {
  letter-spacing: 0.8px;
  font-weight: 600;
}

module-breadcrumbs a:last-of-type {
  color: #334;
}

module-directory-view .listing {
  margin: 1rem 0;
  border: 1px solid #ccd;
  border-radius: var(--border-radius);
}

module-directory-view .listing .entry {
  display: flex;
  align-items: center;
  padding: 0.6rem 0.8rem;
  border-bottom: 1px solid #ccd;
}

module-directory-view .listing .entry:last-child {
  border-bottom: 0;
}

module-directory-view .listing .entry .icon {
  width: 16px;
  height: 16px;
  margin-right: 10px;
}

module-directory-view .listing .entry a {
  font-size: 15px;
  margin-right: auto;
}

module-directory-view .listing .entry small {
  opacity: 0.5;
}

module-file-view {
  display: block;
  margin: 1rem 0;
  border: 1px solid #ccd;
  border-radius: var(--border-radius);
}

module-file-view .hljs {
  background: #fff;
  font-size: 14px;
  line-height: 1.4;
}

module-file-view .content {
  margin: 0;
  padding: 1.25rem 1rem;
}

.content > :first-child {
  margin-top: 0;
}
.content hr {
  border: 0;
  border-top: 1px solid #ccd;
}
.content h1,
.content h2,
.content h3,
.content h4,
.content h5 { margin: 1.5rem 0; }
.content h1 { font-size: 2em; }
.content h2 { font-size: 1.7em; }
.content h3 { font-size: 1.4em; }
.content h4 { font-size: 1.3em; }
.content h5 { font-size: 1.1em; }
.content pre {
  background: var(--light-gray);
  padding: 1em;
  overflow: auto;
}
.content p,
.content ul,
.content ol {
  line-height: 1.5;
}
.content table {
  margin: 1em 0;
}
.content blockquote {
  border-left: 10px solid var(--light-gray);
  margin: 1em 0;
  padding: 1px 1.5em;
  color: #667;
}

.content #mocha {
  margin: 0;
}

.content #mocha h1 {
  margin-top: 0;
  font-weight: 500;
}

.content #mocha .suite {
  margin-bottom: 15px;
}

.content #mocha #mocha-stats {
  background: #fff;
  border: 1px solid #ccd;
  padding: 9px 6px 4px 12px;
  border-radius: 30px;
  box-shadow: 0 1px 2px #0002;
}

.content #mocha #mocha-stats li:not(.progress) {
  padding-top: 12px;
}

@media (max-width: 1460px) {
  .content #mocha #mocha-stats {
    top: unset;
    bottom: 15px;
    right: 15px;
  }
}
MIT License

Copyright (c) 2020 Blue Link Labs

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, 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 THE
SOFTWARE.