Skip to content

foxdonut/seview

Repository files navigation

seview: S-Expression View

A simple way of writing views with s-expressions, and meant to be used with a virtual DOM library.

Why?

Because plain JavaScript is simpler to write and build than JSX or HTML string literals, and it's great to write views in a way that is independent of the virtual DOM library being used. It's also nice to use convenient features even if the underlying virtual DOM library does not support them.

Example

Instead of writing this in JSX:

<div id="home">
  <span className="instruction">Enter your name:</span>
  <input type="text" id="username" name="username" size="10"/>
  {isMessage && <div className={"message" + (isError ? " error" : "")}>{message}</div>}
</div>

Or even this in hyperscript:

h('div', { id: 'home' }, [
  h('span', { className: 'instruction' }, 'Enter your name:'),
  h('input', { type: 'text', id: 'username', name: 'username', size: 10 }),
  isMessage && h('div', { className: 'message' + (isError ? ' error' : '') }, message)
])

You can write this with seview:

['div#home',
  ['span.instruction', 'Enter your name:'],
  ['input:text#username[name=username][size=10]'],
  isMessage && ['div.message', { class: { 'error': isError } }, message]
]

Besides the conveniences of the syntax, you also don't have to write h at every element. To switch from one virtual DOM library to another, you only need to make changes in one place. All your view code can remain the same.

If you are using the Meiosis pattern, seview is a great way to further decouple your code from specific libraries. Your views become independent of the underlying virtual DOM library API.

Installation

Using Node.js:

npm i seview

With a script tag:

<script src="http://unpkg.com/seview"></script>

Usage

Out of the box, seview supports 3 view libraries:

Using a different library is not difficult. See Using a different view library.

When using seview with built-in support, we assume writing views with the following attributes:

  • class for the HTML class attribute - converted to className for React
  • for for the HTML for attribute - converted to htmlFor for React
  • innerHTML for using unescaped HTML - converted appropriately for React, Preact, and Mithril
  • onClick, onChange, etc. for DOM events - converted to lowercase for Mithril

By writing views with the conventions above, you can switch between React, Preact, or Mithril without changing any of your view code! You can see this in action in the Meiosis Realworld Example, where switching can be achieving just by editing one file.

React

To use seview with React:

import { h } from 'seview/react';
import { createRoot } from 'react-dom/client';

const rootView = (...) =>
  ['div.container',
    [...]
  ];

const root = createRoot(document.getElementById('app'));
root.render(h(rootView(...)));

Click here for a live example: seview + React

Preact

To use seview with Preact:

import { h } from 'seview/preact';
import { render } from 'preact';

const rootView = (...) =>
  ['div.container',
    [...]
  ];

const element = document.getElementById('app');
render(h(rootView(...)), element);

Click here for a live example: seview + Preact

Mithril

To use seview with Mithril:

import { h } from 'seview/mithril';
import m from 'mithril';

const rootView = (...) =>
  ['div.container',
    [...]
  ];

m.mount(document.getElementById('app'), {
  view: () => h(rootView(...))
});

Click here for a live example: seview + Mithril

Features

seview supports CSS-style selectors in tag names, { class: boolean } for toggling classes, using an array or varags for children, flattening of nested arrays, and removal of null/empty elements.

Element

An element is an array:

[tag, attrs, children]

or a string (text node):

'this is a text node'

The tag can be a string, or something that your virtual DOM library understands; for example, a Component in React. For the latter, seview just returns the selector as-is.

Tag

When the tag is a string, it is assumed to be a tag name, possibly with CSS-style selectors:

  • 'div', 'span', 'h1', 'input', etc.
  • 'div.highlighted', 'button.btn.btn-default' for classes
  • 'div#home' for id
  • 'input:text' for <input type="text">. There can only be one type, so additional types are ignored. 'input:password:text' would result in <input type="password">.
  • 'input[name=username][required]' results in <input name="username" required="true">
  • if you need spaces, just use them: 'input[placeholder=Enter your name here]'
  • default tag is 'div', so you can write '', '.highlighted', '#home', etc.
  • these features can all be used together, for example 'input:password#duck.quack.yellow[name=pwd][required]' results in <input type="password" id="duck" class="quack yellow" name="pwd" required="true">

