cook is a pre-release Rust utility crate. At the moment, the substantial
module is md-pages: a Markdown page system for apps that want filesystem
authoring, route-aware querying, navigation, and a path to Postgres-backed
editing.
No features are enabled by default.
[dependencies]
cook = { version = "0.0.0-pre.1", features = ["md-pages"] }Add adapter features as needed:
cook = { version = "0.0.0-pre.1", features = [
"md-pages",
"md-pages-postgres",
"md-pages-tower",
"md-pages-vorma",
] }md-pages is for content that has URLs: docs, blogs, changelogs, notes,
project pages, help centers, and similar app-owned content.
The module includes:
- Markdown rendering with YAML frontmatter
- Canonical routes, aliases, redirects, and gone routes
- Inferred directories from page routes
- App-defined virtual sections such as
/posts/:yearor/tags/:tag - Selectors for tags, text, route patterns, children, descendants, drafts, and composition
- Named collections with ordering and pagination
- Breadcrumbs, sibling nav, child nav, and page summaries
- A refreshable in-memory index shared by filesystem and Postgres sources
- Optional Postgres storage and write APIs
- Optional Tower middleware for
text/markdownandtext/plain - Optional Vorma lookup and template data helpers
The core model is:
ContentSource -> ContentSnapshot -> ContentIndex -> Site
Most applications use Site. It owns a content source, builds an immutable
ContentIndex, and refreshes that index according to a CachePolicy.
Enable:
[dependencies]
cook = { version = "0.0.0-pre.1", features = ["md-pages"] }Filesystem routes come from .md files:
content/_index.md -> /
content/about.md -> /about
content/docs/_index.md -> /docs
content/docs/intro.md -> /docs/intro
content/posts/2026/hi.md -> /posts/2026/hi
_index.md is the page for its containing directory. A route collision, such as
both foo.md and foo/_index.md, is an index error.
use std::time::Duration;
use cook::md_pages::{
CachePolicy, Collection, FileSource, OrderBy, QueryOptions, RoutePath, Selector, Site,
VirtualNode,
};
async fn load_content() -> cook::md_pages::Result<()> {
let site = Site::new(FileSource::new("content"))
.with_cache_policy(CachePolicy::CheckEvery(Duration::from_secs(1)))
.with_collection(
"posts",
Collection::new()
.select(Selector::pattern("/posts/:year/*"))
.order_by(OrderBy::DateDesc)
.page_size(20),
)
.with_virtual_node(
VirtualNode::new("/posts/:year")
.expand_from_routes("/posts/:year/*")
.title(|params| params["year"].clone())
.select(|params| Selector::pattern(format!("/posts/{}/*", params["year"]))),
);
let page = site
.page_for_route(&RoutePath::parse("/posts/2026/hello")?)
.await?;
let docs_nav = site
.children(&RoutePath::parse("/docs")?, QueryOptions::nav())
.await?;
let first_posts_page = site.collection("posts")?.page(1).await?;
let _ = (page, docs_nav, first_posts_page);
Ok(())
}Filesystem pages do not get durable PageIds. Routes, aliases, navigation, and
queries work without them.
Markdown files use YAML frontmatter.
---
title: Hello
description: A short summary
date: "2026-01-01"
order: 10
tags: [rust, docs]
draft: false
route: /writing/hello
aliases:
- /posts/hello
- path: /blog/hello
behavior: redirect_temporary
teaser: Custom fields are preserved
editor_note: Built-in serving does not expose this
---
# Hello
Markdown body.Standard page metadata:
titledescriptiondateordertagsdraft
Route directives:
routeoverrides the route derived from the file path.aliasesadds redirect or gone routes.- Alias behavior is
redirect_permanent,redirect_temporary, orgone.
Unknown frontmatter fields are stored in PageMeta::extra for application code.
Built-in serving adapters return the Markdown body only.
Page::html is rendered Markdown HTML. It is not sanitized. If untrusted users
can author Markdown, sanitize before inserting that HTML into a page.
Site builds an in-memory ContentIndex and reuses it.
use std::time::Duration;
use cook::md_pages::CachePolicy;
CachePolicy::Static;
CachePolicy::CheckEveryRequest;
CachePolicy::CheckEvery(Duration::from_secs(1));Staticbuilds on first use and changes only aftersite.refresh().await?.CheckEveryRequestchecks the source revision before every query.CheckEvery(duration)checks the source revision at most once per interval.
For filesystem content, CheckEveryRequest is convenient during development and
Static or CheckEvery usually fits deployment. For Postgres content,
CheckEvery(duration) gives every process a simple TTL for seeing writes made
by any process.
Use Selector to describe what content you want.
use cook::md_pages::{OrderBy, QueryOptions, RoutePath, Selector};
let selector = Selector::pattern("/posts/:year/*")
.and(Selector::tag("rust"))
.and_not(Selector::draft());
let pages = site
.pages(selector, QueryOptions::recent().limit(20))
.await?;
let entries = site
.entries(
Selector::children_of(RoutePath::parse("/docs")?),
QueryOptions::nav(),
)
.await?;
let search = site
.pages(
Selector::text("cache policy"),
QueryOptions::new().order_by(OrderBy::TitleAsc),
)
.await?;
let _ = (pages, entries, search);Pattern selectors use Vorma-style path patterns:
:namecaptures one segment.- A final
*captures the rest. _indexis the explicit index segment.
pages(...) returns page summaries. entries(...) returns page, directory, and
virtual-node entries. Drafts are hidden unless QueryOptions::include_drafts()
is set.
lookup(...) returns the route context needed by page layouts.
use cook::md_pages::RoutePath;
let lookup = site.lookup(&RoutePath::parse("/docs/intro")?).await?;
if let Some(lookup) = lookup {
let current = lookup.node;
let breadcrumbs = lookup.breadcrumbs;
let siblings = lookup.siblings;
let children = lookup.children;
let _ = (current, breadcrumbs, siblings, children);
}Nodes can be:
PageBacked: a page exists at the route.Inferred: the route exists because descendants exist.Virtual: the app configured the route withVirtualNode.
Aliases do not create directory nodes.
Collections are named reusable queries.
use cook::md_pages::{Collection, OrderBy, Selector};
let site = site.with_collection(
"posts",
Collection::new()
.select(Selector::pattern("/posts/:year/*"))
.order_by(OrderBy::DateDesc)
.page_size(20),
);
let page_one = site.collection("posts")?.page(1).await?;Use them for stable groups such as posts, docs, projects, notes, releases, or tag pages.
Virtual nodes add sections that do not have their own Markdown file.
use cook::md_pages::{Selector, VirtualNode};
let by_year = VirtualNode::new("/posts/:year")
.expand_from_routes("/posts/:year/*")
.title(|params| params["year"].clone())
.select(|params| Selector::pattern(format!("/posts/{}/*", params["year"])));
let by_tag = VirtualNode::new("/tags/:tag")
.expand_from_tags("tag")
.title(|params| format!("#{}", params["tag"]))
.select(|params| Selector::tag(params["tag"].clone()));
let site = site.with_virtual_node(by_year).with_virtual_node(by_tag);Expansion sources:
expand_from_routes(pattern)expand_from_tags(param_name)expand_from_instances(params)expand_with(callback)
Use Postgres when content editing moves into an admin UI, a CMS flow, or app
APIs. The read side still uses Site, ContentSnapshot, and ContentIndex, so
queries and navigation stay the same.
Enable:
[dependencies]
cook = { version = "0.0.0-pre.1", features = ["md-pages-postgres"] }
paranoid = { version = "0.0.0-pre.7", default-features = false, features = ["db"] }Create a store for one schema. cook owns the table names inside that schema:
pages, routes, tags, and revisions.
use std::time::Duration;
use cook::md_pages::{
CachePolicy, NewPage, PostgresSource, PostgresStore, RoutePath, RouteSpec, Site,
};
use paranoid::db::{BootstrapConfig, WritePool};
async fn postgres_site(
write_pool: WritePool,
) -> Result<Site<PostgresSource>, Box<dyn std::error::Error>> {
let store = PostgresStore::new("__md_pages")?;
let paranoid_stores = BootstrapConfig::default()
.migrate_schema(&write_pool)
.await?;
store.migrate_schema(&write_pool, ¶noid_stores).await?;
let page_id = store
.create_page(
&write_pool,
NewPage::new(
RouteSpec::new(RoutePath::parse("/posts/hello")?),
"# Hello\n\nBody.",
),
)
.await?;
let site = Site::new(PostgresSource::new(store, write_pool))
.with_cache_policy(CachePolicy::CheckEvery(Duration::from_secs(1)));
let _ = page_id;
Ok(site)
}Each write updates the Postgres content revision. A Site with
CheckEvery(...) sees new content after the next revision check. A Static
site sees it after site.refresh().await?.
The importer reads a ContentSnapshot and writes structured Postgres rows.
use cook::md_pages::{FileSource, PostgresMarkdownImporter, PostgresStore};
use paranoid::db::WritePool;
async fn import_content(
write_pool: &WritePool,
store: PostgresStore,
) -> Result<(), Box<dyn std::error::Error>> {
let snapshot = FileSource::new("content").read_snapshot()?;
let report = PostgresMarkdownImporter::new(store)
.import_snapshot(write_pool, snapshot)
.await?;
println!("imported {} records", report.imported_count());
Ok(())
}The importer creates pages and standalone routes. It fails on route conflicts.
After import, edit the Postgres rows through PostgresStore write APIs.
Page writes are available as convenience methods that open their own transaction
and as _in_current_transaction methods for composing with other app writes.
use cook::md_pages::{
AliasPath, ClearablePatch, DeletePolicy, NewPage, PagePatch, PageUpdate, Patch, RouteChange,
RoutePath, RouteSpec, StandaloneRoute,
};
let route = RouteSpec::new(RoutePath::parse("/posts/hello")?);
let page_id = store.create_page(&write_pool, NewPage::new(route, "# Hello")).await?;
let new_route = RouteSpec::new(RoutePath::parse("/posts/renamed")?);
store
.update_page(
&write_pool,
page_id,
PageUpdate::new(new_route, "# Renamed")
.with_route_change(RouteChange::mark_previous_canonical_gone()),
)
.await?;
let mut patch = PagePatch::new();
patch.meta.title = ClearablePatch::set("New title".to_owned());
patch.markdown = Patch::set("# New body".to_owned());
store.patch_page(&write_pool, page_id, patch).await?;
store.publish_page(&write_pool, page_id).await?;
store.unpublish_page(&write_pool, page_id).await?;
store
.delete_page_with_policy(
&write_pool,
page_id,
DeletePolicy::redirect_routes_permanently_to(RoutePath::parse("/archive")?),
)
.await?;
store
.set_standalone_route(
&write_pool,
StandaloneRoute::redirect_permanent(
AliasPath::parse("/old-path")?,
RoutePath::parse("/new-path")?,
),
)
.await?;Route-change policies:
RouteChange::redirect_previous_canonical_permanently()RouteChange::redirect_previous_canonical_temporarily()RouteChange::mark_previous_canonical_gone()RouteChange::remove_previous_canonical()
Delete policies:
DeletePolicy::remove_routes()DeletePolicy::mark_routes_gone()DeletePolicy::redirect_routes_permanently_to(route)DeletePolicy::redirect_routes_temporarily_to(route)
Standalone routes are redirects or gone routes without a page row. They are useful for old URLs after a page has been deleted or imported legacy redirects. Page writes do not take over a standalone route by accident; delete or replace the standalone route explicitly when reclaiming a URL.
Enable:
cook = { version = "0.0.0-pre.1", features = ["md-pages-tower"] }MdPagesRepresentationLayer serves the raw Markdown body for requests that
explicitly accept text/markdown or text/plain.
use std::sync::Arc;
use cook::md_pages::{Selector, tower::MdPagesRepresentationLayer};
let layer = MdPagesRepresentationLayer::new(Arc::new(site))
.select(Selector::pattern("/docs/*"));GET and HEAD are handled. Other methods pass through. Drafts are hidden unless
include_drafts() is set. Redirect and gone routes become HTTP redirect and
gone responses.
Enable:
cook = { version = "0.0.0-pre.1", features = ["md-pages-vorma"] }The Vorma adapter gives handler helpers and template-shaped data. Your app still owns its routes and page rendering.
use cook::md_pages::vorma;
let lookup = vorma::lookup_page(&ctx, &site).await?;
vorma::apply_lookup_response(&ctx, &lookup)?;
if let Some(page) = lookup.page() {
vorma::apply_page_head(&ctx, page);
let data = vorma::VormaPage::from(page.as_ref());
let nav = site.lookup(&page.route).await?.map(vorma::VormaLookup::from);
let _ = (data, nav);
}Page::htmlandVormaPage::htmlare rendered Markdown HTML, unsanitized.- Drafts are hidden by default in public queries and serving adapters.
dateis preserved as text; built-in date ordering sorts that text.Selector::text(...)is in-memory search across indexed text fields.- The in-memory index stores rendered page bodies. For very large content sets, watch memory and consider whether page bodies should live elsewhere before rendering.
MIT OR Apache-2.0