Template Expressions
Blogin's layouts are HAML. What HAML does not define is the language inside
= ..., - if ..., and #{...}. Ruby HAML embeds Ruby. Blogin embeds a small
language of its own.
Everything it does not do is a stated boundary rather than a missing feature, and reaching one produces an error naming the file, the line, the column, and the construct. It never guesses.
Why a language at all#
A layout has to ask questions of the page it is rendering: what is the title, does this post have tags, which navigation item is current. Answering those takes expressions. What it does not take is a general-purpose programming language embedded in a template, and the cost of one is a template that can do anything, including things nobody can predict from reading it.
So this language can read, compare, iterate, and call what the view offers. It cannot define, assign, or mutate.
Values#
Every value is one of: null, boolean, integer, number, string, list, or map.
These are the same values configuration and data files produce, so a layout
reads data from a JSON file the same way it reads anything else.
Truthiness#
- if and - unless ask whether a value is true. False are:
nullfalse0and0.0- the empty string
- the empty list
- the empty map
Everything else is true. An empty collection being false is what lets a layout
write - if tags rather than - if tags.elems > 0.
Grammar#
expression := or
or := and ( "||" and )*
and := comparison ( "&&" comparison )*
comparison := additive ( ("==" | "!=" | "<" | "<=" | ">" | ">=" | "eq" | "ne") additive )*
additive := unary ( ("+" | "-" | "~") unary )*
unary := ( "!" | "not" )? postfix
postfix := primary ( "." name call-args? | "<" name ">" | "(" arguments ")" )*
primary := literal | variable | name | map | "(" expression ")"
literal := string | number | "true" | "false" | "null"
map := "{" ( entry ( "," entry )* )? "}"
entry := name ":" expression
variable := "$" name
name := letter ( letter | digit | "-" | "_" )*
arguments := ( argument ( "," argument )* )?
argument := expression | ":" name "<" text ">" | ":" name "(" expression ")"
+ concatenates strings, and so does ~.
&& and || stop as soon as the answer is known and yield the value rather
than a boolean, so subtitle || title reads as a default and
- if post && post<title> is safe to write.
Reading values#
A bare name is a question asked of the view: title, site-title,
has-tags, nav-nodes. The view decides what to return. A name the view does
not offer is an error, not null, because a typo in a layout should be visible.
$name is a local: a loop variable, or something passed through :locals.
An unknown local is an error for the same reason.
.name reads a member of a map or asks an object for a property:
$node.url, $post.title. On a list, .elems, .size, .count, .first,
and .last are built in. On a string, .chars and .elems are its length.
<name> reads a map key: $tag<url>, data<archives>. $tag.url and
$tag<url> do the same thing, so write whichever reads better.
A missing key reads as null rather than raising, because - if $entry<image> is
how a layout asks whether something is there.
name(...) calls what the view offers, with arguments:
format-date(date, "%B %e, %Y"), nav-current($node), truncate(summary, 200).
url() and url mean the same thing.
Maps#
{ name: expression, ... } is the one way to write a map, and it exists because
:locals needs one:
!= render(:partial<header>, :locals({brand: site-title}))
A map and a deferred block both open with {. A map's first thing is a name
followed by a colon, and nothing else can start that way, so the two are told
apart without any marker.
Named arguments#
render takes named arguments:
!= render(:partial<entry>, :collection(posts), :as<entry>)
:name<text> passes literal text. :name(expression) passes a value. This
shape is accepted for any call, not only render.
Control flow#
- if has-tags
%p tagged
- elsif has-categories
%p categorised
- else
%p untagged
- unless draft
%p published
- for tags -> $tag
%li= $tag<name>
- for iterates a list. Iterating null or a scalar is an error, since it is
almost always a mistake rather than an empty result.
There is no ternary operator. Write a two-branch - if instead, or reach for
|| when what you want is a default.
Output#
= writes an escaped value. != writes it raw. #{...} interpolates inside
text and inside attribute values, and escapes.
Escaping means &, <, >, and " become entities. There is no way to write
markup accidentally: raw output is always spelled !=.
Blocks#
One form takes a block, and only one:
!= cache-fragment('header', { render(:partial<header>) })
The block is not a closure. It is an expression whose evaluation is deferred,
which is all cache-fragment needs. Blocks cannot take parameters, be stored,
or be passed anywhere else.
Working examples#
Each of these is a pattern a real site uses, with what the language is doing spelled out.
A post's page#
%article
%h1= title
- if show-dates
%p.meta= "#{format-date(date, "%B %e, %Y")} ยท #{reading-time} min read"
!= body
title, show-dates, date, reading-time, and body are names the view
answers. format-date is a call the view offers, taking the date and a strftime
pattern. body is written with != because it is HTML the Markdown renderer
produced. Everything else is written with = and escapes.
A listing entry#
.card
%h5
%a{href: "#{$entry<url>}"}= $entry<title>
%p= $entry<description>
- if index-dates
%small= $entry<date>
$entry is the loop variable the listing passed in through :collection and
:as. <url> reads a key of it. The href is written with interpolation
because it is part of a larger string. The attribute value escapes either way.
Navigation, which is recursive#
%li
- if nav-current($node)
%a.active{href: "#{$node.url}"}= $node.label
- else
%a{href: "#{$node.url}"}= $node.label
- if $node.children.elems
%ul
!= render(:partial<nav-item>, :collection($node.children), :as<node>)
nav-current($node) asks the view whether this node is the section being
rendered. $node.children.elems is the list's length, and an empty list is
false, so the test could be written - if $node.children just as well. The
partial renders itself, once per child, with $node rebound each time.
A default#
%title= page-title || site-title
|| yields the left value when it is true and the right one otherwise, rather
than a boolean, which is what makes it read as a default.
A fragment rendered once#
!= cache-fragment('header', { render(:partial<header>) })
The name is advisory. What decides whether the rendered bytes are reused is what the fragment read while rendering: a header reading only site-level values renders once for the whole build, and one reading the current url renders per page. Asking for reuse cannot produce a stale fragment.
Values passed to a partial#
!= render(:partial<header>, :locals({brand: site-title}))
Inside _header.haml, that is $brand. :locals takes a map, which is the
reason the language has one.
What it does not do#
Each of these is refused with an error rather than half-supported:
- Assignment. No
=, nomy, no accumulating a variable across a loop. - List literals. A map is written because
:localsneeds one. Nothing needs a list literal, so there is not one. - User-defined functions. A layout calls what the view offers.
- Mutation. Nothing a template does changes a value another template sees.
- Arbitrary method chains into the host.
.uc,.split,.mapand the like are not available. What the view offers is the whole surface. - General closures. Only the deferred-block form above.
- Ranges, regular expressions, and case statements. No equivalent.
- Arithmetic beyond
+and-. No*,/, or%. Layouts do not calculate. Views do.
Errors#
An error names where it happened and what was refused:
layouts/show.haml:14:11: no such name 'has-tag' (did you mean 'has-tags'?)
layouts/_entry.haml:3:22: cannot iterate a string
layouts/base.haml:9:5: assignment is not supported
A suggestion appears when a name is close to one that exists, since the usual cause is a typo rather than a misunderstanding.
The names the view answers are listed in Template Data. The HAML constructs around them are in HAML Compatibility.