Pages
howdy/ui/page turns a list of elements into a whole HTML document and
sends it as the response. It writes the parts every page needs, such as the
doctype, the character set and the viewport tag, and it makes sure the
theme and the styles for your components are in place before the browser
paints anything.
A page
import howdy/controller.{type Context}
import howdy/ui
import howdy/ui/page
import lustre/element.{text}
fn orders(ctx: Context) {
page.new("Orders")
|> page.body([
ui.container([], [
ui.h1("Orders"),
ui.p([text("Nothing here yet.")]),
]),
])
|> page.respond(ctx)
}
page.new takes the title and starts a page with sensible defaults:
English as the language, and the built-in light and dark themes. The
functions after it each adjust one thing, and page.respond finishes the
page and answers the request with text/html.
The elements in the body are Lustre elements.
The components in howdy/ui are one source of them, and anything from
lustre/element/html works beside them. Lustre escapes text and attribute
values for you, so it is safe to put user input into a page.
Building the page
-
page.body(elements)adds elements to the<body>. Call it more than once and the elements are added in order, which is handy when a shared function adds the header and the handler adds the rest. -
page.head(elements)adds elements to the<head>, after the title and the styles. Use it for a description, icons, fonts or your own scripts. -
page.lang(code)sets thelangattribute, which screen readers and search engines use. The default is"en". -
page.theme(name)selects a theme for this response. -
page.themes(themes)replaces the themes the page offers. -
page.stylesheet(at: path)links a CSS file, where the page would otherwise embed its styles. -
page.liveadds the scripts that live components and live links need.
import lustre/attribute
import lustre/element/html
page.new("Orders")
|> page.lang("en-GB")
|> page.head([
html.meta([
attribute.name("description"),
attribute.content("Every order, newest first."),
]),
html.link([attribute.rel("icon"), attribute.href("/favicon.svg")]),
])
Choosing the theme
A page offers a set of themes. With nothing else said, the browser picks between them using the visitor's system setting for light or dark.
page.theme overrides that for one response, by putting a data-theme
attribute on the <html> element. The usual source is a cookie, so that a
visitor's choice is remembered:
import howdy/cookie
fn orders(ctx: Context) {
use theme <- cookie.string_or(ctx, "theme", default: "system")
page.new("Orders")
|> page.theme(theme)
|> page.body([ui.h1("Orders")])
|> page.respond(ctx)
}
A name the page does not offer is ignored, and the attribute is left off.
That is what makes "system" a good default: it is not a theme, so a
visitor who has never chosen falls through to their system setting. It also
means a cookie someone has tampered with cannot put a strange value into
your page.
Because the theme is part of the HTML the server sends, the first paint is already in the right colours. Pages that choose the theme with JavaScript after loading flash the wrong one first; these do not.
ui.theme_toggle is the button that sets the cookie. See
Themes.
Where the styles come from
Every component's CSS is written in Gleam, and a page needs that CSS to look
right. By default the page embeds it: the <head> gets a <style> element
with the theme's variables, a few base styles, and the rules for the
components used so far.
Embedding needs no extra route or file, which makes it the right choice while you are building. Its cost is that the CSS travels with every page and cannot be cached on its own. For a published site, write it to a file once and link that from every page:
page.new("Orders")
|> page.stylesheet(at: "/assets/ui.css")
Publishing covers both ways of serving that file.
A shared layout
Most sites want the same header, footer and head elements on every page. There is nothing special to learn for that. Write a function that takes the part that varies and returns a page:
import howdy/ui/page.{type Page}
import lustre/element.{type Element}
pub fn layout(title: String, theme: String, content: List(Element(msg))) -> Page(msg) {
page.new(title <> " ยท My shop")
|> page.theme(theme)
|> page.stylesheet(at: "/assets/ui.css")
|> page.body([
site_header(),
ui.container([], content),
site_footer(),
])
}
fn orders(ctx: Context) {
use theme <- cookie.string_or(ctx, "theme", default: "system")
layout("Orders", theme, [ui.h1("Orders")])
|> page.respond(ctx)
}
A handler can still pipe the result through page.head or page.live to
add what only that page needs.
Without a response
page.respond is the usual ending. Two others give you the document
without sending it:
-
page.to_stringreturns the HTML as a string, doctype included. Use it to set a different status, as for a404page, or to write pages to disk. -
page.renderreturns the document as a Lustre element.
fn not_found(ctx: Context) {
let html =
page.new("Not found")
|> page.body([ui.h1("There is nothing here")])
|> page.to_string
controller.html(ctx, html)
|> controller.with_status(404)
}
Testing pages
A page is an ordinary response, so send a request and look at the HTML:
import gleam/string
import howdy/testing
pub fn orders_page_test() {
let res = testing.get("/orders") |> testing.send(app())
assert res.status == 200
assert string.contains(testing.text(res), "<title>Orders</title>")
}
pub fn theme_cookie_is_applied_test() {
let res =
testing.get("/orders")
|> testing.cookie("theme", "dark")
|> testing.send(app())
assert string.contains(testing.text(res), "<html data-theme=\"dark\"")
}
Assert on the text a visitor would see, not on class names. Class names are made from the content of each style, so they change whenever a style does.
