Templating HTML
The markup key turns a directory of templates into a static site. Files keep their directory
structure in the output; directories starting with _ (like _layouts, _partials) are treated
as includes and not emitted.
{
"markup": {
"in": "src/markup",
"out": "dist",
"options": {
"engine": "nunjucks",
"site": { "title": "My Site", "description": "Built with Poops." },
"data": ["_data/links.json", "_data/nav.yaml"],
"includePaths": ["_layouts", "_partials"]
}
}
}
inβ the templates directory..html,.md, and the engine's native extension are processed.outβ the output directory.siteβ global data available to every page assite.*.dataβ JSON/YAML files loaded as globals, named after the file (links.jsonβlinks).includePathsβ extra folders on the include search path for partials/layouts.baseURL(optional) β a fixed URL prefix that replaces the computed relative prefixes. When set,relativePathPrefixalways resolves to this value (trailing slash ensured) instead of the page-depth.//../. Useful when deploying under a subdirectory, e.g."/blog"fordomain.com/blog/. The--base-urlCLI flag overrides it per environment.
Every page can carry front matter β a YAML block at the top that sets title, description,
layout, date, order, and any custom fields you invent:
---
layout: default
title: About
description: Who we are.
---
# About us
The body is rendered by the engine (and Markdown, for .md), then wrapped in the layout named by
layout. Markdown code fences are syntax-highlighted at build time.
Tip
Poops exposes relativePathPrefix on every page β a correct ./ / ../ prefix for the page's
depth. Prefix asset and link URLs with it and your site works from any subdirectory or even
file://.
Markdown
.md files are rendered to HTML before the engine wraps them in a layout, so a page can be pure
Markdown with front matter β Jekyll-style. The same renderer powers the markdown filter, so inline
Markdown in a template produces identical output.
GitHub Flavored Markdown (GFM)
Poops renders GFM plus a few GitHub extras β the Markdown you already write in a repo README works here:
| Feature | Syntax |
|---|---|
| Tables | | a | b | with a | --- | --- | divider row |
| Task lists | - [ ] todo / - [x] done |
| Strikethrough | ~~gone~~ |
| Autolinks | a bare https://β¦ URL becomes a link |
| Emoji shortcodes | :rocket: β π |
| Footnotes | text[^1] with a [^1]: note definition |
| Alerts | > [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION], [!INFO] |
Alerts render as styled callout blocks ([!INFO] is a Poops-added variant β the others are
GitHub's). Every heading also gets a slug id and an empty permalink anchor (.heading-anchor), so
table-of-contents generation and in-page links work with no extra markup.
Note
To show a template tag literally inside a fenced code block, wrap the sample in a raw block so
the engine prints it verbatim instead of evaluating it.
Syntax highlighting
Fenced code blocks are highlighted at build time with highlight.js β no client-side script, no theme JS. Tag the fence with a language; an unknown or missing language falls back to auto-detection. Registered languages (with aliases):
| Language | Fence tags |
|---|---|
| JavaScript | javascript, js |
| TypeScript | typescript, ts |
| CSS | css |
| Sass (SCSS) | scss |
| HTML / XML | html, xml |
| JSON | json |
| Bash | bash, sh |
| Shell session | shell |
| Python | python, py |
| Ruby | ruby, rb |
| PHP | php |
| Java | java |
| C | c |
| C++ | cpp |
| C# | csharp, cs |
| Go | go |
| Rust | rust, rs |
| YAML | yaml, yml |
| Markdown | markdown, md |
| SQL | sql |
| Diff | diff |
The output carries hljs and language-{tag} classes on the <code> element β pair it with any
highlight.js stylesheet for colors. Tag a fence with a language:
```javascript
export function greet(name) {
const msg = `Hello, ${name}!`
console.log(msg)
return msg
}
```
β¦and it renders highlighted at build time:
export function greet(name) {
const msg = `Hello, ${name}!`
console.log(msg)
return msg
}
Fence info strings
Only the first word of a fence tags the language. Anything after it rides along onto the <code>
element rather than being dropped β a bare word becomes a class, a key=value token becomes a
data- attribute:
```html preview tab=options widths=375,768
<my-element></my-element>
```
<pre><code class="hljs language-html preview" data-tab="options" data-widths="375,768">β¦</code></pre>
That is how a fence marks itself for a later stage β a post-markup exec script that upgrades
code.preview blocks into live demos, say β without a marker comment in the Markdown. Values are
single tokens: no quotes, no spaces. Anything longer belongs in the prose around the fence.
A key with nothing after the = emits a valueless attribute, which is the way to write a boolean
flag you would rather read with hasAttribute than off classList:
```html preview expanded=
<my-element></my-element>
```
<pre><code class="hljs language-html preview" data-expanded="">β¦</code></pre>
Nunjucks (default)
Nunjucks is Mozilla's Jinja2-inspired engine. A layout uses blocks:
<!DOCTYPE html>
<html>
<head><title>{{ page.title or site.title }}</title></head>
<body>
{% include "header.html" %}
{% block content %}{% endblock %}
</body>
</html>
A page extends it:
{% extends "default.html" %}
{% block content %}
<h1>{{ page.title }}</h1>
{% endblock %}
Templates from an npm package
Layouts and partials can also live in an installed package, so a shared theme
ships as a dependency instead of copied files. Reference it by package name β
anything with a / is resolved from node_modules:
{% extends "my-theme/layout.html" %}
{% block content %}
<h1>{{ page.title }}</h1>
{% endblock %}
Or from front matter, so a page carries no template syntax at all:
---
layout: my-theme/layout
---
A theme package must:
- Not restrict subpaths with
exportsβ or map its templates explicitly, e.g."exports": { "./*": "./*" }. Otherwise Node blocks resolving the.htmlfiles by path. - Reference its own partials relatively β
{% import "./nav.html" as nav %}, not the bare name. A bare name (no/) is always searched in the consumer's project only, never the package.
Bundled filters (toc, breadcrumb, og, canonical, β¦) are engine-global,
so package templates can use them without any extra wiring.
Liquid resolves package templates the same way β node_modules is on its
include roots, so a Liquid theme's layouts and partials resolve by package name
too (with the theme shipping .liquid files):
{% layout "my-theme/layout.liquid" %}
{% block content %}
<h1>{{ page.title }}</h1>
{% endblock %}
Liquid
Prefer Shopify-flavoured Liquid? Set "engine": "liquid". Same feature
set β collections, search index, sitemap, nav, custom tags and filters all work identically. Only
the syntax differs:
| Feature | Nunjucks | Liquid |
|---|---|---|
| File extension | .njk |
.liquid |
| Inheritance | {% extends "base.html" %} |
{% layout "base.liquid" %} |
| Default value | {{ x or "y" }} |
{{ x | default: "y" }} |
| Includes | {% include "p.njk" %} |
{% render "p.liquid" %} |
| Safe output | {{ html | safe }} |
{{ html }} (no escaping) |
{% layout "default.liquid" %}
{% block content %}
<h1>{{ page.title }}</h1>
{% endblock %}
Info
Pick the engine you already know. There is no functional reason to prefer one over the other in Poops β the collections, nav, search and image features are engine-agnostic.
Filters
Both engines ship the same built-in filters. The only syntax difference is how arguments are
passed: Nunjucks uses parentheses {{ x | filter("arg") }}, Liquid uses a colon
{{ x | filter: "arg" }}.
| Filter | Does | Example (Nunjucks) |
|---|---|---|
slugify |
string β URL slug | {{ title | slugify }} |
jsonify |
value β JSON string | {{ obj | jsonify }} |
markdown |
Markdown β HTML (GFM) | {{ text | markdown }} |
date |
format a date (dayjs tokens) | {{ post.date | date("MMM D, YYYY") }} |
toc |
table of contents from headings | {{ content | toc }} |
concat |
new array with value appended | {{ items | concat("c") }} |
push |
append to an array in place | {{ items | push("c") }} |
svg |
inline an SVG file | {{ 'icons/logo.svg' | svg }} |
highlight |
syntax-highlight a code string | {{ code | highlight("js") }} |
groupby |
group an array by a field | {{ posts | groupby("date", "year") }} |
srcset |
build a srcset for an image |
{{ 'photo.jpg' | srcset }} |
exif |
EXIF object for an image | {{ 'photo.jpg' | exif }} |
images |
list images in a directory | {{ 'static/img' | images }} |
og |
Open Graph + Twitter card <meta> |
{{ page | og(site) }} |
canonical |
<link rel="canonical"> dedup tag |
{{ page | canonical(site) }} |
jsonld |
schema.org JSON-LD for GEO | {{ page | jsonld(site) }} |
breadcrumb |
visible breadcrumb <nav> trail |
{{ page | breadcrumb(site, relativePathPrefix) }} |
srcset, exif and images need the poops-images
compile cache β see Images & galleries.
Social & structured data (Open Graph, JSON-LD)
Two filters turn a page's front matter into the metadata search engines, generative engines (GEO)
and social platforms read. Drop both in your layout <head>:
{{ page | canonical(site) }}
{{ page | og(site) }}
{{ page | jsonld(site) }}
canonical emits a <link rel="canonical"> with the page's authoritative absolute URL (site.url +
its url) β the dedup signal that stops query-string and duplicate URLs splitting your ranking.
Front matter canonical overrides it (absolute URL, or a path resolved against site.url); the
homepage canonicals to the site root.
og emits Open Graph + Twitter-card <meta> tags for link previews. og:type is article when
the page has a date, else website; it pulls title, description, url, image and
site_name, adds article:* timestamps for posts, and picks summary_large_image when an image is
set. A missing description falls back to the page's auto-excerpt (first paragraph), then
site.description. Set an og object in front matter to add or override any tag (e.g. og:image:alt).
jsonld turns a page's front matter into a schema.org
<script type="application/ld+json"> block β the structured data search and
generative engines (GEO) read. Drop it in your layout <head>:
{{ page | jsonld(site) }}
Liquid: {{ page | jsonld: site }}. The @type auto-detects β BlogPosting
when the page has a date, otherwise WebPage β pulling title, description, url (made
absolute via site.url), date, author, image and more from front matter. description shares
the same page.excerpt β site.description fallback as og. Values are escaped so they can't break
out of the <script>.
Set site.logo and the publisher gains a logo ImageObject (made absolute) β Google Article rich
results require it. On the homepage (a page with no url) a second WebSite block is emitted,
declaring the site name for search results; on nested pages a BreadcrumbList block is auto-appended
(see Breadcrumbs).
site.lang (a page's front-matter lang overrides it) sets the JSON-LD inLanguage on every
block. Reuse it for the language attribute too β <html lang="{{ page.lang or site.lang or 'en' }}"> β
so the declared language and the markup stay in sync.
For full control, set a jsonld object in front matter; its keys merge over (and override) the
defaults, including @type:
---
title: How to brew coffee
date: 2026-01-01
jsonld:
"@type": HowTo
totalTime: PT5M
---
The same object works in your site data, as a site-wide default β worth it when every page is one
type. A docs site is TechArticle, not WebPage:
{
"markup": {
"options": {
"site": { "jsonld": { "@type": "TechArticle" } }
}
}
}
Precedence is defaults β site.jsonld β page.jsonld, so a single page still opts out (a FAQPage
inside a TechArticle site). Note that site.jsonld also overrides the auto-detected BlogPosting
on dated pages β on a site that mixes docs and a blog, set the type per page instead of site-wide.
It merges into the page's own block only; the auto-emitted WebSite and BreadcrumbList blocks are
untouched.
Common @type values
poops picks BlogPosting or WebPage for you; override @type (and add the type's own fields)
via the jsonld object for anything else. The types search and generative engines act on most:
@type |
Use for | Notable extra fields |
|---|---|---|
WebPage |
generic page (poops default) | β |
BlogPosting / Article |
blog posts, articles (auto when date is set) |
headline, datePublished, author |
NewsArticle |
news / press | dateline, datePublished |
HowTo |
step-by-step guides | step[], totalTime, supply, tool |
FAQPage |
a page of Q&As | mainEntity[] (Question β acceptedAnswer) |
QAPage |
a single question thread | mainEntity (Question) |
Product |
product pages | offers (Offer), aggregateRating, brand |
Recipe |
recipes | recipeIngredient[], cookTime, nutrition |
Event |
events | startDate, location, offers |
Course |
courses / lessons | provider, hasCourseInstance |
VideoObject |
pages built around a video | thumbnailUrl, uploadDate, duration |
SoftwareApplication |
apps / tools | applicationCategory, operatingSystem, offers |
Organization |
the site's company/brand entity | logo, sameAs[], contactPoint |
Person |
author / profile pages | jobTitle, sameAs[] |
BreadcrumbList |
breadcrumb trails | itemListElement[] (ListItem); auto-emitted on nested pages |
WebSite |
one site-level block (homepage) | declares the site name; auto-emitted on the homepage |
Full vocabulary at schema.org/docs/full; check what Google supports for rich results. Validate a page with the Rich Results Test or the Schema Markup Validator.
Breadcrumbs
jsonld already gives you the SEO half for free: on any nested page (its url has at least one
folder) it auto-appends a BreadcrumbList block β a Google rich result β with no extra markup. The
trail is derived from the page's URL depth: the site root, each ancestor folder (humanized, e.g.
docs/static-site β Static Site), then the page itself. Item URLs are absolute, so it needs
site.url.
For a visible trail in the page body, add the breadcrumb filter β same crumbs, rendered as a
<nav class="breadcrumb"><ol>:
{{ page | breadcrumb(site, relativePathPrefix) }}
Liquid: {{ page | breadcrumb: site, relativePathPrefix }}. Pass
relativePathPrefix so the links resolve against the current page (localhost in dev, your deployed
path in prod) instead of the absolute domain β the same convention the nav uses. The last crumb is
the current page, rendered as aria-current text rather than a link. Both outputs return nothing on
the homepage or a single-crumb page.
The home crumb is optional. Turn it off (or rename it) site-wide via site.breadcrumb, or
per-page in front matter β front matter wins:
# poops.json β markup.site
breadcrumb:
home: false # drop the leading "Home" crumb
homeLabel: Start # or just rename it
With home: false, top-level pages (only one crumb left) render nothing; nested pages still show
their folder trail. Set breadcrumb: false on a page (or on site) to disable both the visible
trail and the JSON-LD entirely.
Custom engines
engine also accepts a module specifier β an npm package name or a path relative to your
project root β so you can bring your own template engine or extend a built-in one. The module's
default export must be an engine class:
{
"markup": {
"in": "src/markup",
"out": "dist",
"options": { "engine": "poops-shopify" }
}
}
An engine class implements this contract (the two built-ins in
lib/markup/engines/ are the
reference implementations):
export default class MyEngine {
constructor(templatesDir, includePaths, options) {} // options: { autoescape }
get fileExtension() { return '.liquid' } // native template extension
get indexableExtensions() { return new Set(['.html']) } // eligible for search index / nav
get markupExtensions() { return 'html|liquid|md' } // glob alternation of processed extensions
registerFilters({ dateFormat, markupOut }) {}
registerTags(getOutputDir) {}
setGlobal(key, value) {}
removeGlobal(key) {}
async render(templatePath, context) { return 'html' } // templatePath is an absolute path
async renderString(source, context) { return 'html' }
}
Optionally implement replaceOutExtensions(outputPath) to control how source extensions map to
output (the default maps .md / .njk / .liquid to .html; a theme engine might flatten paths
instead).
The easy path is extending a built-in β deep imports are supported for exactly this:
import LiquidEngine from 'poops/lib/markup/engines/liquid.js'
export default class MyEngine extends LiquidEngine {
registerFilters(opts) {
super.registerFilters(opts)
this.engine.registerFilter('shout', (str) => String(str).toUpperCase())
}
}
Note
The specifier resolves against your project's node_modules (or a relative path from the project
root), so a locally linked engine works too. poops-shopify
is a full example β a Shopify Liquid engine that maps templates into a theme directory.
Images
Both engines ship an {% image %} tag that emits a responsive <img> with a
srcset. Image processing is a separate step (see Images & galleries);
the tag just discovers the generated variants and writes correct markup.
Name your variants {name}-{width}w.{ext} (e.g. photo-320w.webp, photo-640w.webp) and call:
{% image 'static/photo.jpg', alt='Hero', sizes='(max-width: 640px) 100vw, 50vw' %}
Output:
<img
src="static/photo-640w.jpg"
srcset="static/photo-320w.webp 320w, static/photo-640w.webp 640w, static/photo-960w.webp 960w"
sizes="(max-width: 640px) 100vw, 50vw"
alt="Hero" loading="lazy" />
The tag prefers avif > webp > original, prepends relativePathPrefix, defaults to
loading="lazy", and falls back to a plain <img> if no variants exist.
Note
If you run poops-images, the tag also reads exact
width/height from its cache to prevent layout shift, and unlocks the exif and images
filters. More in Images & galleries.
Google Fonts
The {% googleFonts %} tag emits Google Fonts <link> tags with preconnect
hints. Pass an array of font names, or objects for weights and italics:
{% googleFonts ["DM Sans", {name: "Poppins", weights: [400, 700], ital: true}] %}
Output:
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans&family=Poppins:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet" />
Font object options: name, weights (e.g. [400, 700]), ital (include italics), display
(defaults to swap).
Note
Liquid syntax has no inline arrays β pass a variable instead: define the array in a data file
(e.g. fonts.json) and call {% googleFonts fonts %}.
Next, get a site building end to end in Build a Static Site.