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 as site.*.
  • 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, relativePathPrefix always resolves to this value (trailing slash ensured) instead of the page-depth .//../. Useful when deploying under a subdirectory, e.g. "/blog" for domain.com/blog/. The --base-url CLI 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 .html files 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.

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.