Markup

Midnight has JSX style elements for writing HTML, and a small runtime (midnight.markup) that renders it to HTML: components, scoped styles, and (w/ a host that keeps state between requests, like rest's page handlers) interactive pages.

local { css, state } = require('midnight.markup')

local s = css({ counter = { display = 'flex', gap = '.5rem' } })

local Counter = (props) -> {
  local st = state({ count = props.start || 0 })
  return <div class={s.counter}>
    <button onclick={() -> { st.count -= 1 }}>-</button>
    <span>{st.count}</span>
    <button onclick={() -> { st.count += 1 }}>+</button>
  </div>
}

return () -> <html>
  <head><title>counters</title></head>
  <body><Counter/><Counter start={10}/></body>
</html>

Running it

Markup is part of the language, so there's nothing to enable:

midnight page.mn                                 # run
midnight compile .                               # compile to lua (runs on any lua, see Portability)
midnight compile -markup-factory harmony.h .     # w/ another factory (see below)
L := lua.NewState(lua.Options{Midnight: parse.MidnightOptions{MarkupFactory: "harmony.h"}})

< only starts an element where a value can start (ex. after =, (, ,, return, or another element). After anything that ends a value (a name, number, string, ), ], }), it's the less than operator, so a <b and f() < x are comparisons. require('midnight.jsx') is the runtime's old name, and still works for now.

Syntax

Elements are lowered to calls of h(tag, props, ...children), like JSX's classic runtime:

midnightlua
<div class="card" id={id}>hi {name}</div>h('div', { class = 'card', id = id }, 'hi ', name)
<input disabled/>h('input', { disabled = true })
<Card {...props} title="x"/>h(Card, <props merged left to right>)
<ui.Button/>h(ui.Button, {})
<>a {b}</>h(nil, nil, 'a ', b)

The factory

Elements call h: whatever h is in scope (ex. local { h } = require('midnight.markup')). If there's no h, it's imported automatically (once, at the top of the file, like JSX's automatic runtime), so files w/ elements don't need any imports.

-markup-factory name (MidnightOptions.MarkupFactory) uses another function instead, ex. -markup-factory harmony.h or -markup-factory React.createElement.

Rendering

render(node) renders to an HTML string, document(node) to a whole page (w/ <!DOCTYPE html>, scoped styles, and the client script of interactive pages).

Components

A component is a function from props to an element (or anything renderable). props.children has its children.

local Card = (props) -> <section class="card">
  <h2>{props.title}</h2>
  {props.children}
</section>

return <Card title="hi"><p>body</p></Card>

Components run when they're rendered (top down, parents first), not when the element is created. Calling one as a plain function (Card(props)) renders it inline, as part of the caller.

State

state(init) returns a table that's kept between renders of a component instance (ex. across actions on an interactive page). init (a table, or a function returning one) is only used the first time.

local Counter = (props) -> {
  local s = state({ count = props.start || 0 })
  return <button onclick={() -> { s.count += 1 }}>{s.count}</button>
}

Instances are identified by their position in the tree (and which component they are), so call state the same way on every render (not conditionally). {cond && <X/>} keeps its place even when it renders nothing, so it doesn't move the components after it. In lists, give items a key (<Item key={item.id}/>) so their state follows them when the list changes. State of components that are no longer rendered is dropped.

Scoped styles

css(rules) turns lua tables into css, w/ class names generated from a hash of the rules, and returns the class names by name:

local s = css({
  card = {
    padding = '1rem',
    backgroundColor = '#222',              -- or background_color
    ['&:hover'] = { borderColor = 'gold' },  -- `&` is the class
    ['&.big, &:focus'] = { padding = '2rem' },
    ['$title'] = { margin = 0 },           -- `$name` is another class from this css()
    img = { width = '100%' },              -- descendants
    ['@media (max-width: 600px)'] = { padding = '.5rem' },
  },
  title = { color = 'gold' },
})

return <section class={s.card}><h2 class={s.title}>hi</h2></section>

Custom elements

Behavior on the client is plain js, as custom elements. elements(src, tags) returns components for the elements of a js module (a library), by name:

-- elements.mn: the page's element library
local { elements } = require('midnight.markup')
return elements('/static/elements.js', {
  CopyButton = 'got-copy',
  BlameButton = 'got-blame',
})
// /static/elements.js: exports each element's class under its name
export { default as CopyButton } from './elements/copy-button.js'
export { default as BlameButton } from './elements/blame-button.js'
local { CopyButton } = require('elements')
return <CopyButton src={url}><button>copy</button></CopyButton>

Interactive pages

On a mounted page, functions in on* attributes (onclick, onClick, and on:click are all click) become actions: the event calls the server, which runs the function and sends back what changed.

local { mount, dispatch, document } = require('midnight.markup')

-- the page
document(mount(() -> <App/>, { rpc = '/actions', headers = { ['X-State'] = id } }))

-- the rpc endpoint (POST, json body { id, data }); returns { headers, body }
dispatch(body.id, body.data)

W/o rpc, actions POST to the page's own url (location.pathname + location.search, as the browser sees it), so the host can handle them w/ the same route as the page, even behind a proxy that rewrites paths.

The host has to run the page and its actions in the same lua state (rest's page handlers do all of this, see rest's docs). From there:

Runtime API

require('midnight.markup'):

h(tag, props, ...)element (tag string), component (function), or fragment (nil)
render(node)HTML string
document(node)<!DOCTYPE html> page, w/ styles (and the client script of interactive pages)
raw(html)trusted HTML, rendered as is
map(list, fn)list of fn(value, index)
escape(s)HTML escaped string
cx(...)joins class names (strings/numbers kept, false/nil dropped, tables flattened)
isElement(v)whether v was built by h
state(init)state of the component being rendered
css(rules)scoped styles, returns class names
styles()all styles, as a <style> (or '')
elements(src, tags)components for the custom elements of a js module, by name (see above)
scripts()the module scripts of the element libraries used in the last render (or '')
mount(root, { rpc, headers, morph })makes root() the interactive page (actions POST to rpc, or the page's own url), returns root()
dispatch(id, data)runs an action, returns { headers, body } (what changed)
isInteractive()whether the last render of the mounted page had actions
session()a page of its own (state and actions), for hosts serving many pages from one state (see below)
s:render(root)renders root() (or a node) w/ the session's state, actions as data-mn-<event>="id" attributes
s:rerender()renders the last root again (ex. after an action)
s:dispatch(id, data)runs an action of the last render, false if there's no such action (stale)
s:isInteractive()whether the session's last render had actions

Sessions

mount makes one page per lua state, which is what a host that runs a state per page (like rest) wants. A host that serves many pages from one state (ex. a chat bot's panel for each of its viewers) uses a session() per page instead: each has its own component state and actions, and the host keeps them by whatever identifies the page.

Sessions render actions as data-mn-<event>="id" attributes (data-mn-click, data-mn-submit, ...) and never include a client script, so they survive html sanitizers (which drop on* attributes) and the host's own client does the rest: find the attribute for an event, send { type, value, checked, key, form } (like mount's client), dispatch it, and rerender.

Rendering isn't reentrant across sessions: render one at a time (a host that runs actions concurrently has to serialize renders).

Portability

Only the syntax is midnight specific. The runtime is plain lua (5.1 to 5.5, LuaJIT), so midnight compile output runs anywhere w/ the runtime on package.path:

midnight/markup.lua            -- lib/markup.lua
midnight/markup/idiomorph.lua  -- lib/idiomorph.lua (optional: w/o it, pages load idiomorph from a cdn)

midnight preloads both (and compiles them once per process).

Tooling