2.3.0 • Published 1 year ago

gotob v2.3.0

Weekly downloads
2
License
-
Repository
github
Last release
1 year ago

gotoв

"The only software that I like is one that I can easily understand and solves my problems. The amount of complexity I'm willing to tolerate is proportional to the size of the problem being solved." --Ryan Dahl

gotoв is a framework for making the frontend of a web application (henceforth webapp).

Current status of the project

The current version of gotoв, v2.3.0, is considered to be stable and complete. Suggestions and patches are welcome. Besides bug fixes, and the completion of the tutorial in one of the appendixes, there are no changes planned.

gotoв is part of the ustack, a set of libraries to build webapps which aims to be fully understandable by those who use it.

Why gotoв?

gotoв is a framework optimized for understanding. Its purpose is to allow you to write webapps in a way that you can fully understand what's going on.

In my experience, understanding leads to short and beautiful code that can last for years in a production setting. It is my sincere hope that you'll be able to use gotoв to create reliable webapps and have a lot of fun while at it.

Because gotoв is optimized for understanding, if anything on this readme strikes you as unclear or confusing, please let me know. I'll be glad to improve the explanation and make it better for you and everyone else.

Installation

gotoв is written in Javascript. You can use it in the browser by loading the pre-built file, gotoB.min.js, in a <script> tag at the top of the <body>:

<script src="gotoB.min.js"></script>

Or you can use this link to use the latest version - courtesy of jsDelivr.

<script src="https://cdn.jsdelivr.net/gh/fpereiro/gotob@d599867a327a74d3c53aa518f507820161bb4ac8/gotoB.min.js"></script>

gotoв uses non-ASCII symbols, so you also must specify an encoding for your document (for example UTF-8) by placing a <meta> tag in the <head> of the document: <meta charset="utf-8">.

gotoв is exclusively a client-side library. Still, you can find it in npm: npm install gotob

Browser compatibility has been tested in the following browsers:

  • Google Chrome 15 and above.
  • Mozilla Firefox 3 and above.
  • Safari 4 and above.
  • Internet Explorer 6 and above.
  • Microsoft Edge 14 and above.
  • Opera 10.6 and above.
  • Yandex 14.12 and above.

The author wishes to thank Browserstack for providing tools to test cross-browser compatibility.

Index

Examples

Hello world

var helloWorld = function () {
   return ['h1', 'Hello, world!'];
}

B.mount ('body', helloWorld);

Counter

var counter = function () {
   return B.view ('counter', function (counter) {
      counter = counter || 0;
      return ['div', [
         ['h2', 'Counter'],
         ['h3', ['Counter is: ', counter]],
         ['button', {
            onclick: B.ev ('set', 'counter', counter + 1)
         }, 'Increment counter']
      ]];
   });
}

B.mount ('body', counter);

Todo list

B.respond ('create', 'todo', function (x) {
   var todo = prompt ('What\'s one to do?');
   if (todo) B.call (x, 'add', 'todos', todo);
});

var todoList = function () {
   return [
      ['style', [
         ['span.action', {color: 'blue', cursor: 'pointer', 'margin-left': 10}],
      ]],
      ['h2', 'Todos'],
      B.view ('todos', function (todos) {
         return ['ul', dale.go (todos, function (todo, index) {
            return ['li', ['', todo, ['span', {'class': 'action', onclick: B.ev ('rem', 'todos', index)}, 'Remove']]];
         })];
      }),
      ['button', {onclick: B.ev ('create', 'todo')}, 'Create todo']
   ];
}

B.mount ('body', todoList);

Input

var input = function () {
   return B.view ('input', function (input) {
      return ['div', [
         ['input', {value: input, oninput: B.ev ('set', 'input'), onchange: B.ev ('set', 'input')}],
         ['p', ['Value of input is ', ['strong', input]]]
      ]];
   });
}

B.mount ('body', input);

Textarea

var textarea = function () {
   return B.view ('textarea', function (textarea) {
      return ['div', [
         ['textarea', {value: textarea, oninput: B.ev ('set', 'textarea')}],
         ['p', ['Value of textarea is ', ['strong', textarea]]]
      ]];
   });
}

B.mount ('body', textarea);

Select

var select = function () {
   var options = ['Select one', 'Elephant Island', 'South Georgia'];
   return B.view ('select', function (select) {
      return ['div', [
         ['select', {onchange: B.set ('set', 'select')}, dale.go (options, function (option) {
            return ['option', {value: option !== 'Select one' ? option : ''}, option];
         })]
      ]];
   });
}

B.mount ('body', select);

Radio

var radio = function () {
   var options = ['Clics', 'Peperina', 'Bicicleta'];
   return B.view ('radio', function (radio) {
      return ['div', [
         dale.go (options, function (option) {
            return [
               ['input', {type: 'radio', name: 'radio', checked: radio === option, onchange: B.ev ('set', 'radio'), value: option}],
               ['label', ' ' + option],
               ['br'],
            ];
         }),
         ['p', ['Value of radio is ', ['strong', radio]]]
      ]];
   });
}

B.mount ('body', radio);

Checkboxes

B.respond ('toggle', 'checkboxes', function (x, option) {
   var index = (B.get ('checkboxes') || []).indexOf (option);
   if (index === -1) B.call (x, 'add', 'checkboxes', option);
   else              B.call (x, 'rem', 'checkboxes', index);
});

var checkboxes = function () {
   var options = ['O\'ahu', 'Maui', 'Kauai'];
   return B.view ('checkboxes', function (checkboxes) {
      checkboxes = checkboxes || [];
      return ['div', [
         dale.go (options, function (option) {
            return [
               ['input', {type: 'checkbox', checked: teishi.inc (checkboxes, option), onclick: B.ev ('toggle', 'checkboxes', option)}],
               ['label', ' ' + option],
               ['br'],
            ];
         }),
         ['p', ['Selected islands: ', ['strong', checkboxes.sort ().join (', ')]]]
      ]];
   });
}

B.mount ('body', checkboxes);

Table

B.call ('set', 'table', [
   {id: 1, name: 'Top of line',     price: 100},
   {id: 2, name: 'Value for money', price: 65},
   {id: 3, name: 'Last resort',     price: 24}
]);

var table = function () {
   return B.view ('table', function (table) {
      table = table || [];
      return ['table', [
         ['tr', dale.go (table [0], function (v, k) {
            return ['th', k];
         })],
         dale.go (table, function (v) {
            return ['tr', dale.go (v, function (v2) {
               return ['td', v2];
            })];
         })
      ]];
   });
}

