日本語

Pages and links

A route is a page the server renders at a path. The router serves it and the links on other pages point to it from the same value, so a parameter of the wrong type or in the wrong number is a compile error.

Routes

A Route<P> is a path whose parameters are P. A parameter is a whole segment written {name}, as in axum, and P is () for a path without one, one Segment for one, and a tuple for two to six, in path order. A Segment is an integer, a String, or an enum whose variants have no fields with #[derive(Segment)], whose segment is the string serde writes for the variant, so #[serde(rename_all = "lowercase")] gives /ja/. A last segment written {*name} takes the rest of the path, /s included, into a Rest, as in "/files/{*path}". Declare it in routes!, on a line that names the handler serving it, so a route without a handler cannot be declared. The macro counts the segments, so a path whose count differs from P fails to compile. routes! needs the axum feature, which is on by default.

use hypersig::Route;

hypersig::routes! {
    fn pages(Router<()>);
    // error[E0080]: evaluation panicked: the route's `{name}` segments differ
    // in number from the values of its parameter type
    const MEMBER: Route<i32> = "/members" => member;
}

The first line of routes! names a function and the router state, and the function registers each route on GET with its handler. A handler receives the router state and the parameters, already parsed, and returns the Page. A path whose segments do not parse into the parameter types gets a 404, and so does a handler that returns None, as member does below for an id it does not know.

src/main.rs
use axum::Router;
use hypersig::html::{a, each, h1, li, p, ul};
use hypersig::{Page, Route, RouterExt, Signals};
use serde::{Deserialize, Serialize};

#[derive(Signals, Serialize, Deserialize, Clone)]
struct Member {
    id: i32,
    name: String,
}

#[derive(Signals, Serialize, Deserialize)]
struct Team {
    members: Vec<Member>,
}

#[derive(Signals, Serialize, Deserialize)]
struct Profile {
    name: String,
}

hypersig::routes! {
    fn pages(Router<()>);
    const TEAM: Route = "/" => team;
    const MEMBER: Route<i32> = "/members/{id}" => member;
}

fn members() -> Vec<Member> {
    vec![
        Member {
            id: 1,
            name: "Ada".to_owned(),
        },
        Member {
            id: 2,
            name: "Grace".to_owned(),
        },
    ]
}

async fn team((): (), (): ()) -> Page<Team> {
    Page::new("Team", Team { members: members() })
        .child(h1().text("Team"))
        .child(ul().child(each(Team::members(), |member| {
            li().child(
                a().href(MEMBER.link_with(member.at(Member::id())))
                    .bind_text(member.at(Member::name())),
            )
        })))
}

async fn member((): (), id: i32) -> Option<Page<Profile>> {
    let member = members().into_iter().find(|member| member.id == id)?;
    Some(
        Page::new(member.name.clone(), Profile { name: member.name })
            .child(h1().bind_text(Profile::name()))
            .child(p().child(a().href(TEAM.link()).text("Back to the team"))),
    )
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let app = pages(Router::new()).hypersig_runtime();
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
    axum::serve(listener, app).await
}

El::href takes a Link, which link() builds for a route without parameters and link_with(params) for one with them. The parameters are expressions of the route's types, as the arguments of an action call are. Literals give a fixed href. A parameter that reads a signal or a list item, as on the team page above, is evaluated in the browser and kept up to date. Both sides percent-encode a value as encodeURIComponent does, so the first HTML and the runtime write the same URL.

use hypersig::Route;
use hypersig::html::a;

#[derive(Signals, Serialize, Deserialize)]
struct Search {
    team: String,
}

hypersig::routes! {
    fn pages(Router<()>);
    const MEMBER: Route<(String, i32)> = "/teams/{team}/members/{id}" => member;
}

