A tour of Pleris, part 3: pages
Part 1 covered the language and part 2 data on the server. This part puts them on pages: routes, markup, state in the browser, forms, layouts and navigation.
Markup is HTML with blocks, as in Svelte. A page is rendered on the server as plain HTML, readable with JavaScript off, and then resumed in the browser: the browser attaches the page's handlers without running the page again (charter §8.5).
Pages and routes
module shop.pages
opaque type StoreId = String
page Home() {
route "/"
view {
<title>Stores</title>
<main>
<h1>Stores</h1>
<a href="/stores/blue-bottle">Blue Bottle</a>
</main>
}
}
page StorePage(id: StoreId) {
route "/stores/{id}"
view {
<title>Store</title>
<main>
<h1>Store {id}</h1>
<a href="/">All stores</a>
</main>
}
}- A page's parameters come from its route:
{id}namesid. A route names each parameter once (PW0340), and a path parameter is text: aString, or an opaque type over one (PW0621). - Every link is matched against the program's routes (PW5009).
- A page with a route has a
<title>, first in its view (PW5029).
Query parameters are declared in the route, after ?, and are optional
by type (ADR-0309):
module shop.search
page Search(q: Option<String>) {
route "/search{?q}"
cache private
view {
<title>Search</title>
<main>
<form method="get" action="/search">
<label>Find <input type="search" name="q" /></label>
<button>Search</button>
</form>
{#match q}
{:Some(text)}
<p>You searched for {text}.</p>
{:None}
<p>Nothing searched yet.</p>
{/match}
</main>
}
}A link or a GET form may give only the parameters the route declares
(PW5050).
Markup blocks
module shop.menu
type Item = Item { id: String, name: String, price: Int, sold_out: Bool }
type Panel =
| Shut
| Menu
| Help
view Row(item: Item) {
<li>
<span>{item.name}</span>
{#if item.sold_out}
<span class="sold-out">Sold out</span>
{:else}
<span>{item.price} cents</span>
{/if}
</li>
}
page MenuPage() {
route "/menu"
cache private
signal panel: Panel = Panel.Menu
signal items: List<Item> = [Item { id: "1", name: "Espresso", price: 350, sold_out: false }]
view {
<title>Menu</title>
<main>
{#match panel}
{:Shut}
<p>Closed.</p>
{:Menu}
<ul>
{#each items as item (item.id)}
<Row item={item} />
{/each}
</ul>
{:Help}
<p>Ask at the counter.</p>
{/match}
</main>
}
}{#if c} .. {:else} .. {/if}:cis aBool, or a list or a string tested for being non-empty. A number is not a condition (PW0609): writecount > 0.{#each items as item (item.id)}: every list is keyed by a field of its rows (PW5011), so a row keeps its place as rows come and go. There is no index variable (PW0016). The list is read by path (a signal, a query's result, a field): a computed list is a gap.{#match v} {:Case} .. {/match}: one arm a case, every case handled.view Row(item: Item): a view takes props and renders markup. It is used as an element,<Row item={item} />, and composed in place.
State in the browser: signals and handlers
module shop.counter
page Counter() {
route "/counter"
cache private
signal count: Int = 0
view {
<title>Counter</title>
<main>
<p>Pressed {count} times.</p>
<button type="button" on:press={() => count = count + 1}>Press</button>
<button type="button" on:press={() => count = 0}>Reset</button>
</main>
}
}signal count: Int = 0is the page's own state. Only a handler changes it (PW5300), and the parts of the page that read it are rendered again.on:pressis a click, a tap or Enter on the button. The events arepress,submit,input,keydown,changeandclose;on:clickdoes not exist:
module shop.click
page Clicker() {
route "/clicker"
cache private
signal count: Int = 0
view {
<title>Clicker</title>
<button type="button" on:click={() => count = count + 1}>Press</button>
}
}A handler that calls a command matches on its answer, as the
tutorial shows: match post(text) { Ok(_) => draft = "", Err(_) => () }. The answer says whether the command committed; the new
data comes through the page's queries.
A signal can be read in a part of the page, but not in a <title>: pw check and pw build both refuse it
(where pw check stops). A part may compute a value
from two signals, as in {a + b}, which the section below shows.
Forms and bind:value
module shop.note
import String
page Note() {
route "/note"
cache private
signal note: String = ""
view {
<title>Note</title>
<main>
<label>Your note <textarea bind:value={note}></textarea></label>
<p>{280 - String.length(note)} characters left</p>
<button type="button" disabled={String.length(note) == 0} on:press={() => note = ""}>Clear</button>
</main>
}
}bind:value={note}keeps aStringsignal and an<input>or a<textarea>equal. It binds strings only: there is no checkbox binding yet, and a<select>marks itsselectedoption instead (PW5036).- Every control has a label (PW5014).
- A form that changes data submits through a handler,
<form on:submit|prevent={() => ..}>. A plain<form method action>must go where something answers that method (PW5041): a page answersGET.
Reading data on a page
A page reads queries with let. A query that answers a Result gives its
Ok value, and not_found_on names the case that means the address names
nothing, answered 404 (ADR-0163); any other error is a 503:
module feed.thread
opaque type PostId = String
type Post = Post { id: PostId, text: String, likes: Int }
type FeedError =
| Unavailable
| NotFound
fn thread(id: PostId) -> Result<Post, FeedError> !{ database.read<Post> }
host "feed:data/posts#thread"
public query Thread(id: PostId) -> Result<Post, FeedError>
freshness 5.seconds
consistency snapshot
cache shared
key id
{
thread(id)
}
page PostPage(id: PostId) {
route "/post/{id}"
not_found_on FeedError.NotFound
placement origin
cache private
let post = query Thread(id)
view {
<title>Post</title>
<main>
<p>{post.text}</p>
<p>{post.likes} likes</p>
</main>
}
}session page and user page say whose data a page shows, as a query
does (part 2). A page that asks a predicate of its reader
({#if SignedIn}) is not the same for everyone, so it cannot be public
(PW5049).
Layouts and navigation
A layout is the markup pages share, with a <slot /> where each page goes
(ADR-0303):
module site.layout
layout Shell {
view {
<header>
<nav aria-label="Site">
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</header>
<slot />
}
}
page Home() {
route "/"
layout Shell
view {
<title>Home</title>
<main>
<h1>Home</h1>
</main>
}
}
page About() {
route "/about"
layout Shell
view {
<title>About</title>
<main>
<h1>About</h1>
</main>
}
}- A link between two pages of one layout keeps the layout in place: the browser fetches the next page, swaps the slot, moves the focus to its heading, and keeps the history (ADR-0306). Any other link is a normal page load.
- A handler goes to a page after its command commits:
Ok(_) => navigate OrderPage()(ADR-0280).navigateis only allowed there (PW5043). - A layout has one slot (PW5044) and no parameters (PW0353). Layouts do not nest yet.
Where pw check stops
pw check runs the compiler's checks. pw build goes on to the page's
templates, and refuses some programs pw check passes. So a clean check is
not yet a program that runs: build it, as the tutorial does.
pw check and pw build both report PW6601 for programs the executable backends cannot support.
The examples below distinguish semantic refusals from executable support limits. Check and build share complete executable-program validation; each example states the exact diagnostic that refuses it.
A <title> cannot read a signal, because the browser does not render the
title again (ADR-0137):
module shop.titled
page Counter() {
route "/counter"
cache private
signal count: Int = 0
view {
<title>Pressed {count}</title>
<button type="button" on:press={() => count = count + 1}>Press</button>
}
}A part of a page may compute a value from two signals, or from any of the
values the browser holds: the browser computes it again when either changes,
and the host computes its first from their first values (track
device-store, ruling 0073-a's later steps):
module shop.sum
page Sum() {
route "/sum"
cache private
signal a: Int = 1
signal b: Int = 2
view {
<title>Sum</title>
<main>
<p>{a + b}</p>
<button type="button" on:press={() => a = a + 1}>Add to a</button>
<button type="button" on:press={() => b = b + 1}>Add to b</button>
</main>
}
}Effects in an {#each} head are checked just as they are elsewhere in
the view (ADR-0345). This view claims !{} and calls a database read in its
list, so pw check refuses it with PW0401:
module shop.head
type Item = Item { name: String }
fn items() -> List<Item> !{ database.read<Item> }
host "shop:data/items#all"
view Menu() !{} {
<ul>
{#each items() as item (item.name)}
<li>{item.name}</li>
{/each}
</ul>
}A pure call in the list passes semantic analysis, but the renderer reads
the list by path. Both pw check and pw build refuse it with PW6601:
module shop.computed_head
type Item = Item { name: String }
fn items() -> List<Item> !{} {
[Item { name: "Tea" }]
}
view Menu() !{} {
<ul>
{#each items() as item (item.name)}
<li>{item.name}</li>
{/each}
</ul>
}And an <img> with no alt is not refused, though an <input> with no
label is (PW5014):
pw check passes this todaymodule shop.logo
page Home() {
route "/"
view {
<title>Shop</title>
<main>
<img src="/logo.png" width="64" height="64">
<h1>Shop</h1>
</main>
}
}This page is docs/guide/tour-pages.md, and a test compiles every example on it.