NAME

Punk::Mount::Markdown - a directory of markdown as a documentation site

SYNOPSIS

package MyApp;
use Punk;

markdown '/docs' => 'docs', title => 'MyApp Guide';

1;

DESCRIPTION

Point the markdown keyword at a nested directory of .md files and it becomes a complete documentation site: rendered HTML with syntax-highlighted code, a sidebar reflecting the directory tree, a per-page table of contents built from the headings, ranked search, and the images sitting alongside the markdown served as static files.

The whole site is built at boot. The tree is walked, every page is rendered through Markdown::Simple and wrapped by Template::Stencil, the search index is filled, and the finished bytes are frozen into a lookup table. A request is then a hash hit and a response triplet, with no markdown parse, no template render and no Perl frame. The cost of that is a startup pass linear in the size of the tree, paid once per worker; see "reload" for the edit loop.

Building at boot also means a missing directory, an unreadable document or a broken template fails at startup with the rest of your configuration, rather than on whichever request first happens to reach it.

The mount rides Punk's ordinary mount table, so it behaves like static does: longest prefix wins, and a request served by the mount bypasses before/after hooks, guards and CSRF.

URLS

A page's address is its path under the directory with the .md dropped, and an index file collapses to its containing directory:

docs/index.md              ->  /docs
docs/guide/intro.md        ->  /docs/guide/intro
docs/guide/index.md        ->  /docs/guide

A request carrying a trailing slash or a .md suffix is redirected to the canonical form, so a document never has two addresses. Anything in the tree that is not markdown is served as a static file with the same caching, conditional-request and sendfile behaviour Punk::Static gives it, which is what makes a relative ![](diagram.png) in a document work. A file ending in .md is never served that way, so a draft or an index cannot be read as its source, and neither is anything whose name, or whose directory's name, begins with a dot.

FRONT MATTER

A document may open with a restricted --- block:

---
title: Getting Started
nav: Start here
order: 1
---

# Getting Started

Recognised keys are title, nav (the sidebar label, when it should differ from the title), order (the sort key within a section), draft (when true, the page is skipped entirely), and date and updated (below).

A section's index page (guide/index.md for the guide directory) may also name and place its section in the sidebar: section is the group's label, in place of the directory's name, and section_order its position among the sections. Sections without section_order follow those with it, by directory name:

---
title: File::Raw::JSON
section: JSON
section_order: 2
---

Every key, recognised or not, is passed to the templates in meta, as the string it was written as, so a page can carry its own description, image or anything else a site's templates want to read:

---
title: Getting Started
description: Install the framework and serve a first page.
---

{% if meta.description %}<meta name="description" content="{% meta.description %}">{% end %}

An unknown key is never an error, so a document carrying front matter for some other tool still renders. Values are not split or typed: tags: [a, b] arrives as the string [a, b].

This is deliberately not YAML. Running a YAML parser on every page at boot to read two scalars would put a dependency on the C path for no benefit.

Where there is no title, the first # heading is used, and failing that the filename with hyphens and underscores turned into spaces.

DATES

---
date: 2026-10-19
updated: 2026-10-21T09:30:00+01:00
---

date and updated take YYYY-MM-DD, read as midnight UTC, or YYYY-MM-DDTHH:MM with optional :SS and then Z or a +HH:MM / -HH:MM offset (a space may stand for the T). Anything else, an impossible day like 2026-02-30 included, is a configuration error: the application fails at boot naming the file and the value. Under reload, the same error reaches the request that read the bad edit. updated defaults to date.

A dated page gets og:type article and the article:published_time and article:modified_time tags (see "SEO"), and its templates receive date and updated as epochs, date_iso and updated_iso as 2026-10-19T00:00:00Z, and date_human and updated_human as 19 October 2026 (in English, and in UTC; use the epoch for anything else).

