With JavaScript Off
Wisp pages, links and forms need no script, so an app can run as a Tor onion service or for any visitor with scripts blocked. wisp.js only enhances. This page shows how to keep it that way once the app has search, tables, menus and a dark mode. Nothing here loads a font, script or image from another site.
Keep what matters in server markup: {:x} blocks, SSR = false pages and islands draw nothing without scripts. <noscript> shows only when scripts are off (a message, or the server's version of a script-drawn part).
Search, Sort and Pages
---
let q = cx.query_or("q", String::new());
let mut rows = POSTS.filter(|p| p.title.contains(&q));
if cx.query("sort").as_deref() == Some("title") {
rows.sort_by(|a, b| a.title.cmp(&b.title));
}
let posts = Page::of(cx, 10, rows);
---
<form><input type="search" name="q" value=${q} aria-label="Search"><button>Search</button></form>
<th aria-sort=${cx.sort_dir("title")}><a href=${cx.sort_href("title")}>Title</a></th>
{#each posts as post}<p>${post.title}</p>{/each}
<p>Page ${posts.number} of ${posts.pages()}, ${posts.total} posts</p>
{@pager posts}
- A GET
<form>sends?q=. A link needs nothing.cx.sort_href("title")is?sort=title&dir=asc, ordir=descwhen the page is already sorted by it. It keeps the rest of the query and dropspage.cx.sort_dir("title")is"ascending","descending"or"none", foraria-sort. POSTS.page_where(cx, 10, |p| p.title.contains(&q))isTable::pageof the matches only, newest first, copying only the page's rows.Page::of(cx, 10, rows)pages rows in your own order.- A
Pagehasrows,number,prev,next,totalandpages()(at least 1).{@pager posts}writes the Newer and Older links, which keep the query. - A query value is data. Wisp escapes it where it comes back. A
pagethat is not a number is the first page.
Coming Back: No Referer
Tor Browser can send no Referer, and a page opened with Referrer-Policy: no-referrer sends Origin: null. A form that must return the visitor to the page carries the address:
<input type="hidden" name="next" value=${cx.url()}>
cx.url() is this request's path and query (an action's /name left out). The route that receives it checks next.starts_with('/') and ends in redirect(next), which keeps //evil.example or /evil.example on this site. Wisp's form check lets an Origin: null post through only when the browser also says Sec-Fetch-Site: same-origin.
Light and Dark
wisp ui add theme writes a Theme component and src/routes/theme/+server.rs:
<Theme theme=${cx.theme()} next=${cx.url()} />
Put it first in the layout. It is a form of three buttons (Light, Dark, Auto) that posts to /theme. The route calls cx.set_theme(&to) (the theme cookie: 400 days, SameSite=Lax, hidden from scripts. Anything but light or dark forgets it) and redirects back to next. cx.theme() is "light", "dark" or "", and anything else in the cookie reads as "".
The server writes data-theme on the form, so the page shows the chosen theme from its first byte, with no flash. :root:has([data-theme="dark"]) { color-scheme: dark } (the component brings those rules) switches the scheme. Colour with light-dark(#fff, #111) or color-scheme: light dark, and prefers-color-scheme stays the default. Tor Browser reports light, so the buttons matter there. Leave it off a CACHE_PUBLIC page, where everyone shares one cached answer.
Open and Close in CSS
<details><summary>: a dropdown or accordion.<details name="faq">keeps one open. This isAccordion.- A popover:
<button popovertarget="m">Menu</button>and<div id="m" popover>…</div>. Escape and a click outside close it, and focus returns. This isMenuandDialog. A browser too old for popovers shows the content in the page. - Tabs: radio buttons and
input:nth-of-type(2):checked ~ .panels > :nth-child(2) { display: block }. The arrow keys move between them as between radio buttons. This isTabs(up to 12). <a href="#m">and#m:target { display: block }open a panel in any browser.wisp ui add accordion dialog menu tabs toast tooltipwrites them. All open and switch with scripts off, and so do a menu's arrow keys, a tooltip's Escape and a toast's close button.Toastdraws itsmessagefrom the server inside<noscript>and fades it out by CSS after six seconds.
Pages That Refresh Themselves
<head><meta http-equiv="refresh" content="5"></head>
reloads the page every 5 seconds, which suits a status or queue page. The tag is a literal (Wisp refuses a hole in <meta http-equiv>). Do not use it on a page with a form that a visitor fills in.
What Does Not Work
- A button that only a script can work (a copy button, a like): hide it with
@media (scripting: none) { .copy { display: none } }, as this site does. {#await}hides its pending markup (<noscript><style>wisp-await{display:none}</style></noscript>, not on edge builds). The answer comes at the end of the page, since only a script moves it into place. Use.awaitin the block for such a site. On aCACHEorPRERENDERpage, an{#await}stays with the page or is a hole. Either way its answer is in place with no script (Holes).- Wisp's own pages, including the error pages and
wisp dev's error page, need no script./_wisp/docsis drawn by a script and says so (its token field is hidden), with a link to/_wisp/openapi.json. - For a policy with no third parties use
wisp::csp("img-src 'self'"), and keep fonts instatic/fonts(src/fonts.txt), not a CDN.
What Works With No Script
- Pages, layouts,
{#each}and{#if}, Markdown pages and error pages, which are server HTML. - Forms.
<form action="?/add">is a realPOSTthat answers with a redirect or the page again. A bad value is a 422 with each problem next to its input and what was typed kept (Actions, forms and UI). A<button action="?/remove&id=3">outside a form becomes its own form. - Links, including
?page=2for pagination and<form>withmethod="get"for search and filters. The query string is state the server reads (cx.query_or("page", 1)), and the address is shareable. - Redirects after a post, and a message that survives them in a cookie (
cx.set_cookie,cx.flash) or the session. A page that shows a flash getscache-control: private, no-store, so no shared cache hands one visitor's message to another. <details>,<dialog>,popover,<select>and checkboxes, which the browser handles.- Cookies, signed cookies and sessions, which are headers.
- Images, styles and fonts from your own origin, scoped CSS and Tailwind built at
wisp build.
What Needs a Script
These need a script:
on:click,{:state}andletin the---block that the page changes in the browser<script>and its runes- islands (
client:visibleand the others) - a page with
const SSR: bool = false; {#await}answers moving into place- live tables and WebSockets
- the client router
With JavaScript off, a page that uses them still shows the server's HTML. A button only a script handles does nothing. Give such a control a form to fall back on. A <button> inside a <form action="?/like"> works either way, and use:enhance updates it in place when a script is there.
<form action="?/like&id=${post.id}" use:enhance>
<button>Like (${post.likes})</button>
</form>
What a Privacy Browser Takes Away
Tor Browser at Safest, and most privacy settings, also remove things a form check or layout might rely on:
Refereris stripped or trimmed. Do not read it. Build a "back" link as an address (?return=/posts, checked to start with/).Originmay benullon a form post, when the page saysReferrer-Policy: no-referrer. Wisp accepts that only withSec-Fetch-Site: same-origin, which a browser sets itself, and answers 403 to anullwith any otherSec-Fetch-Siteor none. Another site's POST has a foreignOriginand is refused as before. There is no token to add to your forms.- Third parties are blocked or leak. The default Content Security Policy allows only
'self'for scripts, connections, fonts, frames and forms. Images may come fromhttps:unlessWISP_ONION=oncloses that too. A font pulled from a CDN fails to load for these visitors, so self-host it:src/fonts.txtserves fonts from your own origin. - Third-party widgets (analytics, script-based captchas, embeds, social buttons) do not run, and some phone home when they fail. Leave them out. A human check without a script has to be a question your server asks and a form answers.
Check It
- Test as a script-less client does.
wisp::test::clientsends plain requests and reads plain HTML, so a flow that passes there needs no script.app.post_form("/add", &[("text", "milk")]), thenapp.get("/"). - Disable JavaScript in your browser, or use Tor Browser at Safest, and walk every form once.
- Look for requests to another origin in the browser's network panel. A clean app shows none, and the CSP header refuses them anyway.
curl -si http://localhost:3000/shows the headers a page has:content-security-policy,referrer-policy,x-content-type-options, and nothing that names the server.
See Also
Tor onion services for the headers, cookies and addresses an onion site gets, Actions, forms and UI, Browser code, Serve extras.
Is This Page Useful?