Putting an app on the web¶
lumenc web turns a Lumen app into a static site: real HTML, real CSS, real
links. Every page becomes its own document with the markup already in it, so
a search engine, a screen reader and a browser with scripting turned off all
get the page rather than an empty shell.
Use it when you want the same app to run on a desktop and to be readable at a URL. The app is not rewritten for the web and not drawn onto a canvas; the tree you wrote becomes elements, and the browser lays them out.
Build a site¶
That writes myapp/dist/web. To look at it, ask for a server:
It prints the address it is listening on. Open that; press Ctrl-C to stop. Opening the files directly from disk does not work, because a browser refuses to load a module script or a streamed WebAssembly module without a real origin.
The server listens on 127.0.0.1, so it answers this machine and nobody else.
--host <addr> widens that, and says so when you use it: this is a server for
developing against and for hosting a site yourself, and anything the public
reaches belongs behind a reverse proxy.
To watch the app answer per request rather than serve what the build wrote, ask for a render:
Every page then comes from the app running for the request that asked for it,
and everything else still comes from the directory. That is
rendering on a server with a socket attached; a
production deployment embeds lumen-ssr in a server of your own, and
lumenc web myapp --render ssr on its own writes the directory that server
reads.
What lands in the output directory¶
index.html the entry page
settings.html one document per page
404.html the shell a path with no document falls back to
sitemap.xml every page in every locale, for a crawler
robots.txt what a crawler is told, naming the sitemap
_redirects the deep-path rewrite file, when `host` names one
lumen.web.json what the browser runtime reads before anything else
styles.<hash>.css the app's stylesheet, plus the reset
app.<hash>.lmna the compiled app
app.<hash>.cdlb the compiled candela program, when the app has one
lumen-web.<hash>.wasm the runtime
lumen-web.<hash>.js the module that loads it
locale/<tag>.<hash>.ftl the catalogue for each locale the site is emitted in
assets/ every file the markup or the stylesheet points at
LICENSE the runtime's licence text, and
NOTICE the attribution that goes with it, and
THIRD-PARTY-LICENSES the licences of everything the runtime is built from
The three licence files travel because the runtime carries the engine and the libraries it is built from, and some of those ask that whoever receives the binary also receives the text. They are written whenever the runtime is; a site emitted without a runtime carries none of them.
<hash> is sixteen characters taken from the file itself, and it is there
because a static host is told nothing about how long to keep a file, so it
keeps it. A file whose contents change is written under a name nothing has a
copy of, so a visitor holding the last deploy gets this one; a file that did
not change keeps its name and is not fetched again. The documents are the
site's URLs, so they keep the names a visitor types and shares, and
lumen.web.json keeps its name because it is the one file that names all the
others. The documents fetch it with a marker on the URL that moves whenever
its contents do.
A rebuild into a directory that already holds a site leaves the last build's files there: the build writes what the site needs and deletes nothing. Unchanged files are written under the names they already had, so only what changed accumulates. Delete the directory first for a build with nothing else in it.
Nothing here is per-app code. The runtime is one prebuilt pair of files, the
same for every app and every platform, and it loads the compiled app the way
the desktop runtime loads it. A build never compiles Rust or WebAssembly, so
it takes about as long as lumenc build.
Under render = "ssr" that list holds everything except the documents. A page
is produced when it is asked for, so writing one here would leave a second
copy of it beside the one a visitor is sent. With runtime = false beside it,
the candela program, lumen.web.json, the catalogues and the runtime pair go
too: nothing loads them. The compiled app stays, because the server renders
from it, and the build prints the name it wrote it under.
How a page reaches the browser¶
The document holds the page as it looks on arrival, including the rows of a
list: a <for> over a list the build knows is written out row by row, with
each row's values already substituted in, so a crawler and a reader with no
scripting both get the list itself rather than an empty box. The runtime then
adopts what is already there: it binds itself to the existing elements instead
of building them again, so nothing moves when it starts. From then on the app
behaves as it does on the desktop, and a row or a branch that appears later is
built from the same compiled app the desktop reads.
An app that starts from a different list than the one the page was built with is put right on the first frame: rows the app does not have are taken out of the page, and rows it has and the page does not are built. Neither is something to configure; it is what keeps a stale document from showing a row that is gone.
Input comes from the browser and behaviour stays Lumen's. A click on a tab or a
radio reaches the same widget code a desktop app runs, and what it changes
reaches the page as an attribute the stylesheet already matches. Typing in a
bound <input> writes its signal, and so does moving a bound <slider>, so a
bind-text label next to either follows along.
A <checkbox> and a <radio> are a <label> around the browser's own control
and the caption beside it, which is the row they are on the desktop. The
control is what reports whether it is on, what a press anywhere on the row
lands on, and what a screen reader reads the caption as the name of; the same
.checkbox-box and .radio-dot rules style it on both targets.
How the styling reaches the page¶
The stylesheet holds the whole cascade, in three parts. The reset and the app's
own stylesheet each sit in a layer, lumen.reset then lumen.sheet. A style
written on an element sits in neither: it becomes a class the element carries
and a rule at the end of the file, and an unlayered rule beats a layered one
whatever the selectors weigh. That is what keeps bg="#101014" on a <tile>
ahead of a .card rule, the way it is ahead on the desktop.
A @keyframes block the app wrote is written out too, outside every layer,
with the @media query it was written inside if it had one. Its body goes
through as authored: a keyframe declares background and transform, the
browser's names, not Lumen's.
A site ships its own font the same way. Write the @font-face in
src/main.css and name the file relative to the app directory:
The build copies fonts/demo.woff2 under assets/, under a name carrying
the file's hash, and writes the block into styles.css pointing at the copy.
A file the app does not have fails the build, naming it. A src naming
another site, a data: URL or a local() font is left as written, so a font
served from somewhere else keeps working and nothing is fetched at build
time. A desktop run of the same app ignores the rule and draws with the fonts
the system has.
Nothing is written !important, which leaves that free for you. An important
declaration cannot be overridden by :hover, a media query or a keyframe, so
a page whose styling was written that way could not be animated at all. It is
also what lets a state written on the element, like hover-bg, reach the page:
a rule can carry :hover and an inline declaration cannot.
Two elements written the same way share one class, so a list of identically styled rows costs one rule, and the class is in the compiled app rather than in the document alone. A row the runtime builds after the page has loaded is spawned wearing it.
What the browser does itself¶
Where a browser already implements a behaviour Lumen gives the same meaning to, the page gets the browser's rather than a copy driven from the runtime.
A <dialog> is the clearest case. It opens as a real modal: it sits over the
whole page with no stacking order to arrange, the rest of the document stops
answering clicks and tab stops while it is up, focus lands on the element
marked autofocus when it opens, and Escape dismisses it. A dismissal writes
the signal named in open="...", so a script sees the same close it would see
from a Cancel button and the dialog reports the same rejected verdict it
reports on the desktop.
The browser's own dialog chrome is taken back off. A native dialog is a bordered card with its own fill and an inch of padding; a Lumen dialog is the whole window with the app's surface centred inside it, and that is what the page shows.
An image is emitted with the size of the file the build copied, so the page
holds its place in the layout before a byte of the image has arrived and the
text below it does not jump when it lands. The size is the file's own; a
stylesheet sizing the image still wins. An image the build did not copy, one
behind an external URL or with a src built from a {...} placeholder,
carries no size, because there was no file to read it out of. Every image
after the first one on the page is fetched when the reader scrolls near it.
A script runs the same way. Its on_start publishes the signals the markup
binds to, a handler bound with on("click", ...) runs when that element is
clicked, and a derive() recomputes when one of its dependencies changes.
The one thing to know is that the browser runs your script as bytecode, with
no compiler behind it, so a function the runtime calls by name has to declare
its parameters and their types:
That covers handlers, derive() bodies and lifecycle functions. Annotate a
parameter with the type it arrives as rather than any where the body does
arithmetic on it or joins it to a string; a value typed any cannot do
either, and str(n) is how a number joins a string in any case.
lumenc web names every function the app calls by name that the compiled
program does not export, so a handler that would have done nothing is a
warning at build time rather than a blank in the page.
The runtime is published with every Lumen release. A build uses the copy next
to lumenc when there is one, and otherwise downloads the pair from a
published release, checks it against the checksums published with it, and
keeps it in a cache so later builds do not fetch it again. Which release that
is comes from the releases page:
the CLI reference
has the detail. --lib-dir points at a directory holding lumen-web.wasm and
lumen-web.js to use a copy you built yourself instead. A build that finds
neither says which files it wanted and emits the site without them: the pages
read, the links work, and nothing runs.
Which page a document shows is decided at build time. State comes from
[web.seed] and from the defaults the markup declares, and [web] prerender
says so: set it to "run" to have the app itself supply the state (see
Running the app during the build), or to
"none" to render the markup alone, with no branch taken and no rows.
That state is what the pages are written with. An <if> shows the branch it
resolves to, a <for> holds its rows, and an element bound with bind-text,
bind-checked, bind-value or bind-disabled is written showing the value
its signal holds. Where the state has no value for a signal, the element keeps
what the markup gave it, which is what an author writes a fallback for:
With name seeded, that label reads the name in the document itself, so a
crawler and a reader with no scripting get it. Without, it reads
(signing in) until the app writes the signal.
A list is state like any other, so [web.seed] can name its rows. Each row is
a table of the fields the row template reads:
A <for each="todos"> is then emitted with those two rows in it, and the app
starts from the same list.
Reaching a server¶
fetch(url, tag) and http(...) work in a page and mean what they mean on the
desktop: the request goes out without holding up a frame, and on_fetch,
on_fetch_error or on_http runs on the tick its reply arrives on. That is how
a page shows data the build could not know. What a site is built with goes into
the document, and what changes between visits is asked for once the app is
running.
The browser decides whether the request is allowed. Asking your own origin needs
nothing; reading a response from another origin needs that server to send
Access-Control-Allow-Origin for the origin your page is served from. That is
the server's setting, not something a build can turn on. A refusal, a request an
extension blocked and an unreachable host all arrive as the same failure, so the
message a script gets says to open the console, where the browser writes which
one it was.
Two things differ from the same call on a desktop. A header the browser reserves
for itself, such as Host or Content-Length, is dropped on the way out and
nothing reports it. And credentials follow the browser's rule rather than the
app's: cookies ride along to your own origin and not to another one.
Where a page comes from¶
[web] render says where a document comes from. Every value writes the whole
markup tree, so what a reader and a crawler get does not change; what changes
is what produces the page and what happens once it is open.
csr, the default, writes the runtime, the compiled app and the manifest
beside the pages, and the pages load them. The runtime adopts the markup the
page arrived with and runs the app from there.
static writes the pages, the stylesheet and the assets, and nothing else. No
runtime, no compiled app, no manifest, and no boot script in the documents.
ssr produces each document for the request that asks for it, by running the
app for that request. The build writes what a render needs and leaves the
pages to it, so the app answers with what it knows now rather than with what
it knew when the site was built. A rendered page carries the runtime the way a
csr page does, and is adopted the same way. See
rendering on a server.
Whichever it is, a link is an ordinary <a href>, so a browser that does not
run the runtime, or a site that carries none, follows links by loading the
next document. That needs no configuration.
Whether the page carries the runtime¶
[web] runtime, or --runtime / --no-runtime, is the separate question of
whether a document loads the browser runtime at all. static and csr differ
about nothing else, so each already answers it and saying the opposite
alongside either is refused, naming the value that means it.
ssr is the one that leaves it open:
Every page is then produced for the request that asks and carries no wasm and no boot script. The visitor reads the document the app rendered for them and nothing takes it over: links load the next page, and the next page is another render. Reach for it when the page is a document rather than an application, and you want it to depend on who is asking without shipping a runtime to say so. The compiled app is still written beside the stylesheet, because that is what the server renders from.
Running the app during the build¶
An app usually knows things the markup does not. A list arrives from a script, a total is derived from a handful of signals, a panel opens because a value came out true. Written from the seeds alone, the page holds the shape of all that and none of the answers.
[web] prerender = "run", or --prerender run, fills them in. The build starts
the app, lets it settle, and writes each page with the state it settled into:
Reach for it when a page's content comes from the app rather than from the
markup: a list a script publishes, a branch a script decides, a value a
derive() computes. It costs a run of the app per page at build time and
nothing at all afterwards, and what it buys is a document that already holds
the list, the branch and the row values the app decided, and a runtime that
starts from them instead of working them out again.
Each page is run on its own, starting from the values [web.seed] and the
markup declare, so on_start sees the route it is being built for and can
publish something different per page. What the app writes wins over what was
declared, exactly as it does in a browser.
Signals are not the whole of what a run finds. set_class, set_root_class
and the node API's set_attr, set_style and set_text write onto one
element rather than onto a signal, and the page carries what they wrote: the
document is emitted with the class, attribute, style or text the app arrived
at, and the runtime starts from the same values instead of writing the
markup's own back over them.
The build stops when the app's state stops changing, not when it stops drawing,
so an app with a spinner or a looping animation settles like any other. An app
whose state never stops changing runs out of budget instead; the build says so
and writes the state the app had reached by then, and --strict turns that
into a failure.
Two things keep a page the same wherever it is built. The build answers the
app's HTTP calls itself, with a refusal, so nothing is fetched and no page
depends on what a server said the day it was built; every address the app asked
for is reported. The entry page is also built twice and compared, and --strict
compares every page, so an app whose state depends on the clock or on the
machine is caught rather than shipped.
That leaves the network to the browser, which is where dynamic data belongs:
the page arrives complete with everything the app knew on its own, and a
fetch() fills in the part only a server can answer.
A page that depends on who is asking, or on data that changes faster than you
rebuild, wants a render per request instead. That is render = "ssr" and
rendering on a server, and it runs the same app from the
same files. The two do not combine: a rendered page settles its own state for
the request that asked, so prerender = "run" alongside it is refused rather
than run and thrown away.
Links and deep paths¶
A link to a page becomes a link to that page's document: <a href="../settings">
is /settings.html. A link that goes deeper than a page, like
<a href="../user/42"> where the app has user.lmn, keeps the path the author
wrote, because that is the URL a visitor should see and share. The app reads
the leftover /42 from route.segment, exactly as it does on the desktop.
A soft-navigated link ends at the same URL, because the address the app puts
in the bar is the one the anchor names. Nothing about sharing or reloading a
link changes with [web] navigation.
A static file server has no file at /user/42, so it serves 404.html, which
carries the app and resolves the path in the browser. That works on any file
server without configuration. If your host can rewrite instead, name it and
the matching file is written for you:
Then the host serves those paths with a 200 and the URL stays as the visitor
typed it. lumenc web --serve answers deep paths the way a plain file server
does, so what you see locally is what an unconfigured host does. Under
--render ssr the render answers them instead, with the page the path names
and a 200, which is what a server does; no 404.html and no rewrite file is
written, because neither has anything to stand in for.
Reading the address and opening the page it names is the runtime's work, so a
site that carries no runtime cannot do it: under render = "static", or with
runtime = false, /user/42 stays the shell it was served. A site that wants
deep paths to work wants csr or ssr.
An address that names no page at all, like /nowhere, is a 404 either way. A
static host sends 404.html, and a render sends the same shell with the same
status, so a site answers such an address the same way whichever half answers
it. Having the app render its own not-found page is
the embedder's to arrange.
A link with a scheme, a protocol-relative link and a fragment are written into the document unchanged.
Serving from a subdirectory¶
Every reference a document makes is rooted at the site's base path, so a site served from somewhere other than the domain root needs to be told:
or, in lumen.toml:
robots.txt is written at the top of the output directory whichever base
path the site has, because a crawler reads it only at the domain root. A site
served from a subdirectory has to put it there itself; the sitemap URL inside
it is absolute either way.
More than one language¶
Name the locales and the site is emitted once per locale:
The first locale is served from the site root and each of the others from a
directory named after its tag, so the German settings page is
/de-DE/settings.html. The strings an element marked translatable shows -
its text, its placeholder, its alt, and the body of a <tooltip> around
it - are resolved while the site is built, so a page arrives already in its
language, and text carrying a
format is written for that locale at the same point, so one authored amount
reads as 1.234,50 with a trailing euro sign in the German tree and
1,234.50 with a leading one in the English tree; <html lang> and the
writing direction follow the locale, and, once the site has an address, every
document links to its counterparts with hreflang. What the whole site
shares - the stylesheet, the compiled app, the runtime, the assets - is
written once at the root.
Each locale's catalogue travels with the site, and a document loads the one
for its own tree. What the page builds after it opens reads in the language
the document arrived in: the rows of a list a script fills, and whatever that
script's t() returns. Translation is the whole workflow.
Under render = "ssr" no documents are written for any locale. A render
answers every one of them: the request's own locale, then a /de-DE/ prefix on
the path, then Accept-Language, then the locale at the site root. The
server rendering guide has the whole order.
What a crawler sees¶
Give the site its address and the documents carry a canonical link, Open Graph and Twitter metadata, and a sitemap:
A page can say more about itself:
index = false keeps the page out of a search index: its document carries
<meta name="robots" content="noindex"> and the sitemap does not list it.
Everything else it says about itself stays, so a link to it pasted into a
chat still previews. robots.txt is not how a page is kept out of an index
and never lists one: a crawler told not to fetch a page never reads the tag
that would remove it, so a page already indexed stays there. The file names
the sitemap and allows everything; robots = false stops it being written
when you want to ship your own.
sitemap.xml lists every page of every locale that asks to be indexed, one
entry each. An entry carries the page's own URL, when the sources behind it
last changed, and a link to the same page in every other language the site is
emitted in, which is what tells a crawler that a translated page is that page
and not an unrelated one. The dates come off the files the page is built from
rather than off the clock the build ran on, so a page that did not change
keeps its date and a rebuild of an untouched app writes the same file.
The URLs are built from the address the pages themselves call canonical:
canonical when the site sets it, url otherwise. Without either, the
canonical link, the hreflang links and the sitemap are all left out, since
none of them means anything without an absolute address.
Known limits¶
- An author's
!importantrule wins over a style written on the element, where on the desktop the element wins. Normal declarations rank the way Lumen ranks them; this is the one case where the two differ. - A value interpolated with
{name}is read once, when the element is built. The document carries the value the page's state held then, and the runtime arrives at the same string from the same state; a name the state has nothing for keeps its braces. A value that changes while the page is open wantsbind-text, which follows the signal instead of being built once. bind-scrollreaches the document in no form. How far a container has been scrolled is a position the browser keeps, not an attribute a document sets.- Elements a script creates appear when the runtime starts, not in the document, so a crawler does not see them. Components are not among them: the build runs a component that has to run and writes its body into the HTML, so the document carries the whole markup tree. See Composition.
- A component that has to run must annotate its parameters, or the compiled
program has no name to call it by, and it is emitted as an empty element. The
build warns, naming the component and the annotated signature to write in its
place;
--strictmakes that warning a failed build. - A component written inside a
<for>is called once per row while the site is built, and each body is written into the row it belongs to, wherever the rows are known: a list[web.seed]declares, or one aprerender = "run"capture read off the app.prerender = "none"emits no rows, so those carry no bodies either. The build names a row it could not read a body for. - A
<for virtualized="true">is written with the rows a first screen shows, not the whole list. On the web the attribute changes nothing else: the runtime mounts every row when the page opens and the browser scrolls them, the way it scrolls any other list. A site built withrender = "static"carries every row, since nothing runs to add the rest. - A list whose rows only exist once a script has run is emitted empty under
prerender = "seeds".[web.seed]puts rows in the document without anything running, andprerender = "run"gets them from the app itself. - A run captures signals, lists, and what the app wrote onto a node: its class list, its attributes, its inline style and its text. It does not capture elements a script created, a property written on one node rather than on a signal, or a vector or a live Rust value, none of which a document can carry; the build names any it found. Anything the app would have learned from the network is missing too, and so is a value that only appears after an animation longer than the run's budget.
- A script written in Rhai or Lua does not run in the browser. candela does. An app written in one of them is still emitted and still reads: the pages show the state they were built with, and nothing runs.
- A script that creates an element during a build run renumbers the nodes after it, and then what the run wrote onto any node is left out of the document rather than written onto the wrong one; the build warns when it finds that.
- A translatable element keeps the text the catalogue gave it, whatever the run wrote onto it. The app runs once and every locale is emitted from that one run, so its text is the default locale's.
- Keys reach the app, with the ones a native control already acts on held back so nothing happens twice. Inside a text field the browser's own editing keys stay with the field: the characters, Space, Backspace, Delete, the arrows, Home and End, and the copy, cut, paste and undo chords. Everything else reaches a handler from inside a field too, Enter, Escape, the function keys and every other chord among them. Tab moves focus and is never delivered as a key.
- An app cannot cancel the browser's own behaviour for a key. Space still scrolls the page, and find-as-you-type still opens. Whether an app has a handler for a key is known a tick after the browser needs the answer.
- Escape on an open
<dialog>closes it, because the dialog is the browser's own. An app that would have closed something inside the dialog first, a<dropdown>panel say, loses that step: one press closes the dialog itself. [web] navigation = "soft"(the default) swaps a same-page link's target page in without a reload, keeping the app running;navigation = "hard"lets every link load the next document, the same as an ordinary site. Under soft navigation the address becomes the target page's own URL, so reloading or copying the link lands on the page being shown, and the browser's back and forward buttons step the site. What stays behind is the head: the<title>and the meta tags remain the ones the document that was loaded was emitted with. Under hard navigation a link loads the next document, but a navigation that does not come from one (a script callingpage()) still swaps in place, and the address stays on the document that was loaded.- A
<input>is edited by the browser, so Lumen's own caret, selection and IME handling are not in play; what an app sees is the value after each edit. - A
<checkbox indeterminate="true">shows its dash once the runtime is running. The mark belongs to the browser's own control, which takes it from a property rather than from anything the markup can carry, so a page built withrender = "static", or one whose runtime never arrives, shows the box unchecked. - A
<radio>group is not in the tab order. Every member carries thetab-index="-1"the desktop promotes one of at runtime, and nothing promotes it here, so the group is reachable with a pointer only. :drag-overon adrop-targetlights up while a file is dragged in from the desktop, and clears on a drop, matching the desktop.accept="..."filters it the same way on both: a drag the target does not take leaves it dark, and the marker goes to the nearest ancestor that does take it. A file dragged in from the desktop offerstext/uri-listand nothing else, so a target writtenaccept="text/plain"stays dark for one. The browser still shows its own drop cursor over a target that stays dark.on_file_droppedand the in-appdrag-payload/on_droppair, which read what was dropped, are desktop only:draggable="true"has no effect in a browser, so an element cannot start a drag there in the first place, and a drop on the web target delivers nothing.
Reference¶
Every flag is in the CLI reference and every key in
the lumen.toml reference.