Attributes

If the second item is an object, it is considered to be the attributes for the element.

Of course, for everything that you can do with a CSS-style selector in a tag as shown in the previous section, you can also use attributes:

['input', { type: 'password', name: 'password', placeholder: 'Enter your password here' }]

You can also mix selectors and attributes. If you specify something in both places, the attribute overwrites the selector.

['input:password[name=password]', { placeholder: 'Enter your password here' }]
<input type="password" name="password" placeholder="Enter password name here">
['input:password[name=username]', { type: 'text', placeholder: 'Enter your username here' }]
<input type="text" name="username" placeholder="Enter your username here">

Classes

Classes can be specified in the tag as a selector (as shown above), and/or in attributes using class:

['button.btn.info', { class: 'btn-default special' }]
<button class="btn info btn-default special">

If you specify an object instead of a string for class, the keys are classes and the values indicate whether or not to include the class. The class is only included if the value is truthy.

// isDefault is true
// isError is false
['button.btn', { class: { 'btn-default': isDefault, 'error': isError } }]
<button class="btn btn-default">

Children (array or varags)

The last item(s), (starting with the second if there are no attributes, and starting with the third if attributes are present), are the children. The children can be:

  • an array, or
  • varargs.

Using an array

You can specify children as an array:

['div', [
  ['span', ['Hello']],
  ['b', ['World']]
]
<div>
  <span>Hello</span>
  <b>World</b>
</div>

Using varargs

You can specify children as varargs:

['div',
  ['span', 'Hello'],
  ['b', 'World']
]
<div>
  <span>Hello</span>
  <b>World</b>
</div>

Varargs and text nodes

The problem with supporting varargs is, how do you differentiate a single element from two text nodes?

For example:

['div', ['b', 'hello']]

vs

['div', ['hello', 'there']]

For the second case, varargs must be used:

['div', 'hello', 'there']

Flattened arrays

Whether using an array of children or varargs, nested arrays are automatically flattened:

['div', [
  ['div', 'one'],
  [
    ['div', 'two'],
    [
      ['div', 'three']
    ]
  ]
]]

or

['div',
  ['div', 'one'],
  [
    ['div', 'two'],
    [
      ['div', 'three']
    ]
  ]
]

Both result in

<div>
  <div>one</div>
  <div>two</div>
  <div>three</div>
</div>

Ignored elements

The following elements are ignored and not included in the output:

  • undefined
  • null
  • false
  • ''
  • []

This makes it simple to conditionally include an element by writing:

condition && ['div', 'message']

If condition is falsy, the div will not be included in the output. Because it is completely excluded, this will work even if the virtual DOM library that you are using does not handle false, null, or undefined.

Elements converted to a string

The following elements will be converted to a string:

  • true
  • numbers
  • NaN
  • Infinity

Using a different view library

seview exports a single function, seview, that you use to create an h function that works with the view library of your choice. Calling h(view), where view is the view expressed as arrays as we have seen above, produces the final result suitable for your view library.

To set up your view library, call seview and pass it a function that gets called for every node in the view. Each node has the following structure:

{
  tag: 'button',
  attrs: { id: 'save', class: 'btn btn-default', ... }
  children: [ ... ]
}

The function that you write needs to convert the structure above to what is expected by the view library that you are using. Note that your function will also be called for each element in children.

import { seview } from 'seview';
import { myViewLibrary } from 'my-view-library';

export const h = seview((node) => {
  const tag = processTagAsNecessary(node.tag);
  const attrs = processAttrsAsNecessary(node.tag);

  return myViewLibrary(tag, attrs, node.children || []);
});

Then you can use h with your view library in a similar way as we saw in the Usage section.

Credits

seview is inspired by the following. Credit goes to the authors and their communities - thank you for your excellent work!


seview is developed by foxdonut (@foxdonut00) and is released under the MIT license.