Web

Views and assets

Pages are HTML files in app/resources/views/. Vite builds them, with Tailwind and your scripts, into public/build/, and the Rust server serves the result.

Views#

view("welcome") serves public/build/views/welcome.html. Router::view("/", "welcome") is the same thing as a route. When the front end is not built yet, the answer is a 503 page that tells you to run clamp dev.

Templates#

render passes data to a view, which reads it with a small part of Laravel's Blade. {{ }} escapes, so user text is safe by default; {!! !!} prints trusted HTML as-is:

render("post", &[("post", &post), ("comments", &comments)])
@extends('layouts.app')

@section('title', post.title)

@section('content')
  <h1>{{ post.title }}</h1>
  <div>{!! post.body !!}</div>
  @if(comments)
    <ol>
      @foreach(comments as comment)
        <li>{{ comment.name }}: {{ comment.body }}</li>
      @endforeach
    </ol>
  @else
    <p>No comments yet.</p>
  @endif
  @include('partials.notices')
@endsection
{{ post.title }}Prints escaped. Dots read keys of a map.
{!! post.body !!}Prints as-is, for HTML you made yourself.
@if(x) @if(!x) @else @endifTrue for true, non-empty text and non-empty lists and maps.
@foreach(posts as post) @endforeachRepeats for each item of a list.
@extends('layouts.app')Renders the layout with this view's sections.
@section('content') @endsection, @section('title', post.title)Fills the layout's @yield('content').
@include('partials.nav')Renders another view with the same data.
@include('components.post-card', post: featured, compact: true)Also passes named values, for that view only: a name from your data, 'text', true or false. Keep reusable pieces in components/ and layout fragments in partials/.
{{-- note --}}, @@, @{{A comment, a literal @, a literal {{.

There are no expressions: work out counts, dates and labels in the controller. A name the view uses but was not given is an error. So is an unclosed @if or a missing layout. The error names the view and line, goes to the log, and the visitor gets a 500. A missing key of a map is empty text. @ directly after a letter is left alone, so [email protected] and CSS @media pass through.

Data is anything that implements ToValue: text, numbers, bool, Vec, Option (None is false). Give a model the fields its views read:

impl ToValue for Post {
    fn to_value(&self) -> Value {
        Value::map(&[("slug", &self.slug), ("title", &self.title), ("body", &self.html())])
    }
}

Safe output#

  • {{ }} escapes HTML, which is enough in text and quoted attributes. In an href or src it does not stop a javascript: link, so validate links people submit with the url rule (http and https only) before you store them.
  • {!! !!} is for HTML you produced. Markdown from anyone else goes through web::markdown::to_html (the markdown feature), which shows raw HTML as text and points non-web links (javascript:, data:) to #.
  • The Content-Security-Policy from security_headers is the safety net: even if markup slips through, inline script does not run.

Layouts and partials live in app/resources/views/ like any page, and Vite builds them too; the layout is where it injects your scripts and styles. They are only ever served rendered: /build/views/… is never sent raw.

Error pages#

error(status) looks for your own page first, then falls back to a built-in one:

  1. errors/404.html: one exact status.
  2. errors/4xx.html or errors/5xx.html: a whole class. The template ships both.
  3. The built-in page, which follows the visitor's light or dark theme.

Error views get {{ status }} and {{ reason }}, so one 4xx page covers 404, 419, 429 and the rest. They can @extends your layout. If an error view itself fails to render, the built-in page answers.

Static files#

A GET that matches no route serves the file from public/: /robots.txt, images, and the Vite build under /build/. Paths that try to leave public/ are refused with 404. Recognised types: html, js, css, json, txt, svg, png, ico and woff2; anything else is sent as application/octet-stream.

The front end#

vite.config.ts builds every page in app/resources/views/ into public/build/, with Tailwind 4 from resources/css. clamp dev runs the watcher and the server together. Without Node.js, run npm run build once on a machine that has it and ship public/build/.