Wisp is in pre-release, self-hosted since GitHub suspended us. Install it, or see the changelog.

Wisp

Content Collections

A content collection is a folder of Markdown, JSON or YAML files that your code reads as typed data. Use it for posts, docs or products. The build reads each file once. A request parses nothing.

Declare a collection

Put the files in content/<name>/. Declare the entry type with #[collection("<name>")] on a struct in a file of src/, for example src/content.rs.

#[collection("posts")]
struct Post {
    title: &'static str,
    date: &'static str,
    summary: Option<&'static str>,
    tags: &'static [&'static str],
    draft: bool,
    slug: &'static str,
    body: &'static str,
}

A file content/posts/hello.md is one entry:

---
title: Hello
date: 2026-11-01
tags: [wisp, content]
draft: false
---
# Hello

The text of the post.

Fields

  • A field type is static data: &'static str, bool, a number, &'static [&'static str], or Option of one of them. String and Vec fail the build.
  • A front matter line is name: value. A list is [a, b]. og-image is the field og_image, as in a Markdown page.
  • slug is the file name without .md. body is the Markdown as HTML. The build sets both, so the front matter does not.
  • A missing title is the text of the first # h1.
  • An Option field can be missing. Any other field must be in the front matter.

Errors

The build fails with the file and the line. It checks these cases:

  • A field that the front matter lacks: content/posts/a.md:1: Post needs date in the front matter (date is text).
  • A name that the struct lacks, such as a typo: content/posts/a.md:2: titel is not a field of Post. Its fields are title, date, tags, .... Add it to the struct in <file>, or fix the name.
  • A value of the wrong type: content/posts/a.md:5: draft: maybe is not true or false (Post.draft: bool). write true or false.
  • A type that cannot be static data, and a folder content/<name> that does not exist.

Query

content is the file src/content.rs. Each collection is a module in it with these functions.

let posts = content::posts::all();               // &'static [Post]
let hello = content::posts::get("hello");        // Option<&'static Post>
let live = content::posts::filter(|p| !p.draft);  // Vec<&'static Post>
let top = content::posts::first(|p| p.tags.contains(&"rust"));
let n = content::posts::count(|p| p.draft);       // usize
let by_title = content::posts::sorted(|p| p.title);
let longest = content::posts::sorted_desc(|p| p.body.len());
let second_page = content::posts::page(10, 10);   // &'static [Post]
let live_page = content::posts::page_of(|p| !p.draft, 10, 10); // Vec<&'static Post>
let (newer, older) = content::posts::around("hello"); // (Option<&Post>, Option<&Post>)

filter, first and count take a test and read the entries in the order of all(). sorted puts the smallest key first and sorted_desc the largest key first. Equal keys keep the order of all(). page(skip, limit) returns at most limit entries after the first skip, and it returns an empty slice when skip is past the end. page_of does the same for the entries that pass a test, and it returns a Vec. around(slug) returns the entry before and the entry after slug in the order of all(). A value is None at an end of the list and for an unknown slug. The test of filter, first, count and page_of can be FnMut. No function skips drafts by itself: draft is a field of your own, so write filter(|p| !p.draft).

all() is newest first when the struct has a date field. Else it is in slug order. A date of text must be a real YYYY-MM-DD day (2026-02-31 fails), with an optional time (2026-01-02T10:30, then optional seconds and a Z or +02:00 zone). Other text fails the build, because 2026-9-1 would sort after 2026-10-01. A symbolic link in content/ fails the build. A file has at most 1 MiB and a collection has at most 10000 entries. A folder counts as an entry. get is a binary search of the slugs.

In a page, show the body with {@html post.body}. The body is the text of your own files, so treat it as trusted. The example examples/blog has an index page and a page for each post.

Cost

The build parses each file once and writes the entries as a static array in the binary. A request does not read a file and does not parse Markdown. In wisp dev, a save in content/ builds the app again.

Other file kinds and folders

  • A .json file is one JSON object. A list is a JSON array. A null value counts as missing.
  • A .yaml or .yml file is flat name: value lines, with the same rules as front matter. Nesting and - item lists are errors.
  • JSON and YAML entries have no body. Their body is an empty string.
  • Nested folders work. The file content/docs/guide/start.md has the slug guide/start. A route [...slug] can take it.
  • A README.md is not an entry. Names that start with . and other file kinds are skipped.
  • Two files with one slug, such as a.md and a.json, fail the build.

Is This Page Useful?

View This Page's Source

Type to search every docs page and heading.