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 incontent/pages/loom.md. Loom rendered it: template tags first, then Markdown to HTML, then the whole thing intolayouts/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 &#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:
Loom— application, routing, request lifecycleLoom::{Config,Request,Response,Router,Session,Auth,Content,Page}Loom::Controller::{Site,Admin}Loom::Template+::{Lexer,Parser,Renderer,Context,Filters}Loom::{Markdown,Media,Upload,Uhoh,Util}
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,
} );