cuel
v0.0.12
Published
Tiny, powerful data binding & web application framework
Maintainers
Readme
cuel
Tiny, powerful data binding & web application framework
Hello world!
<!doctype html>
<html><body>
<hello-world></hello-world>
<script type=module>
import {cuel} from "../cuel.mjs"
cuel('hello-world', {
set toggle(v) {
this.msg = this.msg === 'Hello' ? 'Goodbye' : 'Hello';
}
}, `<h2>{{msg}} World!</h2>
<button !click=toggle>Toggle Greeting</button>
`);
</script>
</body></html>Table of Contents
Features
- 2-Way binding between DOM and JavaScript Objects
- full DOM access
- scoped binding
- web component based view-modules with JS-code, HTML-templates and styles
- dynamically binding added DOM
- conditional rendering
- iterative rendering (rendering from array-data)
- ponyfill-style non-intrusive behavior, that only ever applies where you invoke it
- all that at < 800 lines of code
Why?
I want a framework that is so small, that it's easy to maintain. Because if you use it, you own it. You have to update it until it falls out of fashion and then take over maintenance completely. Cuel is about 800 lines of nice readable code. It relies on pretty few concepts making it quite easy to fully understand what's going on in a codebase that is very maintainable.
I want a framework, that just works and works without magic. No compilation, no custom language like JSX. Cuel code will be easily readable by developers 20 years from now. Because it heavily leverages standards.
I want a framework, that gives me awesome performance where I need it. A decent web app should clock in at maybe 100K, not megabytes. Cuel is 5K gzipped. And this is not optimized for size. No magic means I know what's going on and can optimize for CPU or RAM where needed.
Finally I want a framework that helps me structuring script and DOM but does not get in my way. I want easy power, native DOM APIs where I need them and nice bindings where I don't.
Demo
Cuel comes with an app demo, that shows most features you'll need to write web apps. You may
git clone https://codeberg.org/schrotie/cuel.git
cd cuel
npm install
npm startThis should open the demo in your browser. Note this will only work in Chrome or Firefox as of this writing. Other browsers need importmaps support as described below! Check out the source code in demo/app/ and see in your browser, what it does. Change it! The browser should automatically reload. I guess, that's the quickest way to learn Cuel for most of you.
You may come back to the documentation in order to understand, how the features work, what's actually going on there.
Documentation
I designed cuel to give me decoupled dynamic view components on the simplest possible code base. It does this by leveraging web components and these are a very fine choice for this. However, web components can be many, many things and cuel was designed with one very specific use case in mind.
Thus cuel may help you with all your web component related coding and I'd be very happy if it did. But I designed it to decouple my apps into several views and decouple these view's logic from the notoriously hard-to-test-with DOM APIs.
Cuel gives you precisely one function and that is a pimped up version of the built in
customElements.define(name, constructor, options)Indeed, whatever you can pass to customElements.define, you can pass to
cuel! And more - cuel's key feature is, that you can pass it a template in
which you define mustache/handlebars style bindings. When your component is
instantiated, the template will become DOM ("light" or "shadow" depending on how
you passed the template) and the instance of your component's JavaScript class
will have accessors to directly read and write the bound DOM-stuff.
Because cuel was written for cookie cutting apps into view modules, it defaults
to rendering light DOM - usually you don't want your view components
completely decoupled from your app's CSS as is the case for
shadow DOM.
So instead of the last options argument, you may just pass your template and
have it rendered a light DOM, when your component is connected:
cuel('hello-world', class extends HTMLElement {}, 'Hello world!');When you then (or before) put <hello-world></hello-world> into your HTML, that
will read "Hello world!" (yes, looks like a questionable deal put like this,
but bear with me). In order to further minimize boilerplate, cuel also lets you
provide a mixin instead of a class extending HTMLElement. It will create an
HTMLElement itself using your mixing. So in the simplest case you just do
cuel('hello-world', {}, 'Hello world!');You only need to provide an HTMLElement if you want to customize the
constructor method.
So cuel accepts two mandatory and one optional argument. The first argument
is a string giving the tag-name of your custom element. The second argument is
a class or a mixin implementing the element. And the last argument - if
provided - is a light-DOM-template-string or an options object.
The options object may have the following properties:
extendssee custom elements documentation for detailstemplatelight-DOM-template-string ORshadowshadow-DOM-template-stringifTemplatemap of subtemplates that are conditionally renderedloopTemplatemap of subtemplates that are iteratively renderedstyleCSS string to be applied to the document's styleconnectoptional feature to connect your component to state management
We'll cover ifTemplate, loopTemplate and style in detail below.
import 'cuel', Development & Deployment
I hedge a deep and well-fostered hate against having a build step in my development cycle and my apps work without one. If you want to import cuel you have two options: import the bundled cuel.min.mjs or the source cuel.mjs. You may use absolute paths to their position in your node_modules folder.
I recommend using the source version and doing all your bundling yourself.
If you want to import it with a nice `import {cuel} from 'cuel', you should use import-maps which are supported in the major browsers with the exception of IE 12 (aka "Safari").
Put something like this into the head of your HTML:
<script type="importmap">
{"imports": {"cuel": "./node_modules/cuel/cuel.mjs"}}
</script>and you're good to go for development. You can then just import {cuel} from 'cuel' in your code and develop on your sources, not some weird artifact that
resembles what you developed after having that artifact and the browser jump
throw a dozen or so hoops.
For production you absolutely want to have a build step that bundles your app. I recommend esbuild for this. It's the perfect tool for this task.
other imports
If you want barebones data binding, the above is what you want. However, cuel comes with the connect utility which let's you connect your components to a state management system.
I also developed cuel-x as a simple state manager that works well with cuel. If you have a favorite state manager, absolutely use that and connect it with cuel's connect utility. If you don't have a favorite state manager, cuel-x is a great choice.
If you want connect but want to use your own state manager, your importmap should look like this:
<script type="importmap">
{"imports": {
"cuel": "./node_modules/cuel/connect.mjs"
}}
</script>And if you want all the glory of cuel with cuel-x, your importmap should look like this:
<script type="importmap">
{"imports": {
"cuel/": "./node_modules/cuel/",
"cuel-x": "./node_modules/cuel-x/index.mjs"
}}
</script>In the latter case you got yourself what I consider a complete SPA framework. Please refer to cuel-x' docs.
data binding
Introduction
Cuel supports a variant of the mustache/handlebars style binding that may be somewhat familiar by now. Just put a name in double curly brackets into your template and have cuel create a binding for you:
cuel('hello-world', {
doSomething() {
this.content = 'Hello world!'; // set the content of this element
console.log(this.content); // -> Hello world!
}
}, '{{content}}');This creates a text node inside the <hello-world> tags and when doSomething
is called, sets the text of the node to "Hello world!". This is a text content
binding. There are eight types of bindings in two categories. The categories
are content bindings and element bindings. Element bindings bind something of
the respective DOM element itself, while content bindings affect an element's
content. What you get depends on where in your template and how you define it:
<div ...{{elementBinding}}> {{contentBinding}}</div>One general note that applies to most binding type: Only null means nothing.
Setting null on an attribute will remove it, setting false will set the
text "false", though.
Binding Types
So here are examples for all binding types:
| Type | Binding | Shorthand | Example Code (demo/app/) |
|----------------|----------------------------------|------------------------------|--------------------------|
| Attribute | attributeName={{accessorA}} | @attributeName=accessorA | binding-types.mjs |
| Property | .domPropertyName={{accessorP}} | .domPropertyName=accessorP | binding-types.mjs |
| Event | !domEventName={{accessorE}} | !domEventName=accessorE | binding-types.mjs |
| Method | domNodeMethod()={{methodName}} | domNodeMethod()=methodName | binding-types.mjs |
| Node | *={{accessorN}} | *=accessorN | binding-types.mjs |
| Text | {{accessorT}} | | binding-types.mjs |
| if | {{accessorI}} | <ìmg ?=accessorI> | conditional-rendering.mjs|
| loop | {{accessorL}} | <img []=accessorL> | looped-rendering.mjs |
The first five are element bindings, Text, if and loop are content bindings.
Atributes, Properties and Text
Note that on the object implementing your custom element everything translates to properties, except for DOM methods, which become methods on the object, too. You can always access bound stuff from the JavaScript object.
If you take the attribute binding from the table above, for example,
element.accessorA (or this.accessorA in a method of the element!) will
return the current value of the attribute of the element. The attribute value
is not stored somewhere, but read from the DOM when you call the property getter
element.accessorA. It will then call element.getAttribute('attributeName').
You can also always do element.accessorA = 'x'. This will invoke
element.setAttribute('attributeName', 'x').
This works exactly the same with property and text bindings.
You may "stuff" attribute, property, and text bindings. If you bind a class
attribute for example: <div class="oneClass {{boundClass}}">, then setting
element.boundClass will always leave "oneClass" untouched.
Events, Methods and Nodes
You cannot get events (element.accessorE will raise an exception) and you
cannot set methods or nodes (element.methodName = 'x' and
element.accessorN = 'x' will each raise exceptions).
Nodes you can only get and then have full access to their DOM APIs.
Exception: you can set cuel-component-nodes. Cuel considers them proxy
objects with which to manipulate DOM. Setting an object to a
cuel-component-node will internally Object.apply(node, obj). This only
works, when the cuel-component has already been initialized. That means,
you'll have to include it's definition before setting to it. Otherwise
you get an exception.
Methods can only be called. element.methodName() will call the node's
domNodeMethod with the same arguments.
You can assign to events, but you should assign DOM events to them, e.g.
element.accessorE = new CustomEvent('foo', {detail: 'bar'}). This will emit
the event on the bound node. You may also just pass the custom event ini and
cuel will generate the event for you:
cuel('event-binding',
{emit() {this.event = {detail: 'data', bubbles: true};}},
'<div !bang={{event}}></div>'
);When element.emit() is called cuel will do
divElement.dispatchEvent(new CustomEvent('bang', {detail: 'data', bubbles: true}));Change Notifications
Cuel can also track changes and events in the DOM for you. It will do that, if it finds a setter for the bound property on the bound object. Let's assume the JavaScript class of the object bound in the table above looks like this:
class MyDomHandler {
set accessorA(a) {}
set accessorE(e) {}
set accessorT(t) {}
}Then Cuel will call the respective setter when the bound attribute or text
changes, or accessorE if the event domEventName is triggered on the bound DOM
node.
Note that you can then not set the respective DOM property yourself using that accessor! This inherently prevents circular bindings. There are still ways to shoot yourself in the foot with circular bindings, but not that easyly.
This does notably not work for DOM properties! Use events to track their changes.
class, mix-in & the render method
As noted above, you can pass cuel a class or a mix-in.
cuel('cl-ass', class extends HTMLElement {});
cuel('mix-in', {});Now, customElements.define, which cuel calls for you, requires something
extending HTMLElement so cuel will create one for you if it does not get one,
adding the methods from the mix-in to it. In any case it will add
a render method to your element, which will be called when the element is
connected to the DOM. For this it will create a connectedCallback which
renders your element.
Note: in case you do not provide a connectedCallback, the connectedCallback
created by cuel will do the initial rendering and there will be no extra render
method!
However, if you provide a connectedCallback yourself, cuel will generate a
render method for you. Thus, if you provide a connectedCallback, you should
probably call this.render() in your connectedCallback method - or elsewhere
in the lifecycle of your element.
cuel('mix-in',
{
connectedCallback() {
this.render();
}
},
'Hello world!'
);ifTemplate
ifTemplate is cuel's conditional rendering facility.
function render(count, template) {
cuel(`if-template-${count}`, {}, template);
const ift = document.createElement('if-template-1');
document.body.append(ift);
return ift;
}
const ift1 = render(1, '<span ?=greet>Hello!</span>');
ift1.greet = true; // now you see "Hello!"
ift1.greet = null; // now you don't
/*** You'll often want to render DOM with placeholders conditionally: ***/
const ift2 = render(2, '<span ?=greeting>{{greet}} {{whom}}!</span>');
ift2.greeting = {greet: 'Hello', whom: 'World'}; // now you see "Hello World!"
/*** Or just render some configurable text: ***/
const ift3 = render(3, '<span ?=text>{{*}}</span>');
ift3.text = 'Hello World!'; // now you see "Hello World!"
/*** Render several shallow nodes: ***/
const ift4 = render(4, '<template ?=who>Hello {{*}}!</template>');
ift4.who = 'World'; // now you see "Hello World!", just text, no extra elements
/*** Finally, conditionally render and bind cuel (cuel only!) components: ***/
cuel('sub-component', {}, '{{greeting}} from sub-component!');
const ift5 = render(5, '<sub-component ?*=sub></sub-component');
ift5.sub = {greeting: 'Hello'}; // now you see "Hello from sub-component!"Conditionally rendered DOM can be put into separate named HTML templates. Your
component adds the conditionally rendered part to its (your component's)
template by addressing it (in curly braces) by the sub-template's name. Your
component must also list the sub-template in its ifTemplate option:
cuel('if-template', {}, {
template: '{{showFirst}}{{showSecond}}',
ifTemplate: {
showFirst: 'first',
showSecond: 'second',
}
});
const ift = document.createElement('if-template');
document.body.append(ift);
ift.showFirst = true; // now you see "first"
ift.showFirst = null; // now you don'tWhen a cuel element has ifTemplate properties, cuel adds conditional
properties with the names of the conditional templates to the parent element.
When such a property gets a non-null value, the respective sub-template is
rendered, when it gets null, it gets removed.
If you want to render conditional shallow DOM, you can either use the
ifTemplate option as shown above, or you can use the ? attribute on a
<template> element:
cuel('if-template', {}, '<template ?=greet>Hello!<br></template>');Custom Conditions
You can define your own conditions in the ifTemplate option:
cuel('if-template', {}, {
template: '{{myIf}}',
ifTemplate: {myIf: {
if: x => x % 2,
template: 'odd',
}},
});
const ift = document.createElement('if-template');
document.body.append(ift);
ift.myIf = 3; // now you see "odd"
ift.myIf = 2; // now you don'tdynamic binding
Consider this code:
const ift = document.createElement('if-template');
document.body.append(ift);
ift.greet = true;
cuel('if-template', {}, '<span ?=greet>Hello!</span>');Note how the element is first created, then its conditional property is set and
only then it is defined. Before that definition, greet has no special
meaning, it's just a random property name with the value true added to an
unknown custom element. But when cuel instantiates your custom element it will
pick up the already existing property and in this case render the conditional
sub-template text "Hello!".
Cuel does that with all bound properties.
loopTemplate
loopTemplate is somewhat similar to ifTemplate. But it will instantiate its
content once for each element of the array you set to the respectively named
property.
cuel('loop-template', {}, '<ul><li []=list>{{label}}: {{value}}</li></ul>');
const loopt = document.createElement('loop-template');
document.body.append(loopt);
loopt.list = [
{label: 'first item', value: 1},
{label: 'second item', value: 2},
];This renders an unordered list with two items:
- first item: 1
- second item: 2
As with ifTemplates, you can also render shallow looped DOM inline:
cuel('loop-template', {}, '<template []=list>Value: {{*}}<br></template>');
const loopt = document.createElement('loop-template');
document.body.append(loopt);
loopt.list = [1, 2, 3];This renders:
Value: 1<br>
Value: 2<br>
Value: 3<br>And bind sub-components:
cuel('loop-template', {}, '<sub-component []*=list></sub-component>');
const loopt = document.createElement('loop-template');
document.body.append(loopt);
loopt.list = [
{greeting: 'Hello'},
{greeting: 'Hi'},
];This renders:
Hello from sub-component!
Hi from sub-component!You can also loop <template> as with ìfTemplate, in order to render shallow
nodes or elements.
chunk
loopTemplate supports the chunk option to perform chunked rendering:
cuel('loop-template', {}, '<ul><li [500]=list>{{value}}</li></ul>');
const loopt = document.createElement('loop-template');
document.body.append(loopt);
loopt.list = (new Array(100000)).fill(0).map(() => ({a: Math.random()}));This renders a list with 100.000 items. On my computer that takes a few seconds, your milage may vary. Now that's a lot of items. More realistic cases render fewer items but with complex DOM and more computations to render the dynamic items. So cases where the user experiences lag do occur.
The above example renders without impeding user interaction though. It does so by chunking the rendering: it renders 500 items at each iteration and then returns the control to the browser. Cuel repeats that until all 100.000 items are rendered.
You should aim to render only what the user is currently seeing and then you will almost never experience performance problems when rendering. However, if you render big arrays or very complex DOM, you may use chunked rendering. That way the browser won't freeze while rendering.
Above examples in handlebars syntax:
// First example
cuel('loop-template', {}, {
template: '<ul>{{list}}</ul>',
loopTemplate: { list: '<li>{{label}}: {{value}}</li>'}
});
// Second example:
cuel('loop-template', {}, {
template: '{{list}}'
loopTemplate: {list: 'Value: {{*}}<br>'
});
// Third example:
cuel('loop-template', {}, {
template: '<ul>{{list}}</ul>',
loopTemplate: { list: {
template: '<li>{{value}}</li>',
chunk: 500,
}}
});When to Use Handlebars
While shorthand syntax is recommended for most cases, handlebars syntax is useful for specific edge cases:
Multiple CSS Classes:
<!-- Can't do this with shorthand -->
<div class="foo bar {{myClass}}"></div>Complex Nested Structures:
<!-- When you need explicit control over template structure -->
<div>{{complexNestedContent}}</div>Use shorthand for 90% of cases, and fall back to handlebars when you encounter these specific edge cases.
Nested Content-Proxies
Cuel implements data bindings with proxy properties. Your custom element gets properties (getters and setters) that allow you to access the DOM properties of bound stuff in your light- or shadow-DOM.
With ifTemplate and loopTemplate you nest templates inside your custom
element's template. Now nested templates may contain their own bindings. In
particular loopTemplate cannot bind to simple proxy properties of your
element, because there is an array of things. Since loopTemplate needs
something special there, ifTemplate simply behaves in a similar fashion.
Consider this code:
cuel('if-template', {}, '<span ?=conditional>{{nested}}</span>');
const ift = document.createElement('if-template');
document.body.append(ift);
ift.conditional = {nested: 'foo'}; // now you see 'foo'
ift.conditional.nested = 'bar'; // now you see 'bar'Behind the scenes cuel creates custom elements for your nested templates. And
these custom elements have proxy properties for their data binding. So above,
ift.conditional is a proxy property of your custom element and it is a
specialized (specialized as conditional) node-accessor. See the binding types
above. A conditional is a node-binding, i.e. the first kind of content
binding.
The getter returns the custom element created for your nested template. And
that custom element has a proxy property nested. The nested property of the
conditional custom element is a text-(content-)accessor with which you can get
and set the inner text of your template.
You may begin to see, how this is not just consistent with the way things have
to be for loopTemplate, but genuinely useful. It may be a bit of hard to grasp
recursive logic at first, but when you get it, it is rather simple, just the
same as cuel is anyway. And it is pretty useful to be able to assign your
data-objects to your DOM-objects and have them rendered as expected.
Now for loopTemplates the node accessor will return an array of custom
elements (each of the same type). Those will have their own proxy properties
(accessors) as defined in your loop template.
Thus you can as easily access you nested dynamic DOM, as you can the rest of your template. However, cuel does nothing to prevent you from shooting yourself into your foot. If you try to access DOM that isn't there (possibly not there, yet, because you render chunks), you'll get an exception.
However, if you want change notifications on your nested DOM, your out of luck. Or rather, you should implement another cuel custom element that is conditionally (or loopedly) rendered and implements its own change handling. Keep in mind, though, that events may bubble: in many cases you just want to handle an event triggered in you dynamic DOM and you can add a handler on a parent element of that dynamic DOM.
"Root" Bindings
In the above example we showed how nested proxies have their own proxy property accessors. In order to set these properties, you need to pass an object with the respective properties. For complex nested proxies this is ideal. However, if you want to set one simple property, that approach is pretty cumbersome. In order to alleviate this, cuel supports a special syntax here for binding the whole passed value instead of individual properties:
cuel('if-template', {}, '<span ?=conditional>{{*}}</span>);
const ift = document.createElement('if-template');
document.body.append(ift);
ift.conditional = 'foo'; // now you see 'foo'
ift.conditional = 'bar'; // now you see 'bar'
ift.conditional = null; // now you see naughtStyle
The style option allows you to automatically add CSS to the document when creating a cuel component:
cuel('styled-component', {}, {
template: 'content',
style: ':scope { color: red; background: yellow; }'
});Scoped Styling: Styles are automatically scoped to the component using CSS @scope at-rule:
@scope (styled-component) {
:scope {
color: red;
background: yellow;
}
}This ensures styles only apply to your component and don't leak to other elements on the page.
Please refer to the
MDN documentation
for more information on the @scope at-rule.
Usage: Perfect for component-specific styling without needing separate CSS files or global stylesheets.
Design Considerations
This section does not document any of cuel's functionality, but explains some peculiarities of cuel that I encountered while developing a somewhat complex application on top of it. I also try to give guidance on how to deal with these peculiarities.
SPA Architecture
Cuel is designed to be as minimal and maintainable as possible while offering a minimal reasonable feature set for covering the data binding side of modern SPAs. The standard architecture of a modern SPA looks something like this:
- Data Store
- Data logic
- Data Binding
- DOM
The by far most complex and extensive code lives behind the DOM part - it's the API to the native browser code. Cuel fills the Data Binding part with custom-elements and proxies. The value of good data binding is that it simplifies the code that uses it, and makes it easier to test and argue about!
Now a complex data binding API is counterproductive to that. And that API itself needs to be tested and argued about. Thus I tried to find a compromise that will force you to adapt to that simplicity in some places and makes it easy to fall back on the full complexity of the native APIs where necessary.
The traditional "god frameworks" (like Angular, Vue, React) cover all aspects of modern SPAs. Cuel only addresses a part of that. You should fill the other parts with something else.
State Management
I recommend adding some state management framework. You can also write your own if you feel confident about that. I use my own JavaScript proxy based state management library called xt8. If you have no idea what to use: if your app is rather complex and you'll have several people working on it, redux will be a solid choice. If your app is extremely simple you may skip the separate sate management and manage state inside your cuel components. If your in between xt8 or another simpler state manager may be a good choice.
No Extra Properties
One peculiarity of cuel that I encountered again and again is that it's very restrictive with regards to the data you can throw at it. Suppose you have a component like this:
cuel('my-component', {}, `
{{shallow}}
{{deep.a}}
{{deep.b}}
`);Now this will work:
Object.assign(myComponent, {shallow: 'foo', deep: {a: 'bar', b: 'baz'}, c: 'c'});But this will not:
Object.assign(myComponent, {deep: {a: 'bar', b: 'baz', c: 'c'}});The latter will throw an exception. Nested proxy objects may receive incomplete data but are not extensible. One reason for doing this is that I want to force consistency between the data model and the DOM binding. Often an additional property that is not bound will be a bug. On the other hand, if I supported that, cuel would have to manage the additional properties you add to the proxies. Remember, cuel does not manage state, it just offers proxy accessors to DOM APIs. State is the root of much evil in programming so cuel tries to play it safe there.
That means you'll have to consider this in your data model design.
Multiplexing
Another similar point is the following:
cuel('my-component', {}, `{{a}}{{a}}`);Often you'll want one model property to affect multiple properties in the DOM - and vice versa. While this would be possible (I implemented it in cuel's predecessor bindom), it is quite a can of worms. In my opinion the value of simplicity discussed above trumps the usefulness of this feature.
However, it is relatively simple and straightforward to add this at another point in your code. Assuming you'll use some state management framework it is advisable to write some code that facilitates connecting the state to the databinding. Indeed, if you use cuel's own connect functionality to do that binding for you, you'll already get this multicasting in the connect code. Please refer to connect's own documentation for more details.
Events
Speaking of events - cuel does not notify you about property changes. That means if you have an input element and you want your code triggered when the value changes, you should add an event handler for that:
cuel('my-component', {
set userText(evt) {
changeUserTextState(this.userText);
}
}, `<input type="text" .value=userText !change=userTextChange/>`
);It is quite possible to automate away the event handling. But that is not a
transparent API. Cuel would need to choose the event for you (change, input,
keyDown, etc.) and it would need to cover several other cases like checkbox
value and so on. All the while that would not save you all that much code on
your side. So the call for simplicity again trumps such features.
As a simple guideline: state changes should usually trigger DOM write accessors while DOM events should call state action handlers.
