Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/loilo/node-html-api

Makes creating an HTML API a breeze
https://github.com/loilo/node-html-api

Last synced: 5 days ago
JSON representation

Makes creating an HTML API a breeze

Awesome Lists containing this project

README

        

# HTML API

[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
[![npm](https://img.shields.io/npm/v/html-api.svg)](https://www.npmjs.com/package/html-api)

This package makes it easy to give your JavaScript-generated widgets a clean, declarative [HTML API](https://www.smashingmagazine.com/2017/02/designing-html-apis/).

It features

* a hybrid interface, allowing to change option values via a JavaScript API as well as by changing `data-*` attributes
* observation of the option attributes, allowing to react to changes
* decent presets and extensibility for type checking and casting
* support for all modern browsers down to IE 11
* reasonably small size: it's 3.8 KB minified & gzipped

---

Table of Contents

* [Motivation](#motivation)
* [Installation](#installation)
* [Include in the browser](#include-in-the-browser)
* [Include in Node.js](#include-in-nodejs)
* [Usage](#usage)
* [Read and write options](#read-and-write-options)
* [Type constraints](#type-constraints)
* [Basic constraints](#basic-constraints)
* [Union constraints](#union-constraints)
* [Custom constraints](#custom-constraints)
* [Provide default values](#provide-default-values)
* [Require option attributes](#require-option-attributes)
* [Events](#events)
* [Option value changes](#option-value-changes)
* [New elements](#new-elements)
* [Error handling](#error-handling)
* [Formal Definitions](#formal-definitions)

---

## Motivation

This package helps developers to provide an easy, declarative configuration interface for their component-like entities—let's call them "widgets"—purely via HTML.

Imagine an accordion widget.

```html

...

```

The way you would usually let users configure and initialize your widget on a per-instance basis is an additional inline ``, especially if your content is generated by something like a CMS. It may look something like this:

```html
<script>
new Accordion('#my-accordion', {
swipeTime: 0.8,
allowMultiple: true
})

```

This is however inconvenient to write, a little obstrusive to read and relatively hard to maintain on the client side, especially for non-developer users.

With this package, you can use the following little block of JavaScript inside your widget

```javascript
const api = htmlApi({
swipeTime: {
type: Number,
default: 0.8
},
allowMultiple: Boolean
})('.accordion')
```

to make all accordions configurable like so:

```html

...

```

This allows users of your widget to configure it *exclusively* in HTML, without ever having to write a line of JavaScript.

At the same time, the more powerful JavaScript-side API is open to you as the widget developer, featuring many goodies explained below:

```javascript
// Access the element-level API for the first .accordion
const elementApi = api.for(document.querySelector('.accordion'))

elementApi.options.swipeTime // 0.5
elementApi.options.multiple // true
```

## Installation

Install it from npm:

```bash
npm install --save html-api
```

### Include in the browser

You can use this package in your browser with one of the following snippets:

* The most common version. Compiled to ES5, runs in all major browsers down to IE 11:

```html


```

* Not transpiled to ES5, runs in browsers that support ES2015:

```html


```

* If you're really living on the bleeding edge and use ES modules directly in the browser, you can `import` the package as well:

```javascript
import htmlApi from "./node_modules/html-api/dist/browser.module.min.js"
```

As opposed to the snippets above, this will not create a global `htmlApi` function.

### Include in Node.js

To make this package part of your build chain, you can `require` it in Node:

```javascript
const htmlApi = require('html-api')
```

If you need this to work in Node.js v4 or below, try this instead:

```javascript
var htmlApi = require('html-api/dist/cjs.es5')
```

Note however that the package won't work when run directly in Node since it does rely on browser features like the DOM and `MutationObserver` (for which at the time of writing no Node implementation is available).

## Usage

Once you have somehow obtained the `htmlApi` function, you can use it to define an HTML API.

Let's take a look at the most basic example with the following markup and JS code:

```html

```

```javascript
/*
* Define an HTML API with only a `label` option which must
* be a string, and assign it to all .btn elements
*/
htmlApi({
label: {
type: String,
required: true
}
})('.btn')

/*
* The `change:label` event will tell whenever the `label` option
* changes on any `.btn`.
* It will also trigger when the API is first applied to an element
* to get an option's initial value.
*/
.on('change:label', event => {
event.element.textContent = event.value
})
```

That will make our button be labeled with a cheerful `I'm a magic button!`.

If now, in any way, the button's `data-label` attribute value would be changed to `"I'm batman."`, the change listener will trigger and the button label will update accordingly.

[You can try out this example on Codepen.](https://codepen.io/loilo/pen/Xaromw?editors=1011)

> Note that, because we have set the `required` flag on the `label` option to `true`, we enforce a `data-label` attribute to always be set.
> Removing the attribute in this setup would [raise an error](#error-handling).

### Read and write options

Of course as a widget developer, you could get your options directly from the `data-*` attributes. However, to make use of features like type casting, you'll have to access them via the JavaScript API.

Let's again take our button example from above:

```javascript
const api = htmlApi({ label: ... })('.btn')
```

We have now created an API, reading options from all `.btn` elements. However, to read (and write) the options of a *concrete* button element, we need to access the element-based API via the `for()` method:

```javascript
const elementApi = api.for(document.querySelector('.btn'))

// read the `label` option
elementApi.options.label // "I'm a magic button!"

// write the `label` option
elementApi.options.label = "I'm batman."
```

### Type constraints

One of the core features of this package is type casting—converting options of various types to strings, i.e. to values of `data-*` attributes ("serialize"), and evaluating them back to their original type ("unserialize").

#### Basic constraints

The examples above introduced the simplest of types: `String`. However, there are many more:

```javascript
htmlApi({
// This is the shorthand way to assign a type
myOption: Type
})
```

Instead of `Type`, you could use one of the following:

* **`null`**

> Enforces a value set through `elementApi.options.myOption` to be...

`null`.

> Unserializes the `data-my-option` attribute by...

returning `null` if the serialized value is `"null"` and throwing an error otherwise.

Note that *every* option will be considered nullable if neither the definition marks it as [required](#require-option-attributes) nor it has a defined [default value](#provide-default-values).

* **`Boolean`**

> Enforces a value set through `elementApi.options.myOption` to be...

a boolean: `true` or `false`.

> Unserializes the `data-my-option` attribute by...

evaluating it as follows:

* `"true"` and `""` (the latter being equivalent to just adding the attribute at all, as in ``) will evaluate to `true`
* `"false"` and the absence of the attribute will evaluate to `false`

* **`Number`**

> Enforces a value set through `elementApi.options.myOption` to be...

of type `number`, including [`Infinity`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Infinity) but not [`NaN`](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/NaN).

> Unserializes the `data-my-option` attribute by...

calling `+value`, which will cast a numeric string to an actual number.

* **`Array`**

> Enforces a value set through `elementApi.options.myOption` to be...

an array.

> Unserializes the `data-my-option` attribute by...

parsing it as JSON.

* **`Object`**

> Enforces a value set through `elementApi.options.myOption` to be...

a plain object.

> Unserializes the `data-my-option` attribute by...

parsing it as JSON.

* **`Function`**

> Enforces a value set through `elementApi.options.myOption` to be...

a function.

> Unserializes the `data-my-option` attribute by...

`eval()`ing it.

The serialization is done via the function's `.toString()` method (which is not [yet](http://tc39.github.io/Function-prototype-toString-revision/) standardized but still works in all tested browsers so far).

Be aware that because `eval()` changes pretty much the whole environment of your function, you should only use functions that do not rely on anything but their very own parameter values.

* **`htmlApi.Enum(string1, string2, string3, ...)`**

> Enforces a value set through `elementApi.options.myOption` to be...

a string, and as such, one of the provided parameters.

* **`htmlApi.Integer`**

> Enforces a value set through `elementApi.options.myOption` to be...

an integer.

Its range can be additionally constrained by using

* `htmlApi.Integer.min(lowerBound)`
* `htmlApi.Integer.max(upperBound)` or
* `htmlApi.Integer.min(lowerBound).max(upperBound)`

* **`htmlApi.Float`**

> Enforces a value set through `elementApi.options.myOption` to be...

any finite number.

Its range can be additionally constrained by using

* `htmlApi.Float.min(lowerBound)`
* `htmlApi.Float.max(upperBound)` or
* `htmlApi.Float.min(lowerBound).max(upperBound)`

#### Union constraints

You can use an array of multiple type constraints to make an option valid if it matches *any* of them.

If you, for example, would like to have an option for your widget that defines the `framerate` at which animations will be performed, you could do it like this:

```javascript
const {Integer, Enum} = htmlApi

htmlApi({
framerate: [ Integer.min(1), Enum('max') ]
})
```

This would allow the `data-framerate` to either take any integer value from `1` upwards or `max`.

---

Union type constraints are powerful. However, be careful when using them, especially if `String` is one of them.

If you define an option like the following:

```javascript
myOption: [Number, String]
```

you should be aware that the number `5` and the string `"5"` do serialize to the same value (which is `"5"`).

Consequently, if you set your option's value to a numeric string (like in `api.options.myOption = "5"`), it will still be unserialized as the number `5`.

Generally, serialized options are evaluated from the most narrow to the widest type constraint. For example, `Number` is more narrow than `String` because all serialized numbers can be deserialized as strings, but not all serialized strings be deserialized as numbers. This means that the attempt to unserialize a stringified option value check applicable type constraints in the following order:

1. [Custom type constraints](#custom-constraints)
2. `null`
3. `Boolean`
4. `Number`
5. `Array`
6. `Object`
7. `Function`
8. `String`

Of course, of this list, only those constraints that are given in an option's definition will be considered.

#### Custom constraints

You can define your own type constraints. They are just plain objects with a `validate`, a `serialize` and an `unserialize` method.

Since object interfaces in TypeScript are pretty concise and should be readable for most JS developers, here's the interface structure of such a constraint:

```javascript
interface Constraint {
/*
* Checks if a value belongs to the defined type
*/
validate (value: any): value is Type

/*
* Converts a value of the defined Type into a string
*/
serialize (value: Type): string

/*
* The inverse of `serialize`: Converts a string back to the
* defined Type. If the string does not belong to the Type
* this method should throw an Error.
*/
unserialize (serializedValue: string): Type
}
```

And since many people (me included) do learn things better by example, this is the structure of this package's built-in `Number` constraint:

```javascript
{
validate: value => typeof value === 'number' && !isNaN(value),
serialize: number => String(number),
unserialize: numericString => +numericString
}
```

### Provide default values

If no appropriate `data-*` attribute for an option is set, its value will default to `null`.

However, an option definition may provide a default value that will be used instead:

```javascript
const {Enum} = htmlApi

htmlApi({
direction: {
type: Enum('forwards', 'backwards', 'auto'),
default: 'auto'
}
})
```

Now whenever reading `elementApi.options.direction` without the `data-direction` attribute set, `"auto"` will be returned.

> **Note:** Providing a default value for an option is mutually exclusive with [marking it as `required`](#require-option-attributes).

### Require option attributes

If an option should neither have a defined default value nor default to `null` (which could be a potential type constraint violation), you may flag it as `required`:

```javascript
const {Enum} = htmlApi

htmlApi(btn, {
direction: {
type: Enum('forwards', 'backwards'),
required: true
}
})
```

This will [raise an error](#error-handling) whenever the `data-direction` attribute is not set to a valid value.

> **Note:** Marking an option as `required` is mutually exclusive with [providing a default value](#provide-default-values).

### Events

Both the `api` (returned by `htmlApi(config)(elements)`) and the `elementApi` (returned by `api.for(element)`) are event emitters. They offer `on`, `once` and `off` methods to handle messages coming from them.

#### Option value changes

Changing an option, either through the `elementApi.options` interface or through a `data-*` attribute, will emit two events: `change` and `change:[optionName]`

Let's say you somehow changed the previously unset option `label` to `"Greetings, developer"`. Then you could react to this change by using one of the following snippets:

```javascript
elementApi.on('change:label', event => {
/*
* The `event` object has the following properties:
*/

/*
* "Greetings, developer"
*/
event.value

/*
* null
*/
event.oldValue

/*
* `true` if this was triggered by the initialization of the API
* and not by an actual change
*/
event.initial
})
```

You could also listen to any option changes:
```javascript
elementApi.on('change', event => {
/*
* The `event` object has the same properties as in `change:label`
* and additionally:
*/

/*
* "label"
*/
event.option
})
```

All those events will also be propagated to the `api`. That means, you could also do:
```javascript
api.on('change:label', event => {
/*
* The `event` object has the same properties as in
* elementApi.on('change:label'), and additionally:
*/

/*
* The element on which the change happened
*/
event.element

/*
* The element API referring to that element
*/
event.elementApi
})
```

The same goes for `api.on('change')`.

> **Note:** Please be aware that option changes will be grouped.
> That means that setting an option to two different values subsequently (i.e. in the same call stack) will only cause the last one to trigger a change with the `oldValue` on the event still being the value before the first change since the intermediate change did never apply.

#### New elements

If you applied your created HTML API to a selector string instead of a concrete element, this package will set up a [MutationObserver](https://developer.mozilla.org/docs/Web/API/MutationObserver) to keep track of new elements on the website that match the selector.

When such an item enters the site's DOM, it will trigger a `newElement` event on the `api`:

```javascript
api.on('newElement', event => {
/*
* The `event` is an object with the following properties:
*/

/*
* The newly inserted element
*/
event.element

/*
* The element API referring to that element
*/
event.elementApi
})
```

#### Error handling

The `elementApi` also emits `error` events which will be triggered when a required option is missing or an option is set to a value not matching its type constraints:

```javascript
elementApi.on('error', err => {
/*
* The `event` is an object with the following properties:
*/

/*
* What caused the error
* "invalid-value-js", "invalid-value-html" or "missing-required"
*/
error.type

/*
* Some details about the trigger
* Just the option name for "missing-required", an object in
* the form of { option, value } for the "invalid-value-*" types
*/
error.details

/*
* A clear English message that tells what went wrong
*/
error.message
})
```

As with the `change` events, all `error` events will be passed up to the `api` as well.

## Formal definitions

To get a complete picture of what's possible with the `htmlApi` function, here's its signature:

```javascript
htmlApi(options: { [option: string]: OptionDefinition|TypeConstraint }): ApiFactory
```

where

* An `OptionDefinition` is a plain object matching the following interface:

```javascript
interface OptionDefinition {
/*
* A type constraint as defined below.
* This *must* be set, otherwise the package will not know how
* to serialize and unserialize option values.
*/
type: TypeConstraint

/*
* Tells if the data attribute belonging to this option must
* be set. If not set or set to `false`, the `default` option
* will be used.
*/
required?: boolean

/*
* A default value, applying when the according data-* attribute
* is not set. If set, the option must not be `required`.
*/
default?: any
}
```

* A `TypeConstraint` is either
* one of the following constraint shorthands:
* the `Boolean` constructor, allowing boolean values or
* the `Number` constructor, allowing numeric values or
* the `String` constructor, allowing strings or
* the `Array` constructor, allowing arrays or
* the `Object` constructor, allowing plain objects or
* the `Function` constructor, allowing functions or
* `null`, allowing the value to be `null`

* one of the following built-in constraints:
* `htmlApi.Enum(string1, string2, string3, ...)` for one-of-the-defined strings
* `htmlApi.Integer` for an integer number whose range might be further constrained via
* `htmlApi.Integer.min(lowerBound)`
* `htmlApi.Integer.max(upperBound)` or
* `htmlApi.Integer.min(lowerBound).max(upperBound)`
* `htmlApi.Float` for a finite number whose range might be further constrained via
* `htmlApi.Float.min(lowerBound)`
* `htmlApi.Float.max(upperBound)` or
* `htmlApi.Float.min(lowerBound).max(upperBound)`

* a custom type `Constraint`, which is a plain object of the following structure:

```javascript
interface Constraint {
/*
* Checks if a value is of the defined type
*/
validate (value: any): value is Type

/*
* Converts a value of the defined Type into a string
*/
serialize (value: Type): string

/*
* The inverse of `serialize`: Converts a string back to the
* defined Type. If the string can not be successfully
* converted to the Type, this method should throw an Error.
*/
unserialize (serializedValue: string): Type
}
```
This lets you easily define and use your own custom types!

or

* a union type, being a non-empty array of any of the above.

The formal way to describe this would be:

```javascript
type UnionType = Array<
typeof Boolean |
typeof Number |
typeof String |
typeof Boolean |
typeof Array |
typeof Object |
typeof Function |
null |
Constraint
>
```

> **Note:** `htmlApi.Enum`, `htmlApi.Integer` and `htmlApi.Float` are not listed in the `UnionType` definition since they are just `Constraint` objects.

* An `ApiFactory` is a function which takes elements and returns an `Api` object

```javascript
interface ApiFactory {
(elements: string|Element|Element[]|NodeList|HTMLCollection): Api
}
```

* An `Api` is a plain object of the following structure:

```javascript
interface Api {
/*
* An array of all elements the API applies to
*/
elements: Element[]

/*
* Gets the element-based API for a certain element
*/
for (element: Element): ElementApi

/*
* Adds a listener to the `change` or `error` event
*/
on (
event: "change",
listener: (event: OptionChangeEvent & ElementRelatedEvent) => any
): this
on (
event: "change:[optionName]",
listener: (event: ConcreteOptionChangeEvent & ElementRelatedEvent) => any
): this
on (
event: "error",
listener: (event: ErrorEvent & ElementRelatedEvent) => any
): this

/*
* Like `on`, but listeners detach themselves after first use
*/
once (
event: "change",
listener: (event: OptionChangeEvent & ElementRelatedEvent) => any
): this
once (
event: "change:[optionName]",
listener: (event: ConcreteOptionChangeEvent & ElementRelatedEvent) => any
): this
once (
event: "error",
listener: (event: ErrorEvent & ElementRelatedEvent) => any
): this

/*
* Removes listeners
*/
off (
event: "change",
listener: (event: OptionChangeEvent & ElementRelatedEvent) => any
): this
off (
event: "change:[optionName]",
listener: (event: ConcreteOptionChangeEvent & ElementRelatedEvent) => any
): this
off (
event: "error",
listener: (event: ErrorEvent & ElementRelatedEvent) => any
): this

/*
* Destroys the API, disconnecting all MutationObservers
* Also destroys all ElementApi objects
*/
destroy (): void
}
```

* `ElementApi` is a plain object of the following structure:

```javascript
interface ElementApi {
/*
* An object with all defined options as properties
*/
options: { [option: string]: any }

/*
* Adds a listener to the `change` or `error` event
*/
on (
event: "change",
listener: (event: OptionChangeEvent) => any
): this
on (
event: "change:[optionName]",
listener: (event: ConcreteOptionChangeEvent) => any
): this
on (
event: "error",
listener: (event: ErrorEvent) => any
): this

/*
* Like `on`, but listeners detach themselves after first use
*/
once (
event: "change",
listener: (event: OptionChangeEvent) => any
): this
once (
event: "change:[optionName]",
listener: (event: ConcreteOptionChangeEvent) => any
): this
once (
event: "error",
listener: (event: ErrorEvent) => any
): this

/*
* Removes listeners
*/
off (
event: "change",
listener: (event: OptionChangeEvent) => any
): this
off (
event: "change:[optionName]",
listener: (event: ConcreteOptionChangeEvent) => any
): this
off (
event: "error",
listener: (event: ErrorEvent) => any
): this

/*
* Destroys the API, disconnecting all MutationObservers
*/
destroy (): void
}
```

* There are several kinds of events mentioned above. They are all plain objects with different structure:

```javascript
interface ElementRelatedEvent {
/*
* The element the event refers to
*/
element: Element,

/*
* The ElementApi for the given element
*/
elementApi: ElementApi
}

interface OptionChangeEvent {
/*
* The new value of the option
*/
value: any,

/*
* The option's previous value
*/
oldValue: any
}

interface ConcreteOptionChangeEvent extends OptionChangeEvent {
/*
* The name of the changed option
*/
option: string
}

interface ErrorEvent {
type:
"missing-required" |
"invalid-value-js" |
"invalid-value-html"

/*
* A clear, English error message
*/
message: string

/*
* Any details on the error
*/
details: any
}
```