B.mount ('body', table);

You can find more examples here.

Introduction

gotoв is a framework for writing the frontend of a webapp. In the case of a webapp, the frontend consists of an user interface implemented with HTML and (almost always) some js that runs on the browser.

gotoв provides a solution to the two main things that a frontend framework must do:

  1. Generate HTML.
  2. Manage state.

Let's take the example of a shopping cart. A shopping cart is an HTML page that displays a list of products that an user is interested in purchasing. The user can interact with the page to add and remove articles - and other parts of the page (for example, the total amount) will change accordingly.

To implement the shopping cart, we need to generate HTML to make it appear on the user's screen. Some parts of the shopping cart will change according to the selection of products (list of products, amounts), whlie others will remain the same (like the header or footer). It follows that the HTML must take into account both "fixed" elements and "variable" elements.

Besides generating HTML, we need also to keep track of the products and quantities the user has entered. This is the state. This state is essential, because without it the HTML page would be static and would not respond to user input! This state also has to be sent to the server to be processed when the purchase is finalized.

The HTML and the state are deeply interlocked. They interact in a yin-yang manner:

  • The state determines how the HTML will look.
  • Certain elements in the HTML (buttons, inputs) will perform changes to the state.

For example:

  • When the user loads the shopping cart for the first time, the HTML shows an empty cart (state -> HTML).
  • The user clicks on a button and adds a product (HTML -> state).
  • Because a product was added, the HTML is changed (state -> HTML).

An interface can be understood as a function of the state, which returns HTML. This HTML, in turns, contains elements that can trigger further changes to the state. The hard part of implementing frontends is to fully close the circle, and make sure that when the state is updated, the HTML also changes. This is also the reason why frontend frameworks exist and are widely used.

All of the above is valid for any type of webapp. Let's explore now how gotoв solves these problems:

  1. gotoв creates all the HTML in the browser using js: the presentation logic is fully separated from the server and the full power of js is available to generate HTML.
  2. gotoв centralizes all the state into a js object: instead of having data spreaded out in different places (DOM elements, js variables), it centralizes all the state in a single location that can be easily queried and updated.
  3. gotoв uses events to update the state and to update the HTML: by using events, the app can be updated efficiently without having to manually track dependencies between parts of the app.

Let's see each of these in turn:

Generating HTML using js

gotoв uses js object literals to generate HTML. Object literals are mere arrays ([...]) and objects ({...}) that conform to certain shapes. We call these literals liths. Let's see a few examples of some liths and their corresponding HTML:

  • ['p', 'Hello'] -> <p>Hello</p>
  • ['div', {class: 'nice'}, 'Cool'] -> <div class="nice">Cool</div>
  • ['div', ['p', {id: 'nested'}, 'Turtles']] -> <div><p id="nested">Turtles</p></div>

In general, a lith is an array with one to three elements. The first element is a string indicating the tag. There can be a second element for specifying attributes, which is an object. Finally, you can add contents to the lith; these contents can be a string, a number or another lith.

Besides liths, we also can write an array containing multiple liths, which is affectionally called lithbag. For example:

  • [['p'], ['p']] -> <p></p><p></p>

A lithbag can also be a collection of text and number fragments. For example:

  • ['i am', 'a', 1337, 'lithbag'] -> i ama1337lithbag

You can put a lithbag as the contents to another lith:

  • ['div', ['Some', ' ', 'text']] -> <div>Some text</div>

Rather than writing standalone liths or lithbags, gotoв expects you to write functions that return liths or lithbags. For example, this function returns HTML for a hello world page:

var helloWorld = function () {
   return ['h1', 'Hello, world!'];
}

If you come from other frontend frameworks, these functions are called views. To emphasize the fact that in gotoв views are always functions, we'll call them vfuns (short for view functions).

It is possible and even handy (but not required) to generate CSS with gotoв (see the details here).

Using js object literals to generate HTML has two advantages:

  • Because object literals are part of js, the views can live together with the rest of the code.
  • Because object literals are just data, they're very easy to manipulate. You can write conditionals and loops in js that will output different object literals.

Takeaway: use vfuns to generate HTML.

A single store for all the state

gotoв stores all the state of the application (rather, all the state that belongs to the frontend) into a single object. This is a plain js object. What makes it powerful is the fact that it is the single source of truth of the application. We call this object the store.

{
   // here is all the state!
}

The store is located at B.store and gotoв automatically creates it when the app is loaded. B, by the way, is the global variable where gotoв is available.

The following are examples of what can (and should!) be contained on the store:

  • Data brought from the server.
  • Name of the page that is currently being displayed.
  • Data provided by the user that hasn't been submitted yet to the server.

Takeaway: if it affects what's displayed on the screen or what is submitted to the server, it belongs in the store.

Using events

gotoв structures all operations through events. All actions to be performed on the webapp can be modeled as events. This includes updating B.store, which is updated by gotoв's event system instead of being modified directly.

The function for triggering an event is B.call. We prefer the term call instead of other terms normally used with events (such as trigger or fire) because we see events as a form of communication. An event is a call to one or more parts of your code that might in turn respond to that call.

B.call receives as arguments a verb, a path and optional extra arguments.

Events are useless until another part of the program responds to them. Traditionally, these are called event listeners but we call these responders, since they respond to an event being called. To create responders, we will use the function B.respond, which we'll cover in a later section. For now, all you need to know is that responders are defined with a verb and a path (exactly like events) and are matched (triggered) by events with matching verbs and paths.

gotoв provides three built-in responders for modifying B.store: set, add and rem. These responders are already created and allow you to modify the store. Let's see them through examples:

// At the beginning, B.store is merely an empty object

// We now call an event with verb `set`, path `username` and `mono` as its first argument.
B.call ('set', 'username', 'mono');

// Now, B.store is {username: 'mono'}

// We now call an event with verb `set`, path `['State', 'page']` and `main` as its first argument.
B.call ('set', ['State', 'page'], 'main');

// Now, B.store is {username: 'mono', State: {page: 'main'}}

// We now call an event with verb `rem`, path `[]` and `username` as its first argument.
B.call ('rem', [], 'username');

// Now, B.store is {State: {page: 'main'}}

// We now call an event with verb `rem`, path `State` and `page` as its first argument.
B.call ('rem', 'State', 'page');

// Now, B.store is {State: {}}

