conkitty

0.5.21 • Public • Published

Conkitty Template Engine Build Status

Conkitty templates are being compiled to concat.js chains. It is not a regular template engine, it doesn't produce strings of HTML, but generates DOM instead.

Documentation is in painful progress, but it is better than nothing.

Quick start

Three quick start options are available:

  • npm install -g conkitty and conkitty -c common.js -t tpl.js file.ctpl will compile file.ctpl to common.js and tpl.js files.
  • Use grunt-conkitty to build Conkitty templates with Grunt, check out this example.
  • Use gulp-conkitty to build Conkitty templates with Gulp, check out this example (it has example dependencies).

Compiled templates consist of two and a half parts:

  • Common core code needed for runtime,
  • compiled templates code,
  • external dependencies (in case they are declared in templates).

To start using compiled templates, link common core and compiled templates to your page with <script> tags.

There is a Conkitty syntax highlighting plugin for JetBrains products (IntelliJ IDEA, WebStorm, PhpStorm and so on). It is available from official plugin repository.

<html>
    <head>
        <title>Hello</title>
    </head>
    <body>
        <script src="/path/to/common.js"></script> 
        <script src="/path/to/compiled/templates.js"></script> 
        <script>
            // Insert `template-name` result into document body right away.
            $C.tpl['template-name'].call(document.body, 'Hello', [1, 2, 3], {k1: 'v1', k2: 'v2'});
 
            // The same, but assign generated DOM to a variable.
            var rendered = $C.tpl['template-name']('Hello', [1, 2, 3], {k1: 'v1', k2: 'v2'});
            document.body.appendChild(rendered);
            // `rendered` is a document fragment, so, it's not a valid reference
            // after you call appendChild for it.
        </script> 
    </body>
</html>

Syntax

Block nesting is done via indentation. Top level (zero-indented lines) is for templates declarations. Below each template declaration are commands, tags, strings, variables and JavaScript expressions.

template-name $arg1 $arg2 $arg3
    h1
        $arg1
    ul
        EACH $item $arg2
            li
                "Item: "
                $item
    dl
        EACH $key $val $arg3
            dt
                $key
            dd
                $val

// $C.tpl['template-name']('Hello', [1, 2, 3], {k1: 'v1', k2: 'v2'}) will produce:
//
// <h1>Hello</h1>
// <ul>
//     <li>Item: 1</li>
//     <li>Item: 2</li>
//     <li>Item: 3</li>
// </ul>
// <dl>
//     <dt>k1</dt>
//     <dd>v1</dd>
//     <dt>k2</dt>
//     <dd>v2</dd>
// </dl>

Template declaration

template-name [$arg1 [$arg2 […]]]

Where template-name is a name of the template, this name is used to call the template. When you call the template, you can pass any number of arguments to it. These arguments will be accessible from JavaScript expressions of the template via appropriate names. Argument names should be a $ sign plus a valid JavaScript variable names.

template1 $arg1 $arg2
    h1
        $arg1
    p
        ($arg2 + ' ' + $arg2) // JavaScript expression, arguments are
                              // accessible here too.

// $C.tpl.template1('Hello', 'World') will produce:
//
//  <h1>Hello</h1>
//  <p>World World</p>

You can specify default values for arguments.

template2 $arg1="Hello" $arg2=({a: 1, b: 2})
    h1
        $arg1
    p
        (JSON.stringify($arg2))

// $C.tpl.template2('Pillow', 'World') will produce:
//
//  <h1>Pillow</h1>
//  <p>"World"</p>

// $C.tpl.template2() will produce:
//
//  <h1>Hello</h1>
//  <p>{"a":1,"b":2}</p>

Strings

Strings are enclosed in single or double quotes. String should begin and end in the same line. String will be properly escaped in the result.

Good

"Hello world"

'Hello \'world\''

"Hello <world>"

Bad

"Hello
   world"

JavaScript expressions

JavaScript expressions are enclosed in parenthesis. A JavaScript expression value will be coerced to string and properly escaped in the result. You can pass a function expression, this function will be called.

Good

(1 + 2 + 3)

('Hello ' + Math.random() + ' world')

(function() {
     // Expressions could be multiline.
     // `this` is pointing to current DOM node.
     return this.innerHTML + this.innerHTML;
})

Bad

()

(())

(return 123)

Tags

