Skip to content

sjc5/cook

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

cook

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

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/:year or /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/markdown and text/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.

Start With Files

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.

Frontmatter

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:

  • title
  • description
  • date
  • order
  • tags
  • draft

Route directives:

  • route overrides the route derived from the file path.
  • aliases adds redirect or gone routes.
  • Alias behavior is redirect_permanent, redirect_temporary, or gone.

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.

Cache Policy

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));
  • Static builds on first use and changes only after site.refresh().await?.
  • CheckEveryRequest checks 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.

Queries

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:

  • :name captures one segment.
  • A final * captures the rest.
  • _index is 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.

Navigation

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 with VirtualNode.

Aliases do not create directory nodes.

Collections

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

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)

Upgrade To Postgres

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, &paranoid_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?.

Import Files Into Postgres

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.

Postgres Writes

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.

Tower Representations

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.

Vorma

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);
}

Notes

  • Page::html and VormaPage::html are rendered Markdown HTML, unsanitized.
  • Drafts are hidden by default in public queries and serving adapters.
  • date is 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.

License

MIT OR Apache-2.0

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages