Bootstrap
Committed 1133e4
index 0000000..e38eefc
--- /dev/null
+name: Run Tests and Analysis
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ build-linux:
+ strategy:
+ matrix:
+ python-version: ["3.10"]
+ runs-on: "ubuntu-latest"
+ steps:
+ - name: Install graphviz
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y graphviz
+
+ - uses: actions/checkout@v3
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Remove development dependencies
+ run: sed -i '/\[tool.poetry.group.dev.dependencies\]/,/\[/d' pyproject.toml
+
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+ with:
+ version: 1.2.2
+ virtualenvs-in-project: true
+
+ - name: Install dependencies
+ run: poetry install --no-interaction --no-root
+
+ - name: Install library
+ run: poetry install --no-interaction
+
+ - name: Install development tools
+ run: poetry add gmpg
+
+ - uses: psf/black@stable
+ with:
+ options: "--check --verbose"
+ src: "."
+ version: "23.7"
+
+ - uses: isort/isort-action@v1
+ with:
+ configuration: "--profile black"
+
+ # - name: Run tests
+ # run: poetry run gmpg test
+
+ - name: Run analysis
+ run: poetry run gmpg analyze
+
+ - name: Generate dependency graph
+ run: poetry run gmpg graph
+
+ - uses: actions/upload-artifact@v3
+ with:
+ name: analysis
+ path: |
+ test_coverage.xml
+ test_results.xml
+ api_python.json
+ deps.svg
index 0000000..8995423
--- /dev/null
+[tool.poetry]
+name = "webint-editor"
+version = "0.0.170"
+description = "an editor for your website"
+authors = ["Angelo Gladding <angelo@ragt.ag>"]
+license = "AGPL-3.0-or-later"
+keywords = ["Micropub"]
+packages = [{include="webint_editor"}]
+
+[tool.poetry.plugins.webapps]
+editor = "webint_editor:app"
+
+[tool.poetry.dependencies]
+python = ">=3.10,<3.11"
+webint = ">=0.0"
+
+[tool.poetry.dev-dependencies]
+gmpg = {path="../gmpg", develop=true}
+bgq = {path="../bgq", develop=true}
+newmath = {path="../newmath", develop=true}
+sqlyte = {path="../sqlyte", develop=true}
+webint = {path="../webint", develop=true}
+webagt = {path="../webagt", develop=true}
+microformats = {path="../python-microformats", develop=true}
+
+# [[tool.poetry.source]]
+# name = "main"
+# url = "https://ragt.ag/code/pypi"
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
index 0000000..609534b
--- /dev/null
+"""
+An editor for your website.
+
+Implements a Micropub editor.
+
+"""
+
+import json
+import pprint
+
+import sqlyte
+import web
+import webagt
+
+app = web.application(__name__, prefix="editor")
+
+
+@app.control("")
+class Drafts:
+ """All Drafts."""
+
+ def get(self):
+ """Return a list of all drafts."""
+ return app.view.drafts()
+
+ def post(self):
+ return "requires javascript"
+ # form = web.form("content")
+ # web.application("understory.posts").model.create(
+ # "entry", {"content": form.content}
+ # )
+ # return "entry created"
+
+
+@app.control("draft")
+class Draft:
+ """A draft."""
+
+ def get(self):
+ """Return the draft."""
+ permalink = web.form(permalink=None).permalink
+ post = {}
+ if permalink:
+ post = web.application("webint_posts").model.read(permalink)["resource"]
+ else:
+ permalink, _ = web.application("webint_posts").model.create("entry")
+ raise web.SeeOther(f"/editor/draft?permalink={permalink}")
+ return app.view.draft(
+ post,
+ web.application("webint_owner").model.get_identities(),
+ [], # web.application("webint_guests").model.get_guests(),
+ )
+
+ def post(self):
+ return "requires javascript"
+ # form = web.form("content")
+ # web.application("understory.posts").model.create(
+ # "entry", {"content": form.content}
+ # )
+ # return "entry created"
+
+
+@app.control("preview/markdown")
+class PreviewMarkdown:
+ """"""
+
+ def get(self):
+ return (
+ "<form method=post>"
+ "<textarea name=content></textarea>"
+ "<button>Preview</button>"
+ "</form>"
+ )
+
+ def post(self):
+ form = web.form("pad_id", context=None)
+ etherpad_content = sqlyte.db(
+ "/home/admin/app/run/media/pads/etherpad-lite/var/sqlite.db"
+ ).select("store", where="key = ?", vals=[f"pad:{form.pad_id}"])[0]["value"]
+ content = json.loads(etherpad_content)["atext"]["text"]
+ rendered = str(
+ web.mkdn(
+ str(
+ web.template(
+ content,
+ globals={"get": webagt.get, "pformat": pprint.pformat},
+ restricted=True,
+ )()
+ ),
+ form.context,
+ ) # , globals=micropub.markdown_globals)
+ )
+ web.header("Content-Type", "application/json")
+ return {
+ "content": rendered,
+ # "readability": micropub.readability.Readability(form.content).metrics,
+ }
+
+
+@app.control("preview/resource")
+class PreviewResource:
+ """"""
+
+ def get(self):
+ url = web.form(url=None).url
+ web.header("Content-Type", "application/json")
+ if not url:
+ return {}
+ resource = web.get(url)
+ if resource.entry.data:
+ return resource.entry
+ if resource.event.data:
+ return resource.event
+ if resource.feed.data:
+ return resource.feed
+ return {}
+
+ # XXX data = cache.parse(url)
+ # XXX if "license" in data["data"]["rels"]:
+ # XXX data["license"] = data["data"]["rels"]["license"][0]
+ # XXX try:
+ # XXX edit_page = data["html"].cssselect("#ca-viewsource a")[0]
+ # XXX except IndexError:
+ # XXX # h = html2text.HTML2Text()
+ # XXX # try:
+ # XXX # data["content"] = h.handle(data["entry"]["content"]).strip()
+ # XXX # except KeyError:
+ # XXX # pass
+ # XXX try:
+ # XXX markdown_input = ("html", data["entry"]["content"])
+ # XXX except (KeyError, TypeError):
+ # XXX markdown_input = None
+ # XXX else:
+ # XXX edit_url = web.uri.parse(str(data["url"]))
+ # XXX edit_url.path = edit_page.attrib["href"]
+ # XXX edit_page = fromstring(requests.get(edit_url).text)
+ # XXX data["mediawiki"] = edit_page.cssselect("#wpTextbox1")[0].value
+ # XXX data["mediawiki"] = (
+ # XXX data["mediawiki"].replace("{{", r"{!{").replace("}}", r"}!}")
+ # XXX )
+ # XXX markdown_input = ("mediawiki", data["mediawiki"])
+
+ # XXX if markdown_input:
+ # XXX markdown = str(
+ # XXX sh.pandoc(
+ # XXX "-f", markdown_input[0], "-t", "markdown", _in=markdown_input[1]
+ # XXX )
+ # XXX )
+ # XXX for n in range(1, 5):
+ # XXX indent = " " * n
+ # XXX markdown = markdown.replace(f"\n{indent}-",
+ # XXX f"\n{indent}\n{indent}-")
+ # XXX markdown = re.sub(r'\[(\w+)\]\(\w+ "wikilink"\)', r"[[\1]]", markdown)
+ # XXX markdown = markdown.replace("–", "--")
+ # XXX markdown = markdown.replace("—", "---")
+ # XXX data["content"] = markdown
+
+ # XXX data.pop("html")
+ # XXX # XXX data["category"] = list(set(data["entry"].get("category", [])))
+ # XXX web.header("Content-Type", "application/json")
+ # XXX return dump_json(data)
index 0000000..60da55c
--- /dev/null
+from pprint import pformat
+
+import pendulum
+from web import tx
+
+__all__ = ["pendulum", "tx", "pformat"]
index 0000000..5b218a7
--- /dev/null
+$def with (post, identities, guests)
+$var body_classes = ["widescreen"]
+
+$code:
+ def get_property(prop):
+ return post.pop(prop, [""])[0]
+
+$ permalink = get_property("url")
+$ pad_id = permalink.lstrip('/').replace('/', '--')
+$var title: Editing $permalink
+$var show_title = False
+$var hide_footer = True
+
+<form id=editor method=post action=$tx.origin/editor>
+<div id=preview>
+ <div id=replyContext></div>
+ <div id=previewName></div>
+ <div id=previewContent></div>
+</div>
+<div id=properties>
+ <div id=general_properties>
+ $# <fieldset id=author>
+ $# <legend>Author</legend>
+ $# $ author = get_property("author")
+ $# $for identity in identities:
+ $# $ card = identity["card"]
+ $# <label class=radio><input required type=radio name=author value=$card["uid"][0]
+ $# $if author and author["uid"] == card["uid"]:
+ $# checked
+ $# $elif card["uid"][0] == "/":
+ $# checked
+ $# > $card["name"][0]</label>
+ $# </label>
+ $# </fieldset>
+
+ $# <div id=coauthor>
+ $# <label><small>Coauthor</small><br>
+ $# <label class=bounding><fieldset>
+ $# $# $for rsvp in ["yes", "no", "maybe"]:
+ $# $# <label class=radio><input required type=radio name=rsvp value=$rsvp
+ $# $# $if "rsvp" in post and rsvp == post["rsvp"][0] or rsvp == "public":
+ $# $# checked
+ $# $# > $rsvp</label>
+ $# </fieldset></label>
+ $# </label>
+ $# </div>
+ $# <ul>
+ $# <p><small>Guests currently signed in:</small></p>
+ $# $for guest in guests:
+ $# <li><label><input type=checkbox name=coauthor value=$guest["url"]>
+ $# $guest["name"]</label> <small><a href=$guest["url"]>$guest["url"]</a></small></li>
+ $# </ul>
+ $# </fieldset>
+
+ $# <fieldset id=audience>
+ $# <legend>Audience</legend>
+ $# $if post:
+ $# $for member_url in post.get("audience", []):
+ $# <label><input checked type=checkbox name=audience value=$member_url>
+ $# $member_url</label>
+ $# </fieldset>
+
+ <fieldset id=visibility>
+ <legend>Visibility</legend>
+ $for vis in ["public", "protected", "private"]:
+ <label class=radio><input required type=radio name=visibility value=$vis
+ $if vis == get_property("visibility"):
+ checked
+ > $vis</label>
+ </fieldset>
+
+ $# <fieldset id=syndication>
+ $# <legend>Syndication</legend>
+ $# $for syn in ["mastodon"]:
+ $# <label class=radio><input required type=checkbox name=syndication value=$syn
+ $# $if syn == get_property("syndication"):
+ $# checked
+ $# > $syn</label>
+ $# </fieldset>
+
+ $# $ type = get_property("type")
+ $# <fieldset id=type>
+ $# <legend>Type</legend>
+ $# $for _type in ["card", "entry", "event", "recipe", "review"]:
+ $# <label class=radio><input required type=radio name=h value=$_type
+ $# $if (not type and _type == "entry") or _type == type:
+ $# checked
+ $# $else:
+ $# disabled
+ $# > $_type</label>
+ $# </fieldset>
+ </div>
+
+ $# <div id=card_properties>
+ $# <div id=name>
+ $# <label><small>Name</small><br>
+ $# <label class=bounding><input type=text name=name
+ $# value="$get_property('name')"></label></label>
+ $# </div>
+ $# </div>
+
+ <style>
+ #entry_properties div {
+ display: inline-block;
+ width: 100%; }
+ </style>
+ <div id=entry_properties>
+ <div id=in_reply_to>
+ <label><small>In reply to</small><br>
+ <label class=bounding><input type=text name=in_reply_to
+ value="$get_property('in-reply-to')"></label></label>
+ </div>
+ <div id=rsvp>
+ <label><small>RSVP</small><br>
+ <label class=bounding><fieldset>
+ $for rsvp in ["yes", "no", "maybe"]:
+ <label class=radio><input type=radio name=rsvp value=$rsvp
+ $if "rsvp" in post and rsvp == post["rsvp"][0] or rsvp == "public":
+ checked
+ > $rsvp</label>
+ </fieldset></label>
+ </label>
+ </div>
+ <div id=like_of>
+ <label><small>Like of</small><br>
+ <label class=bounding><input type=text name=like_of
+ value="$get_property('like-of')"></label></label>
+ </div>
+ <div id=bookmark_of>
+ <label><small>Bookmark of</small><br>
+ <label class=bounding><input type=text name=bookmark_of
+ value="$get_property('bookmark-of')"></label></label>
+ </div>
+ <div id=listen_of>
+ <label><small>Listen of</small><br>
+ <label class=bounding><input type=text name=listen_of
+ value="$get_property('listen-of')"></label></label>
+ </div>
+ <div id=name>
+ <label><small>Name</small><br>
+ <label class=bounding><input type=text name=name
+ value="$get_property('name')"></label></label>
+ </div>
+ <div id=summary>
+ <label><small>Summary</small><br>
+ <label class=bounding><input type=text name=summary
+ value="$get_property('summary')"></label></label>
+ </div>
+ <div id=photo>
+ <label><small>Photo</small><br>
+ <label class=bounding><input type=text name=name
+ value="$get_property('photo')"></label></label>
+ </div>
+ <div id=econtent>
+ <label><small>Content</small><br>
+ <label class=bounding>
+ $# XXX <div id=editor></div>
+ $# XXX <div id=editorStatus></div>
+ $# TODO FIXME <textarea id=content-editor name=content>$get_property('content')["html"]</textarea>
+ <!--textarea id=content-editor name=content>$get_property('content')</textarea-->
+ <iframe src="$tx.origin/pads/p/$pad_id"
+ frameborder=0 style=height:30em;width:100%></iframe>
+ </label></label>
+ $# XXX <div id=connection></div>
+ $# XXX <div id=version></div>
+ </div>
+ </div>
+
+ $# <div id=event_properties>
+ $# <div id=name>
+ $# <label><small>Name</small><br>
+ $# <label class=bounding><input type=text name=name
+ $# value="$get_property('name')"></label></label>
+ $# </div>
+ $# </div>
+ $# <div id=resume_properties>
+ $# <div id=name>
+ $# <label><small>Name</small><br>
+ $# <label class=bounding><input type=text name=name
+ $# value="$get_property('name')"></label></label>
+ $# </div>
+ $# </div>
+ $# <div id=review_properties>
+ $# <div id=name>
+ $# <label><small>Name</small><br>
+ $# <label class=bounding><input type=text name=name
+ $# value="$get_property('name')"></label></label>
+ $# </div>
+ $# </div>
+
+ $# <fieldset id=categories>
+ $# <legend>Categories</legend>
+ $# <!-- XXX todo populate with call to mp?q=config as with channels -->
+ $# <input type=text><button>Add</button>
+ $# <ul>
+ $# $# $for category in c
+ $# $# <li><input type=checkbox id=coding name=category value=coding>
+ $# $# <label for=coding>Coding</label></li>
+ $# </ul>
+ $# </fieldset>
+
+ $if published := get_property("published"):
+ <div id=published>
+ <label for=published><small>Published</small></label><br>
+ <input style="border:1px solid #333" type=datetime-local name=published_datetime
+ value="$published.to_date_string()T$published.to_time_string()">
+ <input style="font-family:monospace;font-size:.9em;border:1px solid #333;width:10em" type=text name=published_microseconds
+ value="$published.microsecond">
+ <select name=published_tz>
+ $for tz in pendulum.timezones:
+ $if "/" not in tz:
+ $continue
+ <option
+ $if tz == getattr(published, "timezone_name", "America/Los_Angeles"):
+ selected
+ >$tz</option>
+ </select>
+ </div>
+ <div id=permalink>
+ <label><small>Permalink</small><br>
+ <label class=bounding><input readonly type=text name=url
+ value=$tx.origin/$permalink.lstrip("/")></label></label>
+ </div>
+ </div>
+
+ <div class=buttons>
+ $# <label><input type=checkbox> Draft Live</label>
+ $# <button name=action value=save>Save</button>
+ $# if permalink:
+ <button name=action value=update>Update</button>
+ $# $else:
+ $# <button name=action value=create>Create</button>
+ </div>
+</form>
+
+$if post:
+ <h4>Unused properties</h4>
+ <pre>$pformat(post)</pre>
+
+<style>
+form#editor {
+ column-gap: 2%;
+ display: grid;
+ grid-template-columns: 49% 49%; }
+form#editor input[type=text], textarea {
+ background-color: #ddd;
+ border: 0;
+ padding: 0;
+ width: 100%; }
+form#editor textarea {
+ height: 20em;
+ width: 100%; }
+form#editor input[type=text] {
+ height: 1.5em; }
+#categories ul {
+ list-style: none;
+ padding-left: 0; }
+.bounding {
+ background-color: #ddd;
+ border: .1em solid #333;
+ display: block;
+ padding: .125em .25em; }
+.radio {
+ cursor: pointer;
+ font-size:.8em }
+label small, legend {
+ font-size: .8em;
+ font-weight: bold; }
+fieldset {
+ border: 0;
+ margin: 0;
+ padding: 0; }
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0; }
+.buttons {
+ margin-top: 1em;
+ text-align: right; }
+#author, #coauthor, #type, #in_reply_to, #rsvp, #like_of, #bookmark_of, #listen_of, #name, #summary,
+#photo, #econtent, #coauthors, #visibility, #published, #permalink {
+ margin-bottom: .5em; }
+#in_reply_to input[type=checkbox], #name input[type=checkbox] {
+ font-size: .9em; }
+#in_reply_to input[type=text], #name input[type=text], #permalink input[type=text] {
+ outline: none; }
+#in_reply_to input[type=text], #permalink input[type=text] {
+ font-size: .9em; }
+$# #name input {
+$# font-size: 1.5em;
+$# height: 1.2em; }
+#content {
+ height: 35em; }
+</style>
+
+<script type=module>
+// XXX import { load, diamondMonaco, MicropubClient } from '/static/web.js'
+import { load, MicropubClient } from '/static/web.js'
+
+const pub = new MicropubClient('/posts')
+
+load(() => {
+ // document.querySelector('#card_properties').style.display = 'none'
+ // document.querySelector('#event_properties').style.display = 'none'
+ // document.querySelector('#resume_properties').style.display = 'none'
+ // document.querySelector('#review_properties').style.display = 'none'
+ const enlivenOptionalProperty = (type, property) => {
+ const textbox = document.querySelector(`#$${type}_properties #$${property} .bounding`)
+ textbox.style.display = 'none'
+ const checkbox = document.createElement('input')
+ checkbox.type = 'checkbox'
+ checkbox.addEventListener('click', ev => {
+ textbox.style.display = ev.target.checked ? 'block' : 'none'
+ textbox.focus()
+ })
+ document.querySelector(`#$${type}_properties #$${property} small`).prepend(checkbox)
+ }
+ // enlivenOptionalProperty('general', 'coauthor')
+ enlivenOptionalProperty('entry', 'in_reply_to')
+ enlivenOptionalProperty('entry', 'rsvp')
+ enlivenOptionalProperty('entry', 'like_of')
+ enlivenOptionalProperty('entry', 'bookmark_of')
+ enlivenOptionalProperty('entry', 'listen_of')
+ enlivenOptionalProperty('entry', 'name')
+ enlivenOptionalProperty('entry', 'summary')
+ enlivenOptionalProperty('entry', 'photo')
+ enlivenOptionalProperty('entry', 'econtent')
+
+ const getContent = () => {
+ // XXX monaco.getValue()
+ // return document.querySelector('#content-editor').value
+ const request = new XMLHttpRequest()
+ request.open("GET", '/pads/p/$pad_id/export/txt', false)
+ request.send(null);
+ if (request.status === 200) {
+ return request.responseText
+ // const response = JSON.parse(request.responseText)
+ // return response['pad:$pad_id'].atext.text
+ }
+ }
+
+ var frequency = 1; // seconds
+ var count = 0;
+ var clean = true;
+ const tick = () => {
+ setTimeout(() => {
+ if (count++ > frequency) // && !clean)
+ updatePreview();
+ tick();
+ }, 1000);
+ }
+ tick();
+ const updatePreview = () => {
+ clean = false;
+ if (count > frequency) {
+ previewMarkdown();
+ clean = true;
+ count = 0;
+ }
+ }
+ updatePreview()
+
+ pub.getConfig().then(config => console.log("Micropub Config:", config))
+ // pub.getCategories().then(data => {
+ // data.categories.forEach(cat => {
+ // addCategory(cat)
+ // })
+ // })
+
+ // TODO on form control change create a permalink and save a draft
+ const permalink = '$permalink'
+ const properties = {'content': '', 'post-status': 'draft'}
+
+ $# let monaco
+ $# pub.create('entry', properties).then(permalink => {
+ $# $# $# document.querySelector('#permalink input[type=text]').value =
+ $# $# $# `$tx.origin$${permalink}`
+ $# $# monaco = diamondMonaco(
+ $# $# permalink, editor, editorStatus, connection, version,
+ $# $# {
+ $# $# language: 'markdown'
+ $# $# },
+ $# $# '$(tx.user.session["uid"][0] if tx.user.session else tx.user.ip)',
+ $# $# true
+ $# $# )
+ $# $# monaco.onKeyUp(updatePreview)
+ $# })
+
+ // document.querySelector('#content-editor').addEventListener('keyup', updatePreview)
+ document.querySelector('#in_reply_to input[type=text]').addEventListener('blur', ev => {
+ previewReplyContext(ev.target.value)
+ })
+
+ addEventListener('beforeunload', ev => {
+ ev.stopPropagation()
+ ev.preventDefault()
+ return false
+ }, true)
+
+ document.querySelector('form#editor').addEventListener('submit', ev => {
+ ev.preventDefault()
+ // let myForm = document.getElementById('editor')
+ // let formData = new FormData(myForm)
+ // for (let p of formData) {
+ // console.log(p)
+ // }
+ pub.update(document.querySelector('input[name=url]').value,
+ "replace",
+ {
+ "visibility": [document.querySelector('input[name=visibility]').value],
+ "syndication": [document.querySelector('input[name=syndication]').value],
+ "name": [document.querySelector('input[name=name]').value],
+ "content": [getContent()]
+ }
+ )
+
+ // const properties = {}
+ // const name = document.querySelector('input[name=name]').value
+ // if (name) {
+ // properties.name = [name]
+ // }
+
+ // const content = editor.value
+ // if (content) {
+ // properties.content = [content]
+ // }
+
+ // const published_date = document.querySelector('input[name=published_date]').value
+ // const published_time = document.querySelector('input[name=published_time]').value
+ // const published_tz = document.querySelector('select[name=published_tz]').value
+ // console.log(published_date)
+ // console.log(published_time)
+ // console.log(published_tz)
+ // if (published_date) {
+ // let published = published_date
+ // if (published_time) {
+ // published = `$${published_date}T$${published_time}:00+00:00`
+ // }
+ // properties.published = [{datetime: published, timezone: published_tz}]
+ // }
+
+ // const categories = []
+ // document.querySelectorAll('#categories input[type=checkbox]').forEach(el => {
+ // if (el.checked) {
+ // categories.push(el.value)
+ // }
+ // })
+ // if (categories.length) {
+ // properties['category'] = categories
+ // }
+
+ // console.log(properties)
+ // pub.create('entry', properties, 'public').then(permalink => {
+ // window.location.href = permalink
+ // })
+ })
+})
+
+const previewMarkdown = () => {
+ name = document.querySelector('#name input[type=text]').value
+ previewName.innerHTML = `<h1>$${name}</h1>`
+ let body = new FormData()
+ body.append('pad_id', '$pad_id')
+ fetch(
+ '/editor/preview/markdown',
+ {
+ method: 'POST',
+ body: body
+ }
+ ).then(response => {
+ if (response.status === 200) {
+ return response.json().then(data => {
+ previewContent.innerHTML = data['content']
+ })
+ }
+ })
+}
+
+const previewReplyContext = url => {
+ if (url == '') {
+ replyContext.innerHTML = '';
+ return
+ }
+ fetch(
+ '/editor/preview/resource?url=' + encodeURIComponent(url),
+ ).then(response => {
+ if (response.status === 200) {
+ return response.json().then(data => {
+ replyContext.innerHTML = `<big><strong>$${data['name']}</strong></big><p>$${data['summary']}</p>`
+ })
+ }
+ })
+}
+
+$# const addCategory = (cat) => {
+$# const ul = document.querySelector('#categories ul')
+$# const li = document.createElement('li')
+$# const checkbox = document.createElement('input')
+$# const tagname = document.createElement('span')
+$# checkbox.type = 'checkbox'
+$# checkbox.value = cat
+$# li.appendChild(checkbox)
+$# tagname.innerText = cat
+$# li.appendChild(tagname)
+$# ul.appendChild(li)
+$# }
+
+$# const { MicropubClient } = web
+$# const pub = new MicropubClient('/posts')
+$# document.addEventListener('DOMContentLoaded', () => {
+$# pub.getConfig().then(data => console.log(data))
+$# pub.getCategories().then(data => {
+$# data.categories.forEach(cat => {
+$# addCategory(cat)
+$# })
+$# })
+
+$# document.querySelector('#categories button').addEventListener('click', event => {
+$# event.preventDefault()
+$# const textbox = document.querySelector('#categories input')
+$# addCategory(textbox.value)
+$# textbox.value = ''
+$# })
+</script>
index 0000000..02926a7
--- /dev/null
+$def with ()
+$var title: Drafts
+
+<p>..</p>
+
+<form method=post>
+<button>New</button>
+</form>
index 0000000..d68c997
--- /dev/null
+$def with (resource)
+$:resource
+$# $:resource.aside()