// We now call an event with verb `set`, path `['Data', 'items']` and `['foo', 'bar']` as its first argument.
B.call ('set', ['Data', 'items'], ['foo', 'bar']);

// Now, B.store is {State: {}, Data: {items: ['foo', 'bar']}}

// We now call an event with verb `add`, path `['Data', 'items']` and `boo` as its first argument.
B.call ('add', ['Data', 'items'], 'boo');

// Now, B.store is {State: {}, Data: {items: ['foo', 'bar', 'boo']}}

// We now call an event with verb `rem`, path `['Data', 'items']` and `0` as its first argument.
B.call ('rem', ['Data', 'items'], 0);

// Now, B.store is {State: {}, Data: {items: ['bar', 'boo']}}

It is important to note that events can be used for things other than updating B.store, as we will see later.

Takeaway: modify B.store through events, using B.call.

Updating the page when the store changes

gotoв provides B.view, a function for creating views that automatically update themselves when the store changes. To make the app more understandable (and efficient), views can depend on a specific part of the store, instead of depending on the whole state. This means that if a view depends on a part X of the store, then if Y is modified (and Y is not contained inside X, nor X inside Y), the view will remain unchanged.

Let's see an example:

var counter = function () {
   return B.view ('counter', function (counter) {
      counter = counter || 0;
      return ['h2', 'The counter is ' + counter];
   });
}

B.mount ('body', counter);

Whenever B.store.counter is updated, the h2 element will be automatically updated.

B.call ('set', 'counter', 1);

// <h2>The counter is 1</h2>

B.call ('set', 'counter', 2);

// <h2>The counter is 2</h2>

Updating the state from the page

You might be wondering: how can we trigger events from the DOM itself? The example above doesn't show how to place a button that could increase the counter. One way of doing it would be the following:

var counter = function () {
   return B.view ('counter', function (counter) {
      counter = counter || 0;
      return ['div', [
         ['h2', 'The counter is ' + counter],
         ['button', {
            onclick: "B.call ('set', 'counter', " + (counter + 1) + ")"
         }, 'Increment counter']
      ]];
   });
}

B.mount ('body', counter);

But it is much better to use B.ev, which will create a stringified call to B.call that we can put within the onclick attribute directly.

var counter = function () {
   return B.view ('counter', function (counter) {
      counter = counter || 0;
      return ['div', [
         ['h2', 'The counter is ' + counter],
         ['button', {
            onclick: B.ev ('set', 'counter', counter + 1)
         }, 'Increment counter']
      ]];
   });
}

B.mount ('body', counter);

Summary

And that, in a nutshell, is how gotoв works:

  1. Views are functions that return object literals (liths) to generate HTML.
  2. The global store centralizes all of the state.
  3. Events perform all actions, including updating the global store.
  4. Views depend on parts of the store and are automatically updated whenever the relevant part of the store changes.
  5. Views can contain DOM elements that can call events.

An app written with gotoв will mostly consist of views and responders, and most of its logic will live in vfuns (view functions) and rfuns (responder functions).

FAQ

Why did you write another javascript framework?!?

I experience two difficulties with existing javascript frontend frameworks:

  1. They are hard to understand, at least for me.
  2. They are constantly changing.

The combination of these two characteristics mean that I must constantly spend an enormous amount of time and effort to remain an effective frontend developer. Which makes me unhappy, because complex things frustrate me and I am quite lazy when it comes to things I don't enjoy.

Rather than submit to this grind or reject it altogether (and missing out the possibility of creating my webapps), I took a third way out, by deciding to write a frontend framework that:

  1. Is optimized for understanding.
  2. Built on fundamentals, so that the framework will change less and less as time goes by.

And, of course, gotoв must be very useful for building a real webapp.

Is gotoв for me?

gotoв is for you if:

  • You have freedom to decide the technology you use.
  • Complexity is a massive turn-off for you.
  • You like old (ES5) javascript.
  • You miss not having to compile your javascript.
  • You enjoy understanding the internals of a tool, so that you can then use it with precision and confidence.
  • You like technology that's a bit strange.
  • You want to build a community together with me.

gotoв is not for you if:

  • You need to support browsers without javascript.
  • You need a widely supported framework, with a large community of devs and tools.
  • You are looking for a framework that is similar to Angular, Ember or React.
  • You need a very fast framework; gotoв chooses simplicity over performance in a couple of critical and permanent respects.

What does gotoв care about?

  • Ease of use: 90% of the functionality you need is contained in four functions (one for calling an event (B.call), one for setting up event responders (B.respond), one for stringifying an event call into a DOM attribute (B.ev) and one for creating dynamic DOM elements which are updated when the store changes (B.view)). There's also three more events for performing data changes that you'll use often. But that's pretty much it.
  • Fast reload: the edit-reload cycle should take under two seconds. No need to wait until no bundle is completed.
  • Smallness: gotoв and its dependencies are < 2048 lines of consistent, annotated javascript. In other words, it is less than 2048 lines on top of vanilla.js.
  • Batteries included: the core functionality for building a webapp is all provided. Whatever libraries you add on top will probably be for specific things (nice CSS, a calendar widget, etc.)
  • Trivial to set up: add <script src="https://cdn.jsdelivr.net/gh/fpereiro/gotob@d599867a327a74d3c53aa518f507820161bb4ac8/gotoB.min.js"></script> at the top of the <body>.
  • Everything in plain sight: all properties and state are directly accessible from the javascript console of the browser. DOM elements have stringified event handlers that can be inspected with any modern browser.
  • Performance: gotoв itself is small (~15kB when minified and gzipped, including all dependencies) so it is loaded and parsed quickly. Its view redrawing mechanism is reasonably fast.
  • Cross-browser compatibility: gotoв is intended to work on virtually all the browsers you may encounter. See browser current compatibility above in the Installation section.

What does gotoв not care about?

  • Browsers without javascript: gotoв is 100% reliant on client-side javascript - if you want to create webapps that don't require javascript, gotoв cannot possibly help you create them.
  • Post-2009 javascript: everything's written in a subset of ES5 javascript. This means no transpilation, no different syntaxes, and no type declarations. You can of course write your application in ES6 or above and gotoв will still work.
  • Module loading: gotoв and its dependencies happily and unavoidably bind to the global object. No CommonJS or AMD.
  • Build/toolchain integration: there's no integration with any standard tool for compiling HTML, CSS and js. gotoв itself is pre-built with a 50-line javascript file.
  • Hot-reloading: better get that refresh finger ready!
  • Plugin system: gotoв tries to give provide you all the essentials out of the box, without installation or configuration.
  • Object-oriented programming: gotoв uses objects mostly as namespaces. There's no inheritance and no use of bind. Classes are nowhere to be found.
  • Pure functional programming: in gotoв, side-effects are expressed as events. The return values from event handlers are ignored, and every function has access to the global store. There's no immutability; the global state is modified through functions that update it in place.

