No results

    Search failed

    npm GitHub

    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 }}
    humanize slug or raw term β†’ display label {{ "static-site" | humanize }}
    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 }}
    description <meta name="description">, escaped {{ page | description(site) }}
    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.

    Escaping: put escape on every value you write into an attribute

    Poops renders with autoescape off, because a static site mostly renders content you wrote, and turning it on would mean | safe on content, on markdown, and on every bundled filter that returns HTML. The cost is that a value you interpolate yourself lands verbatim:

    <meta name="robots" content="{{ page.robots }}">

    Front matter of robots: 'noindex" onload="x' makes that <meta name="robots" content="noindex" onload="x"> β€” the attribute closed by its own content, and a second one appended. Prose does this by accident: one " in a sentence truncates the value to the words before it, on a green build, with nothing to see until someone reads the page's source. Both engines ship escape, and it works whether or not autoescape is on:

    <meta name="robots" content="{{ page.robots | escape }}">

    The rule is per attribute, not per page: href, alt, aria-label, title, content, datetime β€” anything where a " would end the value early. Element text is safer (a " between tags is just a quote) but a < still opens a tag, so escape a title that can contain one.

    The bundled description, og, canonical, jsonld and breadcrumb filters escape what they emit, which is the reason to prefer them over hand-writing those tags.

    Social & structured data (Open Graph, JSON-LD)

    Four filters turn a page's front matter into the metadata search engines, generative engines (GEO) and social platforms read. Drop them all in your layout <head>:

    {{ page | description(site) }}
    {{ page | canonical(site) }}
    {{ page | og(site) }}
    {{ page | jsonld(site) }}

    description emits <meta name="description"> from front matter description, then the page's auto-excerpt, then site.description β€” the same chain og and jsonld read, so the three cannot disagree. Write the tag with this rather than by hand: Poops renders with autoescape off, so content="{{ page.description }}" ships the front matter verbatim, and one " in a sentence closes the attribute and truncates the description to the words before it.

    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 markupExtensions() { return 'html|liquid|md' }       // glob alternation of processed extensions
      get indexableExtensions() { return new Set(['.html']) }  // eligible for collections, search index, nav
      registerFilters({ dateFormat, markupOut }) {}
      registerTags(getOutputDir) {}
      setGlobal(key, value) {}
      removeGlobal(key) {}
      async render(templatePath, context) { return 'html' }    // templatePath is an absolute path
    }

    That is the whole required surface. Five more are feature-detected β€” invalidate, clearCache and pagesDependingOn for incremental watch rebuilds, replaceOutExtensions to remap output extensions, isMarkupSource to claim a file the glob would not call markup. Implement what your engine can and skip the rest; the Engine API has the lifecycle and the contract for each.

    Warning

    The built-ins also carry fileExtension and renderString, but the pipeline never calls either. Don't implement them and don't rely on them.

    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.