summaryrefslogtreecommitdiff
path: root/desk/lib/mast.hoon
diff options
context:
space:
mode:
Diffstat (limited to 'desk/lib/mast.hoon')
-rw-r--r--desk/lib/mast.hoon694
1 files changed, 694 insertions, 0 deletions
diff --git a/desk/lib/mast.hoon b/desk/lib/mast.hoon
new file mode 100644
index 0000000..74636e8
--- /dev/null
+++ b/desk/lib/mast.hoon
@@ -0,0 +1,694 @@
+:: :: :: ::
+::
+:: Mast - a Sail framework
+::
+:: [v.1.0.1]
+::
+::
+:: This library contains a system for building fully dynamic Sail front-ends
+:: where all front-end app state and the current state of the display itself
+:: live on your ship.
+::
+:: A small script that is generic to any application is inserted into your Sail
+:: and used to establish an Eyre channel, receive display updates from your ship,
+:: and to sync the browser with them.
+::
+:: Events on the browser are handled completely within your ship,
+:: without the need to write a single line of JavaScript.
+:: You may describe event listeners in your Sail components with attributes like this:
+:: =event "/click/..."
+:: The first segment of the path is the event listener name,
+:: with further segments defining an arbitrary endpoint for an event handler on your agent.
+:: Events are sent as pokes under a json mark, which can be parsed with the library.
+:: You may also return data from the event like this:
+:: =return "/target/value"
+:: The first segment is the object to return data from, and the second is the property to return.
+:: Data can be returned from the target element, event object, or any other element associated by id.
+::
+:: When the display state changes as a result of events initiated on the browser,
+:: or from any other kind of event in the agent, updates to the browser containing
+:: only the necessary amount of html to achieve this state are sent and swapped in.
+::
+::
+:: The server section contains all of the arms for usage in your app.
+:: Rig, plank, and gust are the main arms. See the description of these arms below.
+::
+:: For more details visit: https://github.com/R-JG/mast
+::
+:: :: :: ::
+|%
++$ view manx
++$ yard [url=path sail=gate]
++$ yards (list yard)
++$ parsed-req [tags=path data=(map @t @t)]
+:: :: :: ::
+::
+:: Server
+::
+:: :: :: ::
+::
+:: - The rig arm is used to produce a new instance of the display state.
+:: - "yards" is the list of your app's routes, each corresponding to a root level Sail component
+:: (i.e. a complete document with html, head, and body tags).
+:: - "url" is either the request url from Eyre in the context of a direct http request,
+:: or the current url (this should be saved in state).
+:: - "app-state" represents the total sample for each of your root level Sail components
+:: (currently, root level Sail components in yards each need to take the same sample).
+:: - Rig uses the url to select the matching yard and renders its Sail component.
+:: - The newly produced display state should then be used with either plank or gust,
+:: and saved as the current display state in the agent.
+::
+++ rig
+ |* [=yards url=path app-state=*]
+ ^- view
+ ?~ yards
+ (adky (manx sail-404))
+ =/ yurl=path url.i.yards
+ ?: |-
+ ?~ url
+ %.n
+ ?~ yurl
+ %.n
+ ?. |(=(i.url i.yurl) =(%$ i.yurl))
+ %.n
+ ?: &(=(~ t.url) =(~ t.yurl))
+ %.y
+ $(url t.url, yurl t.yurl)
+ =/ rigd (adky (manx (sail.i.yards app-state)))
+ rigd(a.g (mart [[%url (path <url>)] a.g.rigd]))
+ $(yards t.yards)
+::
+:: - The plank arm is used for serving whole pages in response to %handle-http-request pokes,
+:: acting as the first point of contact for the app.
+:: - Plank needs to take some basic information about the page that you are serving:
+:: - "app" is the name of the app,
+:: - "sub" is the subscription path that the client will subscribe to for receiving display updates,
+:: - "ship" is your patp,
+:: - "rid" is the Eyre id from the %handle-http-request poke,
+:: - "new" is the newly rendered display state produced with rig.
+:: - Plank produces a list of cards serving the http response.
+::
+++ plank
+ |= [app=tape sub=path ship=@p rid=@ta new=view]
+ ^- (list card:agent:gall)
+ ?~ c.new !!
+ %^ make-direct-http-cards rid
+ [200 ['Content-Type' 'text/html'] ~]
+ :- ~
+ ^- octs
+ %- as-octt:mimes:html
+ %- en-xml:html
+ ^- manx
+ %= new
+ a.g %- mart :^
+ [%app app]
+ [%path <(path sub)>]
+ [%ship +:(scow %p ship)]
+ a.g.new
+ c.i.c (marl [script-node c.i.c.new])
+ ==
+::
+:: - The gust arm is used for producing a set of display updates for the browser,
+:: used typically after making changes to your app's state, and rendering new display data with rig.
+:: - "sub" is the subscription path that was sent initially in plank, where gust will send the updates.
+:: - "old" is the display state that is currently saved in your agent's state,
+:: produced some time previously by rig.
+:: - "new" is the new display data to be produced with rig just before gust gets used.
+:: - Gust can be used anywhere you'd make a subscription update (in contrast to plank).
+:: - Gust produces a single card.
+::
+++ gust
+ |= [sub=path old=view new=view]
+ ^- card:agent:gall
+ ?~ c.old !!
+ ?~ c.new !!
+ ?~ t.c.old !!
+ ?~ t.c.new !!
+ ?~ a.g.new !!
+ :^ %give %fact ~[sub]
+ :- %json
+ !> %- tape:enjs:format
+ %- en-xml:html
+ ^- manx
+ ;g
+ =url v.i.a.g.new
+ ;* %+ algo
+ c.i.t.c.old
+ c.i.t.c.new
+ ==
+::
+++ parse-json
+ |= j=json
+ ^- parsed-req
+ %- (ot ~[tags+pa data+(om so)]):dejs:format j
+::
+++ parse-url
+ |= url=@t
+ ^- path
+ %- paru (trip url)
+::
+++ make-css-response
+ |= [rid=@ta css=@t]
+ ^- (list card:agent:gall)
+ %^ make-direct-http-cards rid
+ [200 ['Content-Type' 'text/css'] ~]
+ [~ (as-octs:mimes:html css)]
+::
+++ make-auth-redirect
+ |= rid=@ta
+ ^- (list card:agent:gall)
+ %^ make-direct-http-cards rid
+ [307 ['Location' '/~/login?redirect='] ~] ~
+::
+++ make-400
+ |= rid=@ta
+ ^- (list card:agent:gall)
+ %^ make-direct-http-cards
+ rid [400 ~] ~
+::
+++ make-404
+ |= [rid=@ta data=(unit octs)]
+ ^- (list card:agent:gall)
+ %^ make-direct-http-cards
+ rid [404 ~] data
+::
+++ make-direct-http-cards
+ |= [rid=@ta head=response-header.simple-payload:http data=(unit octs)]
+ ^- (list card:agent:gall)
+ :~ [%give %fact ~[/http-response/[rid]] [%http-response-header !>(head)]]
+ [%give %fact ~[/http-response/[rid]] [%http-response-data !>(data)]]
+ [%give %kick ~[/http-response/[rid]] ~]
+ ==
+:: :: :: ::
+::
+:: Algorithms
+::
+:: :: :: ::
+++ algo
+ |= [old=marl new=marl]
+ ^- marl
+ =/ i=@ud 0
+ =/ acc=marl ~
+ |-
+ ?~ new
+ ?. =(~ old)
+ ?: =(%skip -.-.-.old)
+ $(old +.old)
+ :_ acc
+ :_ ~
+ :- %d
+ =/ c=@ud 0
+ |- ^- mart
+ ?~ old
+ ~
+ :- :- (crip (weld "d" <c>))
+ (getv a.g.i.old %key)
+ $(old t.old, c +(c))
+ acc
+ ?: &(?=(^ old) =(%skip -.-.-.old))
+ $(old t.old)
+ ?: =(%m n.g.i.new)
+ $(new t.new, i +(i), acc (snoc acc i.new))
+ =/ j=@ud 0
+ =/ jold=marl old
+ =/ nkey=[n=mane k=tape] [n.g.i.new (getv a.g.i.new %key)]
+ |-
+ ?~ new
+ !!
+ ?~ jold
+ %= ^$
+ new t.new
+ i +(i)
+ acc %+ snoc acc
+ ;n(id <i>)
+ ;+ i.new
+ ==
+ ==
+ ?~ old
+ !!
+ ?: =(%skip n.g.i.jold)
+ $(jold t.jold, j +(j))
+ ?: .=(nkey [n.g.i.jold (getv a.g.i.jold %key)])
+ ?. =(0 j)
+ =/ n=@ud 0
+ =/ nnew=marl new
+ =/ okey=[n=mane k=tape] [n.g.i.old (getv a.g.i.old %key)]
+ |-
+ ?~ nnew
+ ^^$(old (snoc t.old i.old))
+ ?: =(%m n.g.i.nnew)
+ $(nnew t.nnew, n +(n))
+ =/ nnky=[n=mane k=tape] [n.g.i.nnew (getv a.g.i.nnew %key)]
+ ?. .=(okey nnky)
+ $(nnew t.nnew, n +(n))
+ ?: (gte n j)
+ =/ aupd=mart (upda a.g.i.old a.g.i.nnew)
+ ?~ aupd
+ %= ^^$
+ old c.i.old
+ new c.i.nnew
+ i 0
+ acc
+ %= ^^$
+ old t.old
+ new %^ newm new n
+ ;m(id <(add n i)>, key k.nnky);
+ ==
+ ==
+ %= ^^$
+ old c.i.old
+ new c.i.nnew
+ i 0
+ acc
+ %= ^^$
+ old t.old
+ new %^ newm new n
+ ;m(id <(add n i)>, key k.nnky);
+ acc :_ acc
+ [[%c [[%key k.nnky] aupd]] ~]
+ ==
+ ==
+ =/ aupd=mart (upda a.g.i.jold a.g.i.new)
+ ?~ aupd
+ %= ^^$
+ old c.i.jold
+ new c.i.new
+ i 0
+ acc
+ %= ^^$
+ old (newm old j ;skip;)
+ new t.new
+ i +(i)
+ acc %+ snoc acc
+ ;m(id <i>, key k.nkey);
+ ==
+ ==
+ %= ^^$
+ old c.i.jold
+ new c.i.new
+ i 0
+ acc
+ %= ^^$
+ old (newm old j ;skip;)
+ new t.new
+ i +(i)
+ acc :- [[%c [[%key k.nkey] aupd]] ~]
+ %+ snoc
+ acc
+ ;m(id <i>, key k.nkey);
+ ==
+ ==
+ ?: =("text" (getv a.g.i.new %mast))
+ ?: =(+.-.+.-.-.+.-.old +.-.+.-.-.+.-.new)
+ ^$(old t.old, new t.new, i +(i))
+ %= ^$
+ old t.old
+ new t.new
+ i +(i)
+ acc [i.new acc]
+ ==
+ =/ aupd=mart (upda a.g.i.old a.g.i.new)
+ ?~ aupd
+ %= ^$
+ old c.i.old
+ new c.i.new
+ i 0
+ acc ^$(old t.old, new t.new, i +(i))
+ ==
+ %= ^$
+ old c.i.old
+ new c.i.new
+ i 0
+ acc
+ %= ^$
+ old t.old
+ new t.new
+ i +(i)
+ acc :_ acc
+ [[%c [[%key k.nkey] aupd]] ~]
+ ==
+ ==
+ $(jold t.jold, j +(j))
+::
+++ adky
+ |= root=manx
+ |^ ^- manx
+ (tanx root "0" "~")
+ ++ tanx
+ |= [m=manx key=tape pkey=tape]
+ =/ fkey=tape (getv a.g.m %key)
+ =/ nkey=tape ?~(fkey key fkey)
+ ?: =(%$ n.g.m)
+ ;span
+ =mast "text"
+ =key nkey
+ =pkey pkey
+ ;+ m
+ ==
+ =: a.g.m %- mart
+ ?~ fkey
+ [[%key nkey] [[%pkey pkey] a.g.m]]
+ [[%pkey pkey] a.g.m]
+ c.m (tarl c.m nkey)
+ ==
+ m
+ ++ tarl
+ |= [m=marl key=tape]
+ =/ i=@ud 0
+ |- ^- marl
+ ?~ m
+ ~
+ :- %^ tanx
+ (manx i.m)
+ (weld (scow %ud i) (weld "-" key))
+ key
+ $(m t.m, i +(i))
+ --
+::
+++ getv
+ |= [m=mart tag=@tas]
+ ^- tape
+ ?~ m
+ ~
+ ?: =(n.i.m tag)
+ v.i.m
+ $(m t.m)
+::
+++ upda
+ |= [om=mart nm=mart]
+ =/ acc=mart ~
+ |- ^- mart
+ ?~ nm
+ ?~ om
+ acc
+ :_ acc
+ :- %rem
+ =/ omom=mart om
+ |-
+ ?~ omom
+ ~
+ =/ nom=tape +:<n.i.omom>
+ |-
+ ?~ nom
+ [' ' ^$(omom t.omom)]
+ [i.nom $(nom t.nom)]
+ =/ i=@ud 0
+ =/ com=mart om
+ |-
+ ?~ nm
+ !!
+ ?~ com
+ ^$(nm t.nm, acc [i.nm acc])
+ ?~ om
+ !!
+ ?: =(n.i.com n.i.nm)
+ ?: =(v.i.com v.i.nm)
+ ^$(om (oust [i 1] (mart om)), nm t.nm)
+ %= ^$
+ om (oust [i 1] (mart om))
+ nm t.nm
+ acc [i.nm acc]
+ ==
+ $(com t.com, i +(i))
+::
+++ newm
+ |= [ml=marl i=@ud mx=manx]
+ =/ j=@ud 0
+ |- ^- marl
+ ?~ ml
+ ~
+ :- ?: =(i j)
+ mx
+ i.ml
+ $(ml t.ml, j +(j))
+::
+++ paru
+ |= turl=tape
+ ^- path
+ =/ tacc=tape ~
+ =/ pacc=path ~
+ |-
+ ?~ turl
+ ?~ tacc
+ pacc
+ (snoc pacc (crip tacc))
+ ?: =('/' i.turl)
+ ?~ tacc
+ $(turl t.turl)
+ %= $
+ turl t.turl
+ tacc ~
+ pacc (snoc pacc (crip tacc))
+ ==
+ $(turl t.turl, tacc (snoc tacc i.turl))
+:: :: :: ::
+::
+:: Sail
+::
+:: :: :: ::
+++ script-node
+ ^- manx
+ ;script
+ ;+ ;/ script
+ ==
+++ sail-404
+ ^- manx
+ ;html
+ ;head
+ ;meta(charset "utf-8");
+ ==
+ ;body
+ ;span: 404
+ ==
+ ==
+:: :: :: ::
+::
+:: Script
+::
+:: :: :: ::
+++ script
+ ^~
+ %- trip
+ '''
+ let ship;
+ let app;
+ let displayUpdatePath;
+ let channelMessageId = 0;
+ let eventSource;
+ const channelId = `${Date.now()}${Math.floor(Math.random() * 100)}`;
+ const channelPath = `${window.location.origin}/~/channel/${channelId}`;
+ addEventListener('DOMContentLoaded', async () => {
+ ship = document.documentElement.getAttribute('ship');
+ app = document.documentElement.getAttribute('app');
+ displayUpdatePath = document.documentElement.getAttribute('path');
+ await connectToShip();
+ let eventElements = document.querySelectorAll('[event]');
+ eventElements.forEach(el => setEventListeners(el));
+ });
+ function setEventListeners(el) {
+ const eventTags = el.getAttribute('event');
+ const returnTags = el.getAttribute('return');
+ eventTags.split(/\s+/).forEach(eventStr => {
+ const eventType = eventStr.split('/', 2)[1];
+ el[`on${eventType}`] = (e) => pokeShip(e, eventStr, returnTags);
+ });
+ };
+ async function connectToShip() {
+ try {
+ const storageKey = `${ship}${app}${displayUpdatePath}`;
+ let storedId = localStorage.getItem(storageKey);
+ localStorage.setItem(storageKey, channelId);
+ if (storedId) {
+ const delPath = `${window.location.origin}/~/channel/${storedId}`;
+ await fetch(delPath, {
+ method: 'PUT',
+ body: JSON.stringify([{
+ id: channelMessageId,
+ action: 'delete'
+ }])
+ });
+ };
+ const body = JSON.stringify(makeSubscribeBody());
+ await fetch(channelPath, {
+ method: 'PUT',
+ body
+ });
+ eventSource = new EventSource(channelPath);
+ eventSource.addEventListener('message', handleChannelStream);
+ } catch (error) {
+ console.error(error);
+ };
+ };
+ function pokeShip(event, tagString, dataString) {
+ try {
+ let data = {};
+ if (dataString) {
+ const dataToReturn = dataString.split(/\s+/);
+ dataToReturn.forEach(dataTag => {
+ let splitDataTag = dataTag.split('/');
+ if (splitDataTag[0] === '') splitDataTag.shift();
+ const kind = splitDataTag[0];
+ const key = splitDataTag.pop();
+ if (kind === 'event') {
+ if (!(key in event)) {
+ console.error(`Property: ${key} does not exist on the event object`);
+ return;
+ };
+ data[dataTag] = String(event[key]);
+ } else if (kind === 'target') {
+ if (!(key in event.currentTarget)) {
+ console.error(`Property: ${key} does not exist on the target object`);
+ return;
+ };
+ data[dataTag] = String(event.currentTarget[key]);
+ } else {
+ const elementId = splitDataTag.join('/');
+ const linkedEl = document.getElementById(elementId);
+ if (!linkedEl) {
+ console.error(`No element found for id: ${kind}`);
+ return;
+ };
+ if (!(key in linkedEl)) {
+ console.error(`Property: ${key} does not exist on the object with id: ${elementId}`);
+ return;
+ };
+ data[dataTag] = String(linkedEl[key]);
+ };
+ });
+ };
+ fetch(channelPath, {
+ method: 'PUT',
+ body: JSON.stringify(makePokeBody({
+ tags: tagString,
+ data
+ }))
+ });
+ } catch (error) {
+ console.error(error);
+ };
+ };
+ function handleChannelStream(event) {
+ try {
+ const streamResponse = JSON.parse(event.data);
+ if (streamResponse.response !== 'diff') return;
+ fetch(channelPath, {
+ method: 'PUT',
+ body: JSON.stringify(makeAck(streamResponse.id))
+ });
+ const htmlData = streamResponse.json;
+ if (!htmlData) return;
+ let container = document.createElement('template');
+ container.innerHTML = htmlData;
+ if (container.content.firstElementChild.childNodes.length === 0) return;
+ const navUrl = container.content.firstElementChild.getAttribute('url');
+ if (navUrl && (navUrl !== window.location.pathname)) {
+ history.pushState({}, '', navUrl);
+ };
+ while (container.content.firstElementChild.children.length > 0) {
+ let gustChild = container.content.firstElementChild.firstElementChild;
+ if (gustChild.tagName === 'D') {
+ for (const att of gustChild.attributes) {
+ const dkey = att.value;
+ document.querySelector(`[key="${dkey}"]`).remove();
+ };
+ gustChild.remove();
+ } else if (gustChild.tagName === 'N') {
+ const nodeKey = gustChild.firstElementChild.getAttribute('key');
+ const parentKey = gustChild.firstElementChild.getAttribute('pkey');
+ const appendIndex = gustChild.id;
+ let domParent = document.querySelector(`[key="${parentKey}"]`);
+ domParent.insertBefore(gustChild.firstElementChild, domParent.children[appendIndex]);
+ let appendedChild = domParent.querySelector(`[key="${nodeKey}"]`);
+ if (appendedChild.getAttribute('event')) {
+ setEventListeners(appendedChild);
+ };
+ if (appendedChild.childElementCount > 0) {
+ let needingListeners = appendedChild.querySelectorAll('[event]');
+ needingListeners.forEach(child => setEventListeners(child));
+ };
+ appendedChild = appendedChild.nextElementSibling;
+ gustChild.remove();
+ } else if (gustChild.tagName === 'M') {
+ const nodeKey = gustChild.getAttribute('key');
+ const nodeIndex = gustChild.id;
+ let existentNode = document.querySelector(`[key="${nodeKey}"]`);
+ let childAtIndex = existentNode.parentElement.children[nodeIndex];
+ if (existentNode.nextElementSibling
+ && (existentNode.nextElementSibling.getAttribute('key')
+ === childAtIndex.getAttribute('key'))) {
+ existentNode.parentElement.insertBefore(existentNode, childAtIndex.nextElementSibling);
+ } else {
+ existentNode.parentElement.insertBefore(existentNode, childAtIndex);
+ };
+ gustChild.remove();
+ } else if (gustChild.tagName === 'C') {
+ const nodeKey = gustChild.getAttribute('key');
+ const attToRem = gustChild.getAttribute('rem')?.slice(0, -1).split(' ') ?? [];
+ let existentNode = document.querySelector(`[key="${nodeKey}"]`);
+ attToRem.forEach(att => {
+ if (att === 'event') {
+ const eventType = existentNode.getAttribute('event').split('/', 2)[1];
+ existentNode[`on${eventType}`] = null;
+ };
+ existentNode.removeAttribute(att);
+ });
+ gustChild.removeAttribute('key');
+ gustChild.removeAttribute('rem');
+ for (const att of gustChild.attributes) {
+ existentNode.setAttribute(att.name, att.value);
+ if (att.name === 'event') {
+ const eventType = existentNode.getAttribute('event').split('/', 2)[1];
+ existentNode[`on${eventType}`] = null;
+ setEventListeners(existentNode);
+ };
+ };
+ gustChild.remove();
+ } else {
+ const nodeKey = gustChild.getAttribute('key');
+ let existentNode = document.querySelector(`[key="${nodeKey}"]`);
+ existentNode.replaceWith(gustChild);
+ let replacedNode = document.querySelector(`[key="${nodeKey}"]`);
+ if (replacedNode.getAttribute('event')) {
+ setEventListeners(replacedNode);
+ };
+ if (replacedNode.childElementCount > 0) {
+ let needingListeners = replacedNode.querySelectorAll('[event]');
+ needingListeners.forEach(child => setEventListeners(child));
+ };
+ };
+ };
+ } catch (error) {
+ console.error(error);
+ };
+ };
+ function makeSubscribeBody() {
+ channelMessageId++;
+ return [{
+ id: channelMessageId,
+ action: 'subscribe',
+ ship: ship,
+ app: app,
+ path: displayUpdatePath
+ }];
+ };
+ function makePokeBody(jsonData) {
+ channelMessageId++;
+ return [{
+ id: channelMessageId,
+ action: 'poke',
+ ship: ship,
+ app: app,
+ mark: 'json',
+ json: jsonData
+ }];
+ };
+ function makeAck(eventId) {
+ channelMessageId++;
+ return [{
+ id: channelMessageId,
+ action: 'ack',
+ "event-id": eventId
+ }];
+ };
+ '''
+-- \ No newline at end of file