API reference

Before reading this section, it is highly recommended that you read the introduction to have a conceptual overview of gotoв.

The gotoв object: B

gotoв is automatically loaded on the global variable B.

B.v contains a string with the version of gotoв you're currently using. B.t contains a timestamp indicating the moment when the library is loaded - which can be an useful reference point for performance measurements.

While B is a global variable, I suggest assigning B to a local variable to make your code clearer:

var B = window.B;

gotoв automatically loads its five dependencies on the following global variables:

You can use these libraries at your discretion. If you do so, I recommend also assigning local variables to them, for clarity's sake:

var dale = window.dale, teishi = window.teishi, lith = window.lith, c = window.c;

You may have noticed I omitted recalc in the line of code above. This is because you'll most likely use this recalc through gotoв's functions instead of using it directly.

B.mount

B.mount is the function that places your outermost view(s) on the page. This function takes two arguments: the target (the DOM element where the HTML will be placed) and a vfun (the function that generates the liths that will be converted to HTML). For example:

var helloWorld = function () {
   return ['h1', 'Hello, world!'];
}

B.mount ('body', helloWorld);

target must always be a string. It can be either 'body' or a string of the form '#ID', where ID is the id of a DOM element that is already in the document. If target is not present in the document, the function will report an error and will return false.

B.mount will execute the vfun passing no parameters to it. This function must return either a lith or a lithbag. If the function doesn't return a valid lith or lithbag, B.mount will report an error and return false.

The HTML generated will be placed at the bottom of the target. In the example above, the <body> will look like this:

<body>
   <h1>Hello, world!</h1>
</body>

Optionally, the target string can have the form TAG#ID. For example, if you have an element <div id="container"></div> already inside the <body>, you can use either '#container' or 'div#container' as the target.

// Create first a `div` with `id` `container`
document.body.innerHTML += '<div id="container"></div>';

B.mount ('#container', function () {
   return ['p', 'Hello'];
});

B.unmount is a function to undo what was done by B.mount. It receives a target which is just like the target passed to B.mount. It will remove all of the HTML contained inside target - not just the HTML added there by B.mount. If an invalid or non-existing target is passed to B.unmount, the function will report an error and return false.

B.unmount ('#container');

// `document.body.innerHTML` will now be an empty string.

Both B.mount and B.unmount will return undefined if the operation is successful.

In case you're wondering what's going under the hood, B.mount does very little: it just validates its inputs, executes fun and places the resulting HTML in the DOM. B.unmount is almost equally simple, except that it is in charge of removing the responders of the deleted views through B.forget. Which takes us to the following topic - events!

Introduction to the event system: B.call, B.respond, B.responders, B.forget

gotoв is built around events. Its event system considers events as communication between different parts of the app with each other. Some parts of the program perform calls and other parts of the program respond to those calls.

The two nouns with which we can structure this paradigm is: event and responder. The two corresponding verbs are call and match: events are called (almost always by responders), responders are matched by events.

An event call can match zero, one or multiple responders. When a responder is matched, it is executed.

Events are more general than mere function calls. When a function is called, the function must exist and must be defined only once:

function call -> a function is executed

With events, we can have one-to-none, one-to-one or one-to-many execution relationships:

event call -> (nothing happens, no responders were matched)

event call -> exactly one responder matched

event call -> this responder is matched
         |--> this responder is also matched

This generality of events is extremely useful to model and write the code of interfaces, which is highly interconnected, asynchronous and triggered by user interactions. When a responder is matched by an event, its associated function (which we call rfun or responder function) is executed. The ultimate purpose of events and responders is to execute the rfuns (responder functions) at the right time; rfuns, together with their responders and with matching events, can replace direct function calls in most of the logic of the frontend.

Events are called with the function B.call, which takes the following parameters:

  • A verb, which is a string. For example: 'get', 'set' or 'someverb'.
  • A path, which can be either a string, an integer, or an array with zero or more strings or integers. For example, 'hello', 1, or ['hello', '1']. If you pass a single string or integer, it will be interpreted as an array containing that element (for example, 'hello' is considered to be ['hello'] and 0 is considered to be [0]).
  • Optional extra arguments of any type (we will refer to them as args later). These arguments will be passed to matching responders.

If invalid parameters are passed to B.call, the function will report an error and return false.

An invocation to B.call will call an event once.

Responders are created with the function B.respond, which takes the following parameters:

  • verb, which can be a string or a regex.
  • path, which can be a string, an integer, a regex, or an array containing those types of elements.
  • options, an optional object with additional options.
  • rfun, the function that will be executed when the responder is matched. rfun is short for responder function. The responder receives a context object x as its first argument and optional extra arguments (args). We'll see what's inside x in a later section.

If invalid parameters are passed to B.respond, the function will report an error and return false.

If the invocation is valid, B.respond will create a responder and place it in B.responders. This responder will be matched (executed) by any matching event calls throughout the course of the program. Seen from this perspective, a single call to B.respond has a more lasting effect than a call to B.call.

When does an event match a responder? A full answer is contained here and here. The short answer is: when both the verb and the path of the event and the responder match.

Let's define the following events and responders:

B.respond ('foo', 0, rfun)          // responder A
B.respond ('foo', '*', rfun)        // responder B
B.respond ('foo', ['*', '*'], rfun) // responder C
B.respond ('bar', [], rfun)         // responder D

B.call ('foo', 0);      // event A
B.call ('foo', 1);      // event B
B.call ('foo', [0, 1]); // event C
B.call ('bar', 0);      // event D

