Loom

A template engine and flat-file website engine in core Perl. It weaves a header, footer and body around your content — the classic model, scaled up with modern layout inheritance — and ships with a responsive admin for editing pages.

This page is a Markdown file with front-matter, sitting in content/pages/loom.md. Loom rendered it: template tags first, then Markdown to HTML, then the whole thing into layouts/base.

The template language

{{ var }} interpolates and auto-escapes. {{{ var }}} emits raw. Statements use {% ... %}, and comments {# ... #} are dropped.

{% extends "layouts/base" %}
{% block content %}
  <h1>{{ page.title }}</h1>
  {{{ page.body_html }}}
  {# a comment, dropped before rendering #}
  {% if user.role == "admin" %}
    <a href="{{ site.admin_path }}">Admin</a>
  {% elsif user %}
    Signed in as {{ user.name }}
  {% else %}
    <a href="/login">Sign in</a>
  {% endif %}
  {% for post in posts %}
    <li>{{ loop.index }}. {{ post.title | truncate(40) }}</li>
  {% endfor %}
{% endblock %}

Those braces are HTML entities in the source file, and that block is raw HTML rather than a fenced code block. Both are forced: this page is itself a template, so a literal tag would be executed rather than printed, and Markdown would escape the entities back into visible &amp;#123;. That is the honest version of eating your own dog food.

Filters

abs capitalize date default escape first join last length lower raw replace reverse round slug trim truncate upper

Chain them with |, and pass arguments: | truncate(40), | date("%Y"), | default("none").

Try all of it in the playground → — that page runs Loom::Template->render_string on whatever you type, on this server.

Content model

Pages are files. No database.

content/pages/loom.md      ← front-matter + body
content/data/site.json     ← site-wide variables
content/data/menu.json     ← the navigation you see above

Front-matter is key: value lines between --- fences. title, template, format (markdown or html) and description are understood; anything else lands in page.meta for your templates to use.

Architecture

One class per file, no CPAN:

The admin

/admin is a real admin, not a screenshot: session login with salted, stretched password hashing (PBKDF2-style over core Digest::SHA), a CSRF token on every POST, page create/edit/delete, a media manager, and a settings screen.

Sign in with demo / demodemo and change whatever you like. A timer restores the pristine content and clears uploaded media at the top of every hour, so go ahead and break it.

Using the engine directly

use lib 'lib';
use Loom::Template;
my $tt = Loom::Template->new( dirs => 'templates' );
print $tt->render_string( 'Hello {{ name | upper }}', { name => 'ada' } );
print $tt->render( 'layouts/base', {
    site    => \%site,
    page    => \%page,
    content => $html,
} );