Use CSS selector-like constructions to create tags.

// Create <div class="test"></div>
div.test

// Create <a id="ppp" class="hello world" href="http://conkitty.io"></a>
a#ppp.hello.world[href="http://conkitty.io/"]

// Create <a href="http://conkitty.io" data-rnd="0.8223862457089126">Yo</a>
a
    @href "http://conkitty.io/"
    @data-rnd (Math.random())
    "Yo"
// or
a[href="http://conkitty.io/"][data-rnd=(Math.random())]
    "Yo"

There are :if(expr, trueSelector, falseSelector) and :elem(expr) pseudo selectors.

template $arg1 $arg2
    strong:if($arg1, .arg1-is-true#piu, [data-something="yes"])
        "Hello"
    #identifier:elem($arg2)
        "World"

// $C.tpl.template(true, 'em') will produce:
//
//  <strong class="arg1-is-true" id="piu">Hello</strong>
//  <em id="identifier">World</em>

// $C.tpl.template(false, 'span') will produce:
//
//  <strong data-something="yes">Hello</strong>
//  <span id="identifier">World</span>

There are several ways to specify attributes:

  • [attr=val] parts of selectors,
  • @attr val below selectors,
  • ATTR command (see this command description below).
template $val2 $val5
    div.class1[attr1="val1"][attr2=$val2][attr3=('val' + 3)]
        @attr4 "val4"
        @attr5 $val5
        @attr6 ('val' + 6)
        // Special case for `class` attribute, you can add, subtract and
        // use selector syntax:
        @class +.class2.class3  // Add using selector syntax.
        @class -.class2         // Subtract using selector syntax.
        @class +"class4 class5" // Add.

// $C.tpl.template('val222', 'val555') will produce:
//
// <div class="class1 class3 class4 class5"
//      attr1="val1"
//      attr2="val222"
//      attr3="val3"
//      attr4="val4"
//      attr5="val555"
//      attr6="val6"></div>

Variables

A number of variables could be accessible inside a template. Template arguments, SET, EACH and WITH commands, AS $var constructions create variables inside a template. Variable names should be a $ sign followed by a valid JavaScript variable name, all template variables are accessible in template JavaScript expressions via their names.

template $arg1 $arg2
    SET $var1 $arg1
    SET $var2 ('Concatenated ' + $arg2 + $arg2)
    $arg1
    ($var1 + ' piu ' + $arg2)
    $var2

Commands

ATTR name value

This command should be used to add an attribute with a dynamic name.

name and value are strings, variables or JavaScript expressions.

// Create
// <div id="yep"></div>
// or
// <div class="yep"></div>
div
    ATTR (Math.random() < 0.5 ? 'id' : 'class') "yep"

CALL template-name [arg1 [arg2 […]]]

You can call one template from another. argN are arguments for a template. Arguments should be strings, variables or JavaScript expressions.

template1
    div
        CALL template2 "Hello" (' wo' + 'rld')

template2 $arg1 $arg2
    h1
        $arg1
        $arg2
        "!"

// $C.tpl.template1() will produce:
//
// <div>
//     <h1>Hello world!</h1>
// </div>

You can pass arguments by names.

template1
    div
        // Notice that `$` signs in argument names should not present here.
        CALL template2 "111" "22" a6=('si' + 'x') a4="ffff"

// Say, we have a template with a bunch of arguments with default values.
template2 $a1="1" $a2="2" $a3="3" $a4="4" $a5="5" $a6="6"
    h1
        $a1
    h2
        $a2
    h3
        $a3
    h4
        $a4
    h5
        $a5
    h6
        $a6

// $C.tpl.template1() will produce:
//
// <div>
//     <h1>111</h1>
//     <h2>22</h2>
//     <h3>3</h3>
//     <h4>ffff</h4>
//     <h5>5</h5>
//     <h6>six</h6>
// </div>

Additionally, you can pass a subtree when you call a template.

template1
    div
        CALL template2 "Hello world"
            // This is PAYLOAD for template2, it will be calculated lazily
            // when (and if) you use PAYLOAD command inside template2.
            span
                "!"

template2 $arg1
    h1
        $arg1
        PAYLOAD

// $C.tpl.template1() will produce:
//
// <div>
//     <h1>Hello world<span>!</span></h1>
// </div>

It is possible to get template name dynamically, as JavaScript expression.

template1
    div
        CALL ('template' + 2) "Hello" "world"

template2 $arg1 $arg2
    $arg1
    " "
    $arg2

You can compliment CALL command with EXCEPT section, which will be executed in case of any exception during CALL command processing.

template1
    div
        CALL template2
            // Payload could be here too.
        EXCEPT
            "Oops"

    div
        CALL (function() { throw new Error('Template name getter exception'); })
        EXCEPT
            "It is safe"

template2
    span
        JS
            throw new Error('Exception, baby')

CHOOSE

CHOOSE is a command to choose one of multiple choices.

template $arg1
    CHOOSE
        WHEN ($arg1 === 1)
            div
                "111"
        WHEN $arg1
            span
                "222"
        OTHERWISE
            p
                ($arg1 + ' aaa ' + $arg1)

// $C.tpl.template(1) will produce: <div>111</div>.
// $C.tpl.template(2) will produce: <span>222</span>.
// $C.tpl.template(false) will produce: <p>false aaa false</p>.

Any number of WHEN sections is possible. OTHERWISE section is optional.

EACH [$key] $value expr

Iterate over an array or an object.

$key and $value are references to items you're being iterating through. They should be a $ sign plus a valid JavaScript variable name. $key is optional.

expr is a variable or a JavaScript expression returns an array or an object.

template
    EACH $val ([11, 22])
        p
            ($val + ' aa ' + $val)

    EACH $index $val ([33, 44])
        span
            $index
            ": "
            $val

    EACH $vvv ({k1: 'v1', k2: 'v2'})
        div
            $vvv

    EACH $k $v ({k3: 'v3', k4: 'v4'})
        em
            $k
            ": "
            $v

// $C.tpl.template() will produce:
//
// <p>11 aa 11</p>
// <p>22 aa 22</p>
// <span>0: 33</span>
// <span>1: 44</span>
// <div>v1</div>
// <div>v2</div>
// <em>k3: v3</em>
// <em>k4: v4</em>

It is possible to add fallback for an empty array (or object):

template
    ul
        EACH $val ([])
            li
                $val
        ELSE
            li.empty
                "No items."

    EACH $key $val ({})
        p
            $key
            ": "
            $val
    ELSE
        em
            "Empty."

JS [$item $index $obj]

Run arbitrary JavaScript code.

$item, $index and $obj are references to current context items.

Everything that's below JS command with higher indentation will be executed as JavaScript code.

template
    div
        JS
            window.everythingIsOk = true
        p
            "Hello world"
            JS
                var y = 'y',
                    e = 'e',
                    s = 's';
                window.thisIsOkToo = y + e + s;

        ul
            EACH $i $v ([11, 22])
                li
                    JS $item $index $arr
                        console.log($item === $v, $index === $i, $item, $arr);
                    $v


// $C.tpl.template() will produce:
//
// <div>
//     <p>Hello world</p>
//     <ul>
//         <li>11</li>
//         <li>22</li>
//     </ul>
// </div>
//
// `window.everythingIsOk` and `window.thisIsOkToo` will be set to
// `true` and 'yes' respectively, and there will be two lines in console
// log: `true true 11 [11, 22]` and `true true 22 [11, 22]`.

MEM key [expr]

You have access to exact DOM nodes during their creation process. You can memorize some of these nodes for future use.

template
    div
        MEM "my-div"

        p
            MEM ('my' + '-' + 'p') ({ppp: this})

// $C.tpl.template();
// `$C.mem` will be {'my-div': <div>, 'my-p': {'ppp': <p>}}

SET $name expr

$name is a $ sign plus a valid JavaScript variable name.

expr is a JavaScript expression for a variable value.

Sometimes you need to define a variable.

template1
    div
        SET $myvar ({some: 'data', inside: '.'})

        EACH $val $myvar
            $val

        // You can reuse variable names.
        SET $myvar ({another: 'value'})

        ($myvar.another)

// $C.tpl.template1() will produce:
//
// <div>data.value</div>

You can also assign a subtree to a variable.

template2
    SET $myvar2
        em
            "hello"
        strong
            "world"

    div
        // Use unescaped JavaScript expression (see below) to insert the result.
        ((($myvar2)))

// $C.tpl.template2() will produce:
//
// <div>
//     <em>hello</em>
//     <strong>world</strong>
// </div>

TEST expr

TEST is a simplified CHOOSE for the cases you have only one option to check.

template $title
    TEST $title
        h1
            $title
    p
        "Some content"

// $C.tpl.template('Tiiiiiii') will produce:
//
// <h1>Tiiiiiii</h1>
// <p>Some content</p>

// $C.tpl.template() will produce:
//
// <p>Some content</p>

TRIGGER [arg1 [arg2 […]]]

You can subscribe to TRIGGER command calls from your application:

$C.on(function(arg1, arg2) {
    // `this` will be currently created DOM element.
    console.log(arg1, arg2, this);
});

After that:

template
    div
        TRIGGER "some" ({arg: true})
        "Hello"

// Calling $C.tpl.template.call(document.body) will add `<div>Hello</div>`
// to `document.body` and print `some {arg: true} <div>Hello</div>` in your
// console log.

WITH $name expr

$name is a $ sign plus a valid JavaScript variable name.

expr is a JavaScript expression.

template
    div
        SET $v ({a: {b: {c: 'd', e: false}}})

        div
            WITH $ok ($v.a.b.c)
                $ok
            ELSE
                "HECK"

        div
            // Go to ELSE section in case of exception.
            WITH $ok ($v.e.f.g)
                $ok
            ELSE
                "HECK"

        div
            WITH $ok ($v.a.b.e)
                $ok
            ELSE
                "HECK"

        div
            // Go to ELSE section in case of undefined value.
            WITH $ok ($v.a.b.no)
                $ok
            ELSE
                "HECK"

// $C.tpl.template() will produce:
//
// <div>
//     <div>d</div>
//     <div>HECK</div>
//     <div>false</div>
//     <div>HECK</div>
// </div>

Unescaped strings

A string like "&nbsp;" will produce &amp;nbsp; in the result. To put an unescaped text to the result, enclose a string in triple quotes. Note that the markup inside triple quotes should be valid.

Good

"""Hello&nbsp;world"""

'''<p>Hello <strong>world</strong></p>'''

Bad

"""</div>"""

Unescaped JavaScript expressions

When you use a JavaScript expression to insert something to the result, its value is coerced to a properly escaped string. Sometimes you have a DOM node as a template argument or you want to insert an unescaped JavaScript expression value. To do that, enclose your JavaScript expression in triple parenthesis. When you do that, typecheck is performed, if your JavaScript expression value is an instance of Node, it will be inserted as node, otherwise the value will be coerced to a string and this string will be inserted unescaped (note that the markup should be valid).

Please, keep in mind, that if you need unescaped JavaScript expressions often, you are probably doing something wrong.

template1
    div
        CALL template2
            span
                "some DOM inside"

        ('<em>' + 'no, it is not' + '</em>')
        ((('<strong>' + 'yes, it is' + '</strong>')))

template2
    p
        // Pass this template's PAYLOAD as an argument to next template.
        CALL template3 PAYLOAD

template3 $arg
    "This is not "

    $arg

    ", this is "

    ((($arg)))

    " indeed."

// $C.tpl.template1() will produce:
//
// <div>
//     <p>
//         This is not [object DocumentFragment], this is <span>some DOM inside</span> indeed.
//     </p>
//     &lt;em&gt;no, it is not&lt;/em&gt;
//     <strong>yes, it is</strong>
// </div>

Line wrap

Templates can accept many arguments, selectors could be pretty long. It is possible to split command into several lines. When parser meets \, it skips all the whitespaces after and continues with first non-whitespace character.

template1
    CALL template2  \
        "Argument1" \
        "Argument2" \
        "Argument3"

template2 $arg1 $arg2 $arg3
    div.class1\
              [attr1=$arg1]\
              [attr2=$arg2]\
              [attr3=$arg3]            
        "Hello"

// $C.tpl.template1() will produce:
//
// <div class="class1" attr1="Argument1" attr2="Argument2" attr3="Argument3">Hello</div>

Namespaced templates

When you build a set of files, all the templates go to compiled result. But you can declare namespaced templates, they will go to the result only in case they are used from regular templates.

Namespaced templates can be called from regular templates and can't be called directly using $C.tpl[name] functions.

// Regular template, will go to the result anyway.
template
    div
        CALL ns1::template1 "World"

        // You can skip `CALL` keyword when you call namespaced template:
        ns1::template1 "Pillow"

// Namespaced template, will go to the result because it's used from regular template above.
ns1::template1 $arg
    span
        "Hello "
        $arg

// Another namespaced template, will not appear in the result since it's not used.
another-ns::template1
    strong
        "No way"

// $C.tpl.template() will produce:
//
// <div>
//     <span>Hello World</span>
//     <span>Hello Pillow</span>
// </div>

It is possible to skip CALL keyword, when you call namespaced templates.

Remembering created nodes

Conkitty is a DOM generator, not a string generator. As a benefit, you have an access to exact DOM nodes during their creation process. You can push these nodes back to your application using TRIGGER and MEM commands. But you also can assign nodes to variables with AS $varName constructions.

template
    div
        span.hello AS $mySpan
        em.world AS $world

    // Now you have `<span>` in `$mySpan` and `<em>` in `$world`.
    // You can pass them to other templates or do something sophisticated with them.
    JS
        $mySpan.innerHTML = 'Hello';
        $world.innerHTML = ' beautiful world';

// $C.tpl.template() will produce:
//
// <div>
//     <span class="hello">Hello</span>
//     <em class="world"> beautiful world</em>
// </div>

Returning more from templates

You can return complex structures from templates, this is useful when you create reusable components (for example, you can return component's API).

Use EXPOSE command in combination with string, variable, expression or JS command to return value and AS $varName construction to get returned value.

template
    div
        // $btn will have `{btn: <button>, title: <span>}`
        ctrl::button "My sweet button" AS $btn

        JS
            $btn.title.innerHTML += '!!!';

ctrl::button $title $type="button"
    button[type=$type] AS $btnNode
        span AS $titleNode
            $title

    EXPOSE ({btn: $btnNode, title: $titleNode})

    // Or the same with `JS` command:
    //
    // EXPOSE JS
    //     return {btn: $btnNode, title: $titleNode}
    //
    // Or the same with variable:
    //
    // SET $ret ({btn: $btnNode, title: $titleNode})
    // EXPOSE $ret

// $C.tpl.template() will produce:
//
// <div>
//     <button type="button">
//         <span>My sweet button!!!</span>
//     </button>
// </div>

Only one EXPOSE command per template is allowed.

You can return values from regular templates too and have them as the result of template function call.

template1
    div
        "Hello World"

template2
    div AS $d
        "Hello World"
    EXPOSE ({elem: $d, ololo: 'something else'})

Feel the difference in calls below.

var ret;
 
ret = $C.tpl.template1();
// `ret` is a document fragment with `<div>Hello World</div>` inside.
 
ret = $C.tpl.template1.call(document.body);
// `ret` is undefined and `<div>Hello World</div>` is added to `<body>`.
 
ret = $C.tpl.template2();
// `ret` is `{elem: <div>, ololo: 'something else'}`.
 
ret = $C.tpl.template2.call(document.body);
// `ret` is `{elem: <div>, ololo: 'something else'}` and
// `<div>Hello World</div>` is added to `<body>`.

Node appender

If you have DOM node in a variable (or as an expression result), you can use Conkitty syntax to modify attributes and add children to this node. Use ^ operator for this.

template1
    div.hello AS $myDiv
        CALL template2 $myDiv

    div.world AS $myDiv2
    CALL template2 $myDiv2

template2 $div
    strong
        "Yo"

    // Manipulations with $div node.
    TEST $div
        ^$div
            @class +.class1.class2
            span
                "Hello"

    em
        "Yep"

// $C.tpl.template1() will produce (notice node order):
//
// <div class="hello class1 class2">
//     <span>Hello</span>
//     <strong>Yo</strong>
//     <em>Yep</em>
// </div>
// <div class="hello class1 class2">
//     <span>Hello</span>
// </div>
// <strong>Yo</strong>
// <em>Yep</em>

External files dependency declaration

It often happens that you need some additional dependencies for your templates (such as stylesheets, JavaScript files or images).

It is possible to declare these dependencies and have their properly ordered list along with compiled templates. Use & operator for this.

Say, you have tpl.ctpl file like this:

template
    &"file1.css"
    &"file1.js"
    div
        ctrl::button "My button"

// You can declare namespace-wide dependencies (they will go to the result
// in case any template of this namespace is used in regular templates).
ctrl::
    &"ctrl.css"
    &"ctrl.js"

ctrl::button $title $type="button"
    &"button.css"
    button[type=$type]
        $title

// Will not appear in the result since not used.
ctrl::input $name
    &"input.css"
    input[type="text"][name=$name]

// Will not appear in the result since not used.
aaa::
    &"aaa.css"

// Will not appear in the result since not used.
aaa::template
    &"aaa.js"

conkitty command line tool has --deps option, it means filename to put dependencies list to. grunt-conkitty has deps option too, for Grunt plugin it means directory to copy all the dependencies to.

Let's use command line tool like conkitty --common=common.js --templates=tpl.js --deps=deps.txt tpl.ctpl

deps.txt will be:

/absolute/path/to/ctrl.css
/absolute/path/to/ctrl.js
/absolute/path/to/button.css
/absolute/path/to/file1.css
/absolute/path/to/file1.js

Precompile expressions

Precompile expressions are a kind of black voodoo and should be used in exceptional cases only.

Precompile expressions are JavaScript expressions that are evaluated by template parser. Precompile expression should start with |( and end with )|.

For example, we have a template like this:

template|('-suffix' + prop1)|
    &|('"style.' + prop2 + '.css"')|
    p|(prop3 ? '.class1' : '')|
        "Hello"

In this example prop1, prop2 and prop3 are keys of environment object passed to Conkitty constructor. Here is the actual template that will be compiled if we pass environment object like {prop1: 111, prop2: 'dark', prop3: true}:

template-suffix111
    &"style.dark.css"
    p.class1
        "Hello"

Generated code notes

Generated code for a template like:

b-checkbox $props
    div.checkbox
        label
            input[type="checkbox"][name=($props.name)][id=($props.id)][value=($props.value)]
            TEST ($props.label)
                em
                    ($props.label)

will look like:

$C.tpl["b-checkbox"] = function($props) {
    var $ConkittyEnv = $ConkittyGetEnv(this);
    return $C($ConkittyEnv.p)
        .div({"class": "checkbox"})
            .elem("label")
                .elem("input", function $C_b_checkbox_4_13(){return{type:"checkbox",name:$props.name,id:$props.id,value:$props.value}})
                .end()
                .test(function $C_b_checkbox_5_18() { return ($props.label); })
                    .elem("em")
                        .text(function $C_b_checkbox_7_21() { return ($props.label); })
    .end(5);
};

You might notice anonymous functions named like $C_b_checkbox_7_21. These names are for easier debugging — you'll see template name, line number and character position in your call stack. JavaScript minifiers like UglifyJS remove these names during minification, so, compiled templates minify really well.

Source maps

Since version 0.5.7, Conkitty is capable of creating source maps for compiled templates.

Check out gulp-conkitty and grunt-conkitty to see how to create source maps.

Or use --sourcemap argument of command line utility:

conkitty --common=common.js --templates=tpl.js --sourcemap=tpl.map tpl.ctpl

When you compile templates with source map, //@ sourceMappingURL=your.source.map comment is being added on top of your compiled templates code.

Performance notes

It is hard to do adequate performance tests. I did simple ones like (simplified example):

function render(parent, arr) {
    var ret,
        i;
 
    ret = '<ul>';
 
    for (= 0; i < arr.length; i++) {
        ret += '<li>' + arr[i] + '</li>';
    }
 
    ret += '</ul>';
 
    parent.innerHTML = ret;
}
 
render(document.body, [1, 2, 3, 4, 5])

versus template:

render $arr
    ul
        EACH $item $arr
            li
                $item

and template call:

$C.tpl.render.call(document.body, [1, 2, 3, 4, 5])

And first ones were about 50% faster. But there are at least three things to keep in mind:

  • After you've built DOM from HTML string, you need to spend some time traversing this DOM to find all key nodes your application needs. With Conkitty you don't need DOM traversals — you have access to exact DOM nodes during template execution.
  • Pages are mostly reasonably-sized. This means that even bigger performance difference will not have noticeable impact to application speed.
  • Many optimizations could be done in future (like merging adjacent text node generators into single expression). But because of first two things I clarify these optimizations as premature.

Readme

Keywords

Package Sidebar

Install

npm i conkitty

Weekly Downloads

17

Version

0.5.21

License

MIT

Last publish

Collaborators

  • hoho