What will happen?

  • Event A:
    • Will match responder A, because both their verb ('foo') and path (0) are identical.
    • Will match responder B, because their verb ('foo') is identical, and because the responder's path ('*') will be matched by any event's path with length 1.
    • Will not match responder C, because while their verb ('foo') is identical, responder C path's will be matched only by events with paths of length 2.
    • Will not match responder D, because their verb is different ('foo' vs 'bar').
  • Event B:
    • Will not match responder A, because while their verb is identical, their path is not.
    • Will match responder B, because their verb is identical and because the responder's path will be matched by any event's path with length 1.
    • Will not match responder C, because while their verb ('foo') is identical, responder C path's will be matched only by events with paths of length 2.
    • Will not match responder D, because their verb is different ('foo' vs 'bar').
  • Event C:
    • Will not match responders A or B, because while their verb ('foo') is identical, their paths don't match.
    • Will match responder C, because their verb ('foo') is identical and because responder C path's will be matched the path of event C, which has length 2.
    • Will not match responder D, because their verb is different ('foo' vs 'bar').
  • Event D:
    • Will not match responders A, B or C, because their verbs are different.
    • Will not match responder D, because while their verb ('bar') is identical, responder D will only be matched by events with paths of length 0.

Notice that in the example above we called B.respond before B.call; if we had done this the other way around, the event calls would have had no effect since the responders would have not been registered yet.

Wildcards ('*') and regexes can be used in the verbs and path elements of responders, but not of events.

Regarding the optional options object passed to B.respond, please check recalc's documentation for a full specification; the most useful ones are:

  • id (a string or integer), to determine the responder's id
  • priority, an integer value and determines the order of execution if multiple responders are matched by an event call; by default, priority is 0, but you can specify a number that's larger or smaller than that (the higher the priority, the earlier the responder will be executed). If two responders are matched and have the same priority, the oldest one takes precedence
  • match, a function to let the responder decide whether it should be matched by any incoming event; this function supersedes the default verb and path matching logic with your own custom logic; the provided function receives two arguments, an object with the verb and path of the event being called, plus the responder itself.

Responders are stored in B.responders. To remove a responder, invoke B.forget, passing the id of the responder. The id of the responder will be that provided by you when creating it (if you passed it as an option), or the automatically generated id which will be returned if the invocation to B.respond was successful.

Data: B.store and the built-in data responders ('set', 'add' and 'rem')

As we saw in the introduction, all the state and data that is relevant to the frontend should be stored inside B.store, which is a plain object where all the data is contained.

Rather than modifying the store directly, gotoв requires you to do it through the three built-in data responders, which have the following verbs: 'set', 'add' and 'rem'. Whenever you call an event with one of these three verbs (set/add/rem), these responders will be executed and they will do two things:

  1. Update the store.
  2. Call a change event.

change events are very important, because these are the ones that update the page! In fact, B.view, the function for creating reactive elements (which will cover below), creates event responders that are matched when change events are called.

Let's see now each of these responders:

set

The first data responder is set. This responder sets data into a particular location inside B.store. It takes a path and a value. path can be an integer, a string, or an array containing integers and strings (as any responder's path, really); path represents where we want to set the value inside B.store.

Let's see now a set of examples. In each of these examples, I'll consider that we start with an empty B.store so that we don't carry data from one example to the other.

B.call ('set', 'title', 'Hello!');

// B.store is now {title: 'Hello!'}

As you can see, we pass 'set' as the first argument; then we pass the path ('title') and finally the value ('Hello!'). set also allows you to set nested properties:

B.call ('set', ['user', 'username'], 'mono');

// B.store is now {user: {username: 'mono'}}

Notice how B.store.user was initialized to an empty object. Because the second element of the path is a string (username), the set data responder knows that B.store.user must be initialized to an object. Contrast this to the following example:

B.call ('set', ['users', 0], 'mono');

// B.store is now {users: ['mono']}

In the example above, B.store.users is initialized to an array instead, since 0 is an integer and integers can only be the keys of arrays, not objects.

If your path has length 1, you can use a single integer or object as path:

B.call ('set', 'foo', 'bar');

// B.store is now {foo: 'bar'}

If you pass an empty path, you will overwrite the entire B.store. In this case, value can only be an array or object, otherwise an error will be reported and no change will happen to B.store.

B.call ('set', [], []);

// B.store is now []

B.call ('set', [], 'hello');

// B.store still is [], the invocation above will report an error and do nothing else.

B.call ('set', [], {});

// B.store is now {}

set will overwrite whatever part of the existing store stands in its way. Let's see an example:

B.call ('set', ['Data', 'items'], [0, 1, 2]);

// B.store is now {Data: {items: [0, 1, 2]}}

B.call ('set', ['Data', 'key'], 'val');

// B.store is now {Data: {items: [0, 1, 2], key: 'val'}}

B.call ('set', ['Data', 0], 1);

// B.store is now {Data: [1]}

In the example above, when we set ['Data', 'key'], ['Data', 'items'] is left untouched. However, when we set ['Data', 0] to 1, that assertion requires that Data be an array. Because it is an object, it will be overwritten completely and set to an array. This would also happen if Data were an array and a subsequent assertion required it being an object.

In summary, set will preserve the existing keys on the store unless there is a type mismatch, in which case it will overwrite the required keys with the necessary arrays/objects.

add

The second data responder is add. This responder puts elements at the end of an array. It takes a path, plus zero or more elements that will be placed in the array. These elements can be of any type.

B.call ('set', ['Data', 'items'], []);

// B.store is now {Data: {items: []}}

B.call ('add', ['Data', 'items'], 0, 1, 2);

// B.store is now {Data: {items: [0, 1, 2]}}

B.call ('add', ['Data', 'items']);

// B.store is still {Data: {items: [0, 1, 2]}}

If path points to a location with value undefined, the array will be created automatically:

B.call ('add', ['Data', 'items'], 0, 1, 2);

// B.store is now {Data: {items: [0, 1, 2]}}

If no elements are passed to add but path points to an undefined value, the containing array will still be created.

B.call ('add', ['Data', 'items']);

// B.store is now {Data: {items: []}}

If path points to a location that is neither undefined nor an array, an error will be reported and no change will happen to B.store.

rem

The third and final data responder is rem. This responder removes keys from either an array or an object within the store. Like the other data responders, it receives a path, plus zero or more keys that will be removed.

B.call ('add', ['Data', 'items'], 'a', 'b', 'c');

// B.store is now {Data: {items: ['a', 'b', 'c']}}

B.call ('rem', ['Data', 'items'], 1);

// B.store is now {Data: {items: ['a', 'c']}}

B.call ('rem', 'Data', 'items');

// B.store is now {Data: {}}

B.call ('rem', [], 'Data');

// B.store is now {}

If path points to an array, the keys must all be integers. If path points to an object, the keys must instead be all strings. If path points to neither an array nor an object, rem will report an error and do nothing.

B.call ('add', ['Data', 'items'], 'a', 'b', 'c');

// B.store is now {Data: {items: ['a', 'b', 'c']}}

B.call ('rem', ['Data', 'items'], 'a');

// The last invocation will report an error and make no change on B.store

B.call ('rem', 'Data', 0);

// The last invocation will also report an error and make no change on B.store

B.call ('rem', ['Data', 'items', 0], 'foo');

// The last invocation will also report an error and make no change on B.store

An exception to the above rule is that if path points to undefined, rem will not produce any effect but no error will be printed.

B.call ('rem', ['Data', 'foo'], 'bar');

// Nothing will happen.

Nothing will happen also if you pass no keys to remove.

B.call ('rem', ['Data', 'items']);

// Nothing will happen.

You can pass multiple keys to remove in one call.

B.call ('set', ['Data', 'items'], ['a', 'b', 'c']);

B.call ('rem', ['Data', 'items'], 0, 1);

// B.store is now {Data: {items: ['c']}}

Instead of passing the keys as arguments, you can also pass them all together as an array of keys.

// These two invocations are equivalent:
B.call ('rem', ['Data', 'items'], 0, 1);
B.call ('rem', ['Data', 'items'], [0, 1]);

// These two invocations are equivalent:
B.call ('rem', [], 'Data', 'State');
B.call ('rem', [], ['Data', 'State']);

Event calls from the DOM: B.ev

Since gotoв applications are structured around events and responders, user interactions must call events. This means that certain DOM elements need to call gotoв events from from their native event handlers (for example, the onclick should invoke B.call). For this purpose, you can use the function B.ev, which creates stringified event handlers that we can pass to DOM elements, in order to trigger events from them. Let's see an example:

var button = function () {
   return ['button', {
      onclick: B.ev ('do', 'it')
   }, 'Do it!'];
}

B.mount ('body', button);

When the button above is placed in the DOM, clicking on it will call an event with verb do and path it - in other words, it's the equivalent of running B.call ('do', 'it').

B.ev takes as arguments a verb, a path, and optional further arguments. In fact, it takes the same arguments as B.call! This is not a coincidence, since B.ev generates a string that, when executed by a javascript event, will perform a call to B.call with the same arguments.

Let's now see another example, to illustrate other aspects of B.ev: we'll create a button that, when clicked, will call an event with verb submit and path data.

['button', {onclick: B.ev ('submit', 'data')}]

You can pass extra arguments when calling an event. For example, if you want to pass an object of the shape {update: true} you can instead write:

['button', {onclick: B.ev ('submit', 'data', {update: true})}]

You can pass all sorts of things as arguments:

['button', {onclick: B.ev ('submit', 'data', null, NaN, Infinity, undefined, /a regular expression/)}]

If you need to access properties that are within the event handler (like event or this), you can do so as follows:

['button', {onclick: B.ev ('submit', 'data', {raw: 'this.value'})}]

These are called raw arguments, because they are passed as they are, without stringifying them.

Any responder matched by this event will this.value as its first argument, instead of the string 'this.value'. You could also pass the event instead:

['button', {onclick: B.ev ('submit', 'data', {raw: 'event'})}]

You can pass multiple raw arguments. For example, if you want to pass both this.value and event you can write this:

['button', {onclick: B.ev ('submit', 'data', {raw: 'this.value'}, {raw: 'event'})}]

If an object has a key raw but its value is not a string, it will be considered as a normal argument instead:

['button', {onclick: B.ev ('submit', 'data', {raw: 0})}]

The event responder above will receive {raw: 0} as its first argument.

If you pass an object with a raw key that contains a string, other keys within that object will be ignored.

['button', {onclick: B.ev ('submit', 'data', {raw: 'this.value', ignored: 'key'})}]

If you want to call more than one event within the same user interaction, you can do it by wrapping the event arguments into an array, and passing each array as an argument to B.ev.

['button', {onclick: B.ev (['submit', 'data'], ['do', ['something', 'else']])}]

If the onclick handler for the button above is called, B.call will be called twice, first with 'submit', 'data' as arguments and then with 'do', ['something', 'else'] as arguments.

If you need to submit an event only if a condition is met, you can use an empty array to signal a no-op.

['button', {onclick: B.ev (cond ? ['submit', 'data'], [])}]

The same goes in the context of multiple events, out of which a single one should happen conditionally.

['button', {onclick: B.ev (['submit', 'data'], cond ? ['do', 'something'], [])}]

If invalid inputs are passed to B.ev, the function will report an error and return false.

Reactive views: B.view

An essential part of gotoв (or of any frontend framework, really) is the ability to write reactive views. What does reactive mean? It means that the view is automatically updated when the information on which it depends has changed - in other words, it reacts to relevant changes on the store.

Let's go back to the counter example we saw earlier:

var counter = function () {
   return B.view ('counter', function (counter) {
      counter = counter || 0;
      return ['div', [
         ['h3', ['Counter is: ', counter || 0]],
         ['button', {
            onclick: B.ev ('set', 'counter', (counter || 0) + 1)
         }, 'Increment counter']
      ]];
   });
}

B.mount ('body', counter);

As you can see above, B.view takes two arguments:

  • A path.
  • A vfun (view function). Recall that vfuns are functions that return liths. This function receives as an argument the value of the store at path. This function must always return a lith.

When B.store.counter is updated, the vfun will be executed again and the view updated.

If you enter the following command on the developer console to update the store: B.call ('set', 'counter', 1), you will notice that the view gets automatically updated!

If you, however, try to update B.store.counter directly by entering B.store.counter = 2, you'll notice that... nothing happens! This is because you changed the store directly instead of using an event. Most of the time, you'll change the store through events - though later we'll see how you can sidestep the event system to update the store directly.

B.view takes a path and a vfun as arguments. The path is exactly like the path passed to B.call, B.listen and B.ev and can be any of the following:

  • A string: counter.
  • An array of strings and integers: ['Data', 'counter'].
  • An array of arrays of strings and integers: [['Data', 'counter'], ['State', 'page']].

If the path is counter, then the view will be updated when B.store.counter changes. If the path is instead ['Data', 'counter'], then the view will be updated when B.store.Data.counter changes.

By the way, if you passed ['counter'] instead of 'counter' as the path, the result would be the same: B.view ('counter', ... is the same as B.view (['counter'], ....

If the path is a list of paths, as [['Data', 'counter'], ['State', 'page']], then the view will be updated when either Data.counter or State.page change. If you pass multiple paths, the vfun will receive multiple arguments, one per path passed, each of them with the value of the relevant part of the store.

If you pass multiple paths to B.view, the view will be updated when any of the corresponding store elements change:

var dashboard = function () {
   return B.view ([['stockPrice'], ['username']], function (stockPrice, username) {
      return ['div', [
         ['h3', ['Hi ', username]],
         ['h4', ['The current stock price is: ', stockPrice, 'EUR']]
      ]];
   });
}

B.mount ('body', dashboard);

// Here, the dashboard will have neither a name nor a stock price.

B.call ('set', 'username', 'Oom Dagobert');

// The dashboard now will display an username printed, but no stock price.

B.call ('set', 'stockPrice', 140);

// Now the dashboard will print both an username and a stock price.

The vfuns must return a single lith, not a lithbag. For example:

var validVfun1 = function () {
   return ['h1', 'Hello'];
}

var validVfun2 = function () {
   return ['div', [
      ['h2'],
      ['h3']
   ]];
}

// This view is invalid because it returns a lithbag.
var invalidVfun1 = function () {
   return [
      ['h2'],
      ['h3']
   ];
}

// The view is invalid because it returns `undefined`.
var invalidVfun2 = function () {
   return;
}

By requiring every view to return a lith, there's a 1:1 relationship between a view and a DOM element. This makes both debugging and the implementation of the library simpler. (Why is the simplicity of the implementation important? Because gotoв is also meant to be understood, not just used. And simpler implementations are easier to understand).

If its inputs are valid, B.view returns the lith produced by the vfun passed as its second argument. Besides that, it sets up a responder that will be matched when a change event is fired with a path that was passed to B.view.

If it receives invalid inputs, or the vfun doesn't return a lith, B.view will report an error and return false.

Each invocation to B.view creates a responder with a verb change. gotoв uses the event system itself to redraw views, so that everything (even redraws) are part of the same event system.

Once gotoв sets a responder for a reactive view, gotoв expects the outermost DOM element of the view to 1) be placed in the DOM; 2) to be in the DOM exactly once, without being repeated. In this way, each view has a 1:1 relationship with a DOM element.

When the view has no corresponding DOM element, gotoв decides it is a "dangling view", which it considers to be an error. If you place the output of B.view in the DOM through B.mount, and you do this before calling any change events which might redraw the view, this will not happen.

A gotoв view can only be placed in the DOM once (and not more than once) because its corresponding outermost element has an id and as such can only exist once in the DOM.

If you want to encapsulate a view in a variable for later reuse in multiple places (even simultaneously), do it as a function that returns an invocation to B.view, instead of storing a direct invocation to B.view:

// Please don't do this
var counter = B.view ('counter', function (counter) {
   counter = counter || 0;
   return ['h1', 'Counter is ' + counter];
});

// Instead, do this
var counter = function () {
   return B.view ('counter', function (counter) {
      counter = counter || 0;
      return ['h1', 'Counter is ' + counter];
   });
}

// Then, you can use it like this
B.mount ('body', counter);

// Or instead, you can use it like this
var app = function () {
   return [
      ['h1', 'App'],
      counter ()
   ];
}

B.mount ('body', app);

It is particularly important to be aware of this, since using an invocation to B.view in multiple places or multiple times can trigger errors that are not immediate and that cannot be detected by gotoв.

By encapsulating the view into a function, you could have two counters simultaneously - each will have its own view:

var counter = function () {
   return B.view ('counter', function (counter) {
      counter = counter || 0;
      return ['h1', 'Counter is ' + counter];
   });
}

var app = function () {
   return ['div', [
      counter (),
      counter ()
   ]];
}

B.mount ('body', app);

It is perfectly possible to nest reactive views:

var app = function () {
   return B.view ('username', function (username) {
      return ['div', [
         ['h1', username],
         B.view ('counter', function (counter) {
            counter = counter || 0;
            return ['h2', ['Counter is ', counter]];
         })
      ];
   })
}

B.mount ('body', app);

If you pass an id to the lith returned by a vfun, an error will be reported. B.view uses specific ids to track which DOM elements are reactive. B.view adds also a paths attribute to the DOM elements, simply to help debugging; the paths attribute will contain a stringified list of the paths passed to the reactive view.

var app = function () {
   return B.view (['Data', 'counter'], function (counter) {
      counter = counter || 0;
      // This will generate an error. Don't pass ids to the outermost element of a reactive view.
      return ['h1', {id: 'my-counter'}];
   });
}

B.mount ('body', app);

// The HTML for the <h1> will be something like <h1 id="в1" path="Data:counter"></h1>

If you nest views, you need to specify different elements for them - that is, one invocation to B.view cannot simply return another invocation to B.view.

// This will not work, gotoB will return an error saying that you cannot specify an id on the element returned by the vfun.
var app = function () {
   return B.view ('username', function (username) {
      return B.view ('counter', function (counter) {
         return ['h1', [username, counter]];
      });
   });
}

// This will work - notice there's a `<div>` and inside of it, an `<h1>`.
var app = function () {
   return B.view ('username', function (username) {
      return ['div', B.view ('counter', function (counter) {
         return ['h1', [username, counter]];
      })];
   });
}

It is highly discouraged to call events from inside a vfun, unless you have a great reason to do so (if you do, I'm very curious about your use case, so please let me know!). vfuns make much more sense as pure functions. Events should be called from rfuns rather than vfuns.

var app = function () {
   return B.view (['Data', 'counter'], function (counter) {
      counter = counter || 0;
      // Don't invoke B.call from inside a vfun, unless you have a great reason to!
      B.call ('side', ['effects', 'rule']);
      return ['h1', counter];
   });
}

B.mount ('body', app);

One final point: gotoв requires you to not manipulate the DOM elements of a reactive view. By default, gotoв expects full control over the outermost DOM element of a view and its children - if you modify it directly, gotoв won't be aware of the changes and so errors could happen if those elements are recycled after a redraw. In some situations, however, it is necessary to include an element that you (or more often, a library) will modify. For these cases, you can use the opaque property (please refer to the advanced topics section).

Writing your own responders & tracking execution chains: x, B.log, B.eventlog, B.get, B.mrespond, advanced matching

Most of the logic of a gotoв application will be contained in responders that you write yourself; while you'll still be using the built-in responders (those with verbs set, add and rem), most apps will require you to define responders. In fact, many events will be called from inside responders (with the rest of the events being called directly by user interactions with the DOM).

As we noted above, responders are created with B.respond and are matched when an event with a matching verb and path is called. The logic for a responder goes in the rfun (responder function). This function receives x (a context object) as its first argument; it optionally receives further arguments if the matching event was called with extra arguments.

Responders (or more precisely, rfuns) is where most of the logic of a gotoв app lives.

Going back to the todo example defined above:

B.respond ('create', 'todo', function (x) {
   var todo = prompt ('What\'s one to do?');
   if (todo) B.call (x, 'add', 'todos', todo);
});

This responder is defined to match events with a verb create and a path todo. The rfun only receives a context object as argument.

Quite often you might need to pass extra arguments to responders. This can be done as follows:

B.respond ('create', 'todo', function (x, important) {
   var todo = prompt ('What\'s one to do?');
   if (todo) B.call (x, 'add', 'todos', todo);
   if (important) alert ('Important todo added.');
});

B.call ('create', 'todo', true);

The true passed to the event call after the path gets passed as the second argument to the rfun. If the event were to be called without extra arguments, important would be undefined.

In any case, the rfun receives always the context object as its first argument. This object contains the following:

  • verb, the verb of the event that matched the responder.
  • path, the path of the event that matched the responder.
  • args, an array with extra arguments passed to the event. If no extra arguments were passed, this element will be undefined.
  • from, the id of the event that matched this responder (which is the same value returned by the corresponding r.call invocation).
  • cb, a callback function which you only need to use if your responder function is asynchronous.
  • responder, the matched responder.

Most of these keys are there for completeness sake and are not really necessary most of the time; in fact, args is actually redundant, since the extra arguments are also passed directly to rfun. A full description of the context object is available here.

The most useful key of x is from. It will contain the id of the event that was called and that in turned matched the responder. This allows to track event chains. For example: event X matches responder Y, then responder Y calls event Z.

To track event chains, pass x as the first argument to calls to B.call that you do from inside the rfun. For example:

B.respond ('foo', 'bar', function (x) {
   B.call (x, 'do', 'something');
});

The event call do something will contain the id of the listener and in this way it will be possible to track where the call came from. More information is available here.

gotoв stores a list of all the events called and all responders matched into B.log. Since gotoв applications are built around events, This can be extremely useful for debugging an app. Instead of inspecting B.log with the browser console, you can invoke B.eventlog, a function which will add an HTML table to the page where you can see all the information about the events. There's a separate section dedicated to logging.

A function you will probably use quite a bit inside responders is B.get, which retrieves data from B.store. While you can directly access data from B.store without it, B.get is useful to access properties in the store in case they haven't been defined yet. For example, if B.store.user.username is not defined, if you try to do something like var username = B.store.user.username and B.store.user is not present yet, your program will throw an error.

If, instead, you write var username = B.get ('user', 'username'), if B.store.user is not present yet then username will be undefined.

// B.store is {}

// This will throw an error!
var username = B.store.user.username;

// This will be either `undefined` or bring you the `username` if it's already defined.
var username = B.get ('user', 'username');

B.get takes either a list of integers and strings or a single array containing integers and strings. These integers and strings represent the path to the part of the store you're trying to access. This path is the same path that B.call (the event calling function) takes as an argument.

If you pass invalid arguments to B.get, it will return undefined and report an error to the console.

If you pass an empty path to B.get (by passing either an empty array or no arguments), you'll get back B.store in its entirety.

It is important to notice that B.get doesn't return copies of the referenced objects, but the actual object themselves. If B.get returns an array or object and you modify it, you'll also be modifying the corresponding object in the store. Most often, you don't want to do this since it can generate an inconsistency between the store and the views. To avoid this problem, you can copy the returned object or array before modifying it using teishi.copy.

B.call ('set', [], {user: {username: 'foo', type: 'admin'}});
// B.store is {user: {username: 'foo', type: 'admin'}}

var user = B.get ('user');

// If you do this, you'll modify B.user.username!
user.seen = true;

// Better to do this
var user = teishi.copy (B.get ('user'));
user.seen = true;

Note that this is not necessary if 1) you don't need to modify the object or array; or 2) you bring a value that's neither an array nor an object, in which case javascript returns you a copy of the value, not a reference to the value itself.

Responders are active from the moment you create them (with B.respond) until you remove them with B.forget (with the exceptions of responders created with the burn flag, which will be forgotten after being matched once). There's no concept of lifecycle, and most responders will be active for the entire lifetime of you app.

To create multiple responders at once, you can use B.mrespond, which takes an array of arrays, where each internal array contains the arguments to be passed to B.respond:

B.mrespond ([
   ['verb1', 'path1', function (x) {...}],
   ['verb2', ['another', 'path'], function (x) {...}],
   ...
]);

You can use regexes on both the verb and the path of a responder. For example, if you want to create a responder that is matched by events with verbs get and post you can write it as follows:

B.respond (/^get|post$/, 'bar', function (x) {...});

This responder, however, will only be matched by events with verb get or post and a path equal to bar. To make it match all events with a path of length 1, you can use a wildcard for the path:

B.respond (/^get|post$/, '*', function (x) {...});

To make a responder match all events with verbs get or post, you need to use the match property of the responder. For example:

B.respond (/^get|post$/, [], {match: function (ev, responder) {
   if (ev.verb === 'get' || ev.verb === 'post') return true;
}}, function (x) {...});

The responder above will be matched by any event with verb get or post. The match parameter effectively supersedes the verb and path of the responder. If match function returns true, the responder will match the called event.

The change event, the data functions (B.set, B.add, B.rem) and B.changeResponder

As we saw before, when you call an event with any of the built-in data verbs (set, add and rem), a change event with the same path will be called. More precisely, a change event will be called whenever you call a data verb with 1) valid arguments; and 2) when your invocation actually modifies the store. If the event is called with incorrect arguments or it doesn't modify the store, no change event will be triggered.

gotoв's function for creating reactive elements (B.view), relies on the change event to know when it should redraw a view. More

2.3.0

1 year ago

2.2.0

2 years ago

2.1.1

3 years ago

2.1.0

3 years ago

2.0.1

3 years ago

2.0.0

3 years ago

1.2.5

4 years ago

1.2.4

4 years ago

1.2.3

5 years ago

1.2.2

5 years ago

1.2.1

5 years ago

1.2.0

6 years ago

1.1.0

7 years ago

1.0.0

7 years ago

0.4.0

7 years ago

0.3.0

7 years ago

0.2.1

7 years ago

0.2.0

7 years ago

0.1.0

7 years ago