RML is a "conceptual" functional-reactive extension of HTML and the DOM with first-class support for promises and observables
Both HTML and JavaScript have evolved significantly over time. HTML introduced new tags and new magic on one end, JavaScript is creating new primites like Promises and Observables.
Despite this progress, HTML markup is still limited to strings and string representations of numbers and some basic function expressions.
<div id="a-string" class="more strings" onclick="someFunction()" data-foo="bar">
some <strong>HTML</strong> string
</div>
JavaScript, on the other hand, deals with a wide range of primitives and data types, let alone it can access the above through a number of different DOM APIs, like event emitters (.on('event', fn)
), object properties (.innerHTML
), etc.
This leads to a lot of boilerplate.
The next logical step is to make HTML support more primitives and data types natively, or more transparently to scripts.
This document is the specification of such thing, the Reactive Markup Language, or RML for short.
At this stage RML is a concept, most easily implemented by template engines, UI libraries or frameworks, either based on template literals or other abstractions like JSX/ESX.
A reference implementation is provided by Rimmel.js
RML templates can take any of the following forms:
- Simple HTML Strings
- JavaScript tagged templates with string/nlmber/function/Promise/Observable expressions). E.g.:
<div>${someJavaScriptVariable}</div>
- DOM Objects / JavaScript Maps
- Arrays
The JSON form is called JSON, but in reality it refers to regular JavaScript objects.
These are used to represent and map to HTML attributes, including some special-purpose ones such as class
, data-*
, disabled
, event listeners and can present themselves in a variety of forms, depending on the type of attribute they represent.
- Generic attribute objects, e.g.:
{class: ['class1', 'class2'], dataset: {'key1': 'value1'}, onclick: () => null}
- Single dataset objects, e.g.:
{key: 'value'}
- Multiple dataset objects, e.g.:
{key1: 'value1', key2: 'value2'}
- Class Objects, e.g.:
{class1: true, class2: false}
- ClassList arrays, e.g.:
['class1', 'class2']
- CSS Style Objects, e.g.:
{position: 'absolute', width: '100%'}
- CSS Style Values, e.g.:
'absolute'
, ornew Promise(resolve => setTimeout(() => resolve('100%'), 1000))
Values that can be assigned to CSS styles, class names, attribute values, dataset items, can be either static (strings) or deferred values (promises, observables).
In the first case, they are assigned and sinked to the DOM immediately.
<div data-title="title1">
Promises are sinked as they resolve, Observables every time they emit.
<div data-title="${somePromiseOrObservable}">
<div class="class1 class2 ${someClassObject}">
Scripts can generate either and sink it into existing DOM by direct assignment, through a unified and simplified API.
E.G.:
const classes = { class1: true, class2: false }
const template = rml`<div id="" class="${classes}">`
RML can be implemented in both tagged-template and JSX/ESX-based template engines. For convenience, in this document only tagged-templates will be used for examples, but their meaning should be equivalent in a JSX/ESX-like implementation.
const content = 'some data'
const taggedTemplate = rml`
<div>${content}</div>
`
const ESXTemplate = (
<div>{content}</div>
)
Promises are first-class citizens in RML, so they can be assigned to child nodes and attributes.
Whatever the promise resolves to, will be injected as child of the div
below:
const p = new Promise(/*...*/)
const template = rml`
<div>${p}</div>
`
Just like Promises, Observables are also first-class citizens in RML, so they can be assigned to child nodes and attributes alike. In interactive web applications Observables can play a major role and bring many of the benefits of functional programming.
const stream = Observable(/*...*/)
const template = rml`
<div>${stream}</div>
`
const data = Observable.of({author: 'Stephen King', title: 'Misery'})
const classes = Observable.of({class1: true, class2: false})
const moreStuff = Promise.resolve({
class: {
class3: true,
class4: false,
}
data-year: 1987,
})
const template = rml`
<div ...${data} class="...${classes}" ...${moreStuff}>some content</div>
`
This should, once all observables have emitted and promises resolved, generate the following tag:
<div data-author="Stephen King" data-title="Misery" class="class1 class3" data-year="1987">some content</div>
RML is a reactive markup, supporting the functional-reactive paradigm. This means some key concepts like Sources and Sinks have their special place in the syntax.
Most HTML attributes whose name start with "on" represent event sources and are implemented as event emitters.
In RML, event sources can be connected to plain JavaScript functions or to a special, writable type of Observables typically referred to as Subject
in RxJS.
This means, every time an event happens, a bound function will be called or a bound Subject will have its .next() method called with the corresponding HTMLEvent instance.
const handlerFunction = (e: InputEvent) => {
console.log('A plain JS function that does something')
}
const template = rml`
<button onclick="${handlerFunction}">click me</button>
`
This is the simplest case. Button clicks will simply call the handlerFunction
.
const handlerStream = new Subject()
.pipe(
// some further processing...
)
const template = rml`
<button onclick="${handlerStream}">click me</button>
`
In the code above, every time the button is clicked, it will call handlerStream.next()
passing a ClickEvent
.
By convention, every HTML attribute whose name starts with "on", will be treated as an event source.
There is one special event source, 'rml:onmount', not part ofthe HTML specification, which will fire immediately after a given element has been attached to the DOM.
Note how every RML attribute that's not an HTML standard is prefixed with rml:
, similarly to the way XML Namespaces are used.
const init = (e: MountEvent) => {
console.log('An element has been mounted')
}
const template = rml`
<div rml:onmount="${handlerFunction}"></div>
`
Sinks are the opposite of sources. Sources emit events, sinks render them to the DOM.
There are many types of sinks, depending on the use case. Typically they perform some final transformation before calling any relevant DOM API to display data.
Sinks can be implicit, when it's obvious from the syntax and the context what should happen, and explicit, where developers can request a particular sink to be used to render data.
There are three categories of sinks: single-value, multi-value, and runtime sinks, which can respectively sink one or more values every time some data is emitted.
Dynamic sinks will determine at runtime what they need to do. An Observable<unknown>
would typically emit into a runtime sync.
Following is a list of implicit sinks used in RML
This any-value sink takes a string and sets innerHTML
on the specified node. It's used by default when a Promise or an Observable are placed as a child element of a tag:
const str = fetch(/*some.api/data*/).then(x=>x.text())
const template = rml`
<div>${str}</div>
`
If non-string values get emitted to this sink, the following will apply.
- If it's an array with 2 elements
[string | number, Observable<string> | Promise<any>]
, then the former item will be rendered synchronously, the latter will be subscribed to and asynchronously synched on emission. - Otherwise, array items will be concatenated. Static values immediately, deferred ones on subscription.
This multi-value sink takes a JavaScript Object and sets styles on the target object when an object or a deferred object (Promise or Observable) are set in a tag's style
attribute
const styles = {
position: 'relative',
marginTop: '1rem',
}
const template = rml`
<svg style="${styles}">
<some-shapes />
</svg>
`
This single-value sink takes a static or deferred string and sets is as a style on the target object when set in a tag's style
attribute's value:
const position = Promise.resolve('absolute')
const template = rml`
<div style="position: ${position}; top: 0; color: red;">
Red text floating
</div>
`
This single-value sink takes a static or deferred string and sets is as the value
on an <input>
tag:
const laterValue = Promise.resolve('hello world')
const template = rml`
<input type="text" value="${laterValue}">
`
This single-value sink takes a static or deferred string and sets is as the value
for the specified attribute in a tag:
const laterValue = Promise.resolve('hello world')
const template = rml`
<some-tag some-attribute="${laterValue}" />
`
This multi-value sink takes a static or deferred object and sets the corresponding key-value pairs as attribute-value pairs in the target tag:
const laterValue = Promise.resolve({
style: 'position: relative',
'data-some-key': 'some-dataset-value',
onmouseover: mouseoverHandler,
})
const template = rml`
<some-tag ...${laterValue} />
`
Sometimes the default sinks will not be a convenient way to sink data to the DOM. In that case, it is possible to request other sinks to be used by wrappingn data in a Sink
object.
Sink objects are instances of the Sink class and have a .sink
attribute that helps identify them.
const data = "Dirty text from <script>doSometingNasty</script> untrusted sources"
const template = rml`
<div>${InnerText(data)}</div>
`
In the code above, the string can be sinked to the DOM through .innerText
for security.
The following sinks need to be called explicitly:
- innerTextSink: same as the innerHTMLSink, except this one sets innerText on the target node.
- appendHTMLSink: similar to innerHTMLSink, this will call
.append()
on the target node.
Some advanced Observable primitives like the BehaviorSubject
have an initial static value, and then they can emit subsequent values asynchronously.
When a BehaviorSubject-like item is set in a RML template, its .value
property will be used for the initial rendering, then its .subscribe()
method will be called to get and sink subsequent values.
const stream = new BehaviorSubject('initial data')
setInterval(() => stream.next(getSomeRandomData()), 1000)
const template = rml`
<div>${stream}</div>
`
In this case, the div
will be initially rendered with the text initial data
without any FOUC, then every second will have a new value set via innerHTML
Extensible components are regular HTML Elements that can be enriched by synchronous or asynchronous mixins.
Mixins are just partial RML/DOM objects used to enrich their host tags with new attributes, event handlers, classes, etc.
They can be useful to make certain HTML tags gain new functionality without repeating code, or gain it at a later point in time.
Mixins are created easily, by implicitly invoking an Attribute Object Sink
.
// Make an element "content editable" when clicked
const editableNow = {
class: 'class1',
onclick: e => e.target.contentEditable = true,
}
const template = rml`
<div ...${editableNow}</div>
`
The same can be applied asynchronously, e.g. when some features are requested by the user:
// Make an element "content editable" when clicked
const editableWhenEnabled = new Promise(/*some trigger*/)
.then(() => ({
class: 'class1',
onclick: e => e.target.contentEditable = true,
}))
const template = rml`
<div ...${editableWhenEnabled}</div>
`
The reference implementation of RML is Rimmel.js
There are discussions going on around making HTML and/or the DOM natively support Observables at WHATWG DOM/544 and the more recent Observable DOM.