sort => 'date' orders the pages newest first, which is what a blog wants. Sections are ignored, so a nested page sorts among the rest; pages with the same date go by title and then by path, so the order is the same on every filesystem; undated pages come last, in path order. The sidebar follows the same order, and a mount with no index file redirects its root to the newest page.

LISTING

get '/blog' => sub {
    my $c = shift;
    $c->render('blog_index', { posts => $c->app->markdown_pages('/blog') });
};

$app->markdown_pages($prefix) returns the pages of the markdown mount at that prefix, in the mount's order (newest first under sort => 'date'), as an arrayref of hashrefs:

{
    url     => '/blog/hello',     # the prefix and the page: what a link wants
    path    => '/hello',          # under the mount; '/' for the root page
    title   => 'Hello',
    date    => 1792368000,        # epoch, or undef
    updated => 1792368000,        # epoch, or undef
    summary => '...',             # description, else the start of the text
    meta    => { ... },           # every front-matter key
}

Drafts are not in it. summary is the front-matter description, or else the page's plain text with its leading title dropped and its whitespace collapsed, cut to 200 bytes with .... Every string is characters, not bytes, and every value is a copy: changing the list changes nothing served. Under reload, meta, date and updated follow the last re-read of each page; a new file still needs a restart.

The pages are built when the application compiles, so calling it before to_app croaks; from a route, or from anything that runs after to_app, it is ready. An unknown prefix croaks naming the markdown mounts there are.

A route declared at the mount's own prefix (get '/blog' beside markdown '/blog') answers that address, since an exact route is matched before any mount; the pages under it are still the mount's.

The application object is $c->app in a route and MyApp->punk_app outside one.

SEO

The shipped wrapper writes a page's search and social tags from its front matter, so a page's description or share image is one line at its top:

---
title: Getting Started
description: Install the framework and serve a first page.
image: /img/start.png
image_alt: A terminal running punk new
---
  • description - <meta name="description">, og:description and twitter:description.

  • canonical - <link rel="canonical"> and og:url. Without it, the application's host joined to the page's address; with neither, no canonical is written.

  • image - og:image and twitter:image, and the large Twitter card. A full http(s):// URL is used as it is, a rooted path is put on the host, and anything else is taken as beside the source file, so image: diagram.png in docs/guide/intro.md is https://example.com/docs/guide/diagram.png.

  • image_alt - og:image:alt and twitter:image:alt.

  • robots, keywords, author - the tags of those names.

  • og_type - og:type. Without it, article for a page with a date, otherwise website.

og:title, og:site_name, twitter:title and twitter:card are always written for a page; the search page and the 404 have none of these. Every value is escaped for the attribute it sits in.

The origin is the application's host (or a host option on the mount, which wins), never the request's: the pages are built at boot, before there is a request, and a canonical taken from a Host header would hand a cache somebody else's hostname.

A template_dir of your own receives the same values: meta, and the worked-out host, canonical, image, og_type and twitter_card.

OPTIONS