// Literal parameters give a fixed href, percent-encoded on the server.
let fixed = a::<Search>().href(MEMBER.link_with(("red team", 7)));
assert_eq!(fixed.render(), r#"<a href="/teams/red%20team/members/7"></a>"#);

// A parameter that reads a signal is kept up to date in the browser.
let bound = a().href(MEMBER.link_with((Search::team(), 7)));
assert!(bound.render().contains("data-hs-attr"));

Pages under a page

in PARENT after the path declares a route under the page of an earlier line. Its path is the parent's path followed by more segments, and its parameters are the parent's followed by its own, so a link to the child needs the parent's values too. A path that does not continue the parent's, or parameters that do not start with the parent's, fail to compile.

use hypersig::Route;
use hypersig::html::a;

hypersig::routes! {
    fn pages(Router<()>);
    const ORG: Route<String> = "/orgs/{org}" => org;
    // The child repeats the parent's path and parameters, then adds its own.
    const MEMBER: Route<(String, i32)> = "/orgs/{org}/members/{id}" in ORG => member;
}

let link = a::<Org>().href(MEMBER.link_with(("acme", 9)));
assert_eq!(link.render(), r#"<a href="/orgs/acme/members/9"></a>"#);

An application served under a path prefix, such as /app behind a proxy, names it once as a Base constant: fn pages(Router<State>) under APP; on the first line of routes!, and Action::post("/save").under(APP) on each action, stream, and upload. Links, URLs, redirects, action calls, and the path a page loads the runtime from then start with /app, and the router still registers each path without it. The same router then runs inside Router::nest(APP.as_str(), ...) or behind a proxy that strips /app. The prefix is a constant because the routes and actions it prefixes are, so it is chosen when the program is built. A page served by a router nested under any other path answers every request with a 500 that names both paths, and the compiler does not check that an action a page calls was declared under the same base.

Search parameters

A route's second type, Route<P, Q>, types its query string. Q is a struct with #[derive(Signals, Search)], and a link and the handler then take (P, Q), or Q alone for a path without parameters. #[derive(Search)] requires each field to be a SearchValue: a string, a number, a bool, an enum with #[derive(Segment)], or an Option or a Vec of one, so a struct or a list of lists in a field is a compile error. A Vec field writes its name once per item, as ?color=red&color=blue, and the server reads the repeated key back into the list. A field whose type checks its value in Deserialize, such as a newtype with #[serde(try_from = "i32")], validates the query string, and an Option field with unwrap_or in the handler gives a default. The query string lists the fields in the order of their names and leaves out a field that is None, on the server and in the browser alike. A query string that does not deserialize into Q gets a 400.

use hypersig::{Expr, Route, Search, Signals};

#[derive(Signals, Search, Serialize, Deserialize)]
struct Filter {
    q: String,
    page: i32,
    tag: Option<String>,
}

hypersig::routes! {
    fn pages(Router<()>);
    // `team` receives `(String, Filter)`.
    const TEAM: Route<String, Filter> = "/teams/{team}" => team;
}

let first = Filter { q: "rust".to_owned(), page: 1, tag: None };
let link = a::<Filter>().href(TEAM.link_with(("red", &first)));
assert_eq!(link.render(), r#"<a href="/teams/red?page=1&amp;q=rust"></a>"#);

// A field can come from a signal, here the page the reader is on.
let next = Expr::lit(&first).with(Filter::page(), Filter::page() + 1);
let _ = a().href(TEAM.link_with(("red", next)));

Redirects and navigation

The server writes a page's URL with url(params), which takes the route's parameter values themselves and encodes them as a link does. A page handler that sends the browser elsewhere returns a PageResponse, built with PageResponse::redirect(url) or converted from a Page. An action does the same with Patches::navigate(url), which the browser follows once the patches before it are applied.

In the browser, link.navigate() is a statement that loads the linked page, for a handler that should leave the page after it runs other statements.

use hypersig::html::button;
use hypersig::{Action, PageResponse, Patches, Route};

#[derive(Signals, Serialize, Deserialize)]
struct Draft {
    saving: bool,
}

hypersig::routes! {
    fn pages(Router<()>);
    const POST: Route<i32> = "/posts/{id}" => post;
    const OLD_POST: Route<i32> = "/p/{id}" => old_post;
}

const PUBLISH: Action<Draft> = Action::post("/publish");

// A page handler answers an old address with a redirect.
async fn old_post((): (), id: i32) -> PageResponse {
    PageResponse::redirect(POST.url(id))
}

// An action sends the browser to the page it created.
async fn publish((): (), (): ()) -> Patches<Draft> {
    let id = 7;
    Patches::from(Draft::saving().patch(false)).navigate(POST.url(id))
}

// A button leaves the page after setting a signal.
let _back = button::<Draft>().on_click(Draft::saving().set(false).then(POST.link_with(&1).navigate()));

Changing the URL without loading a page

A list whose filter, sort, and page live in the query string can keep the page loaded while they change. link.push_url() is a statement that shows the link's URL in the address bar as a new history entry, and link.replace_url() shows it in place of the current one, for a change the Back button should skip, such as each letter typed into a search field. The page keeps its signals and its focus, so the statement runs beside the call that loads the rows. An action does the same with Patches::push_url(url) and Patches::replace_url(url), and the patches after them are applied. When the visitor presses Back or Forward, the browser loads the page at that entry's URL from the server, which renders it from the query string.

use hypersig::{Action, Expr, Page, Patches, Route, Search, Signals};

// `/fruit` without a query string leaves `q` as `None`.
#[derive(Signals, Search, Serialize, Deserialize, Default)]
struct Filter {
    q: Option<String>,
}

#[derive(Signals, Serialize, Deserialize)]
struct Fruit {
    query: String,
    matches: Vec<String>,
}

hypersig::routes! {
    fn pages(Router<()>);
    const FRUIT: Route<(), Filter> = "/fruit" => fruit;
}

const SEARCH: Action<Fruit, String> = Action::post("/search");

fn find(q: &str) -> Vec<String> {
    ["apple", "fig", "kiwi"].into_iter().filter(|name| name.contains(q)).map(String::from).collect()
}

// The page renders what the query string names, so Back and Forward load it again.
async fn fruit((): (), filter: Filter) -> Page<Fruit> {
    let q = filter.q.unwrap_or_default();
    let matches = find(&q);
    let query = Fruit::query();
    let shown = Expr::lit(&Filter::default()).with(Filter::q(), query.some());
    Page::new("Fruit", Fruit { query: q, matches }).child(
        input().bind(query).on_debounced(
            Event::Input,
            Duration::from_millis(250),
            // Each pause in typing replaces the URL rather than adding an entry.
            SEARCH.call_with(query).then(FRUIT.link_with(shown).replace_url()),
        ),
    )
}

const CLEAR: Action<Fruit> = Action::post("/clear");

// An action changes the URL too, here as a new entry after emptying the search.
async fn clear((): (), (): ()) -> Patches<Fruit> {
    let empty = Fruit::query().patch(String::new()).and(Fruit::matches().patch(find("")));
    Patches::from(empty).push_url(FRUIT.url(Filter::default()))
}

Pages for missing paths

fn pages(Router<State>) else missing; on the first line of routes! names a handler for each page request that reaches no page: a path no route matches, a segment that does not parse, a handler that answers None, and a query string that does not deserialize. The handler receives an Unserved and may take an extractor, and its page is sent with the 404 or 400 that Unserved::status names. Unserved::within(HOME) reads the parameters of HOME from the start of the path, so a site under /{lang} shows the page in the language of the path. A page with another status is PageResponse::with_status(status, page).

Page::description writes the summary a search result shows, and Page::icon the icon of the tab. A canonical URL and the image of a link preview must be absolute, so Page::canonical and OpenGraph::image join a path to an Origin, whose constant fails to compile when it holds more than the scheme, the host, and the port. An application that a proxy serves under a path such as /blog adds that path with Origin::under, which rejects a malformed prefix the same way. OpenGraph takes the card's title, text, and URL from the page, so each is written once.

use hypersig::{OpenGraph, Origin, Page, Route};

#[derive(Signals, Serialize, Deserialize)]
struct Post {
    title: String,
}

hypersig::routes! {
    fn pages(Router<()>);
    const POST: Route<u32> = "/posts/{id}" => post;
}

// The scheme and host, checked when the constant is compiled.
const SITE: Origin = Origin::new("https://blog.example.com");

let page = Page::new("Hello", Post { title: "Hello".to_owned() })
    .description("The first post.")
    .canonical(SITE, &POST.url(1))
    .icon("/icon.svg")
    .open_graph(OpenGraph::new().site_name("Blog").image(SITE, "/og/1.png", "A wave"));
let html = page.render();
assert!(html.contains(r#"<meta property="og:url" content="https://blog.example.com/posts/1">"#));

Page::bind_title(expr) makes the tab's title follow a String expression, such as an unread count. The server writes its first value into <title>, and the runtime writes each later value into document.title. The card of OpenGraph keeps the title given to Page::new.

What routes leave unchecked

  • Nothing checks that the function routes! writes is called, so declare every page of the application in one routes! block and call its one function. A block whose function is never called answers 404 on every page, and rustc reports its handler functions as never used, though not the function itself or a handler written as a closure.
  • A parent after in is a page itself, so a path prefix with no page of its own cannot be one.
  • A link is a plain <a href>, so following it loads the next page from the server, and the signals of the page you leave are gone.
  • Nothing checks that the URL push_url or replace_url shows belongs to the page on screen. The URL of another page shows over this page's content, and reloading it loads the other page.
  • attr("href", ...) still writes any URL, for a link to another site or to a #fragment.