markdown '/docs' => 'docs',
    title    => 'MyApp Guide',
    reload   => 1,
    edit_url => 'https://github.com/me/app/edit/main/docs/%s';
  • title - the site name, shown in the header and the page title. Defaults to the mount prefix.

  • index - the filename that means "this directory". Default index.md.

  • recursive - descend into subdirectories. Default true.

  • sort - order (default) groups pages by section (placed by a section index's section_order:, then by name) and orders them by front-matter order:, alpha keeps the plain path order, and date puts the newest first (see "DATES"). Any other value fails at boot.

  • reload - re-stat a page on request and re-render it when the source has changed. Off by default, since the point of the mount is that pages are frozen; turn it on for the edit loop under punk dev.

  • highlight - syntax-highlight fenced code blocks. Default true.

  • gfm - GitHub Flavored Markdown. Default true.

  • toc - build a per-page table of contents from the h2 and h3 headings. Default true.

  • search - build a Search::Trigram index at boot and serve a search page from it. Default true.

  • search_path - where that page lives, relative to the mount. Default /search.

  • template_dir - your own templates instead of the shipped ones. The directory must supply page.tmpl, wrapper.tmpl and app.css; see "TEMPLATES".

  • wrapper - the wrapper template name. Default wrapper.tmpl.

  • assets - where the shipped stylesheet is served from, relative to the mount. Default /_punk.

  • footer - raw HTML for the page footer.

  • host - the origin for canonical and image URLs. Defaults to the application's host; see "SEO".

  • vars - a hashref of the site's own values, given to every page's templates beside the mount's: a site name, its links, anything a shared header or footer reads. Strings, arrays and hashes are all fine. Copied at boot, so changing the hash afterwards changes nothing. A name the mount fills itself (title, url, meta, canonical and the rest of "TEMPLATES") is refused at boot rather than silently shadowed.

    markdown '/blog' => 'posts',
        template_dir => 'root/templates/post',
        vars         => { site_name => 'Example', links => \@links };
  • filters - a hashref of name => coderef, handed to the mount's Template::Stencil as its filters, so its templates can use the site's own: {% raw title | json %} for structured data, say. A template using a filter that is not registered fails at boot naming it.

  • sitemap - 0 keeps the pages out of Punk::Plugin::Sitemap, which otherwise lists them with a <lastmod> from updated: or date:, leaving out any page whose robots: says noindex. Default 1.

  • headers - a hashref of extra response headers to send with every page.

TEMPLATES

The shell is rendered by Template::Stencil. The shipped templates live in templates/ beside this module; point template_dir at a directory of your own and it replaces them, so that directory has to supply all three files:

  • page.tmpl

    The body of a page. It receives the data below and is expected to emit {% raw content %} somewhere. This one template renders all three kinds of response - a document, the search results and the 404 - so branch on kind if they should differ.

  • wrapper.tmpl

    The surrounding document: <html>, the header, the sidebar, and {% content %} where page.tmpl's output is slotted in. It receives the same data. Rename it with the wrapper option if you would rather call it something else.

  • app.css

    The stylesheet, served from the mount's assets path. It is read verbatim rather than rendered, so it is a plain CSS file and its braces mean nothing to the template engine. It lives with the templates because a caller replacing the shell almost always wants to replace its styling too.

Both templates receive:

  • kind - page, search or notfound

  • title - the page title

  • site_title - the site name, from the title option

  • content - the rendered markdown, to be emitted with {% raw content %}

  • nav - the sidebar HTML, likewise raw

  • toc - the table of contents HTML, likewise raw, or empty

  • url - the current page's mount-relative address

  • prefix - the mount prefix, for building links

  • search - true when a search index was built

  • search_path, assets - both mount-relative

  • footer - raw HTML, or empty

  • meta - the page's front matter, every key; an empty hash on the search page and the 404

  • host, canonical, image, og_type, twitter_card - the SEO values worked out from it; see "SEO"

  • date, updated, date_iso, updated_iso, date_human, updated_human - on a dated page; see "DATES"

The navigation and the table of contents arrive as ready HTML rather than as data to loop over because Stencil has no recursion and no dynamic includes, so an arbitrarily deep tree cannot be walked in a template. They are rebuilt per page at boot, which is what lets the current entry carry a current class without any JavaScript.

If you only want to restyle the site, supply your own app.css and leave the templates alone: copy the shipped templates/ directory, replace the stylesheet, and point template_dir at your copy.

METHODS

app($dir, \%opts?, $prefix?)

my $psgi = Punk::Mount::Markdown->app('docs', { title => 'Guide' }, '/docs');

Builds the whole site from $dir with the "OPTIONS" and returns the PSGI coderef that serves it under $prefix. What the markdown keyword compiles to at to_app; call it directly to serve a site from another PSGI stack. Croaks when $dir is not a directory, and on anything that fails to build.

SEE ALSO

Punk, Punk::Static, Markdown::Simple, Template::Stencil, Search::Trigram

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

Copyright (c) 2026 LNATION.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.