Releases: stamat/poops
Release list
v1.9.5
Added
-
A glob-matched
index.*is named after its directory. This one is for
building libraries of components. One directory per component is the obvious
way to lay a library out — the accordion's markup, styles and script live
together, and each one is calledindex. Point a glob at them and the naming
falls apart, differently for each pipeline. Styles compiled every match to
<out>/index.css, so the last directory to build won and the rest were
overwritten in silence. Scripts fared better but not well: esbuild nests entry
points from different directories under their common ancestor, so you got
dist/accordion/index.jswhere you wanteddist/accordion.js.{ "scripts": { "in": "src/elements/*/index.{js,mjs,cjs,jsx,ts,tsx}", "out": "dist/js/" }, "styles": { "in": "src/elements/*/index.{scss,sass,css}", "out": "dist/css/" } }src/elements/accordion/index.scss → dist/css/accordion.css src/elements/accordion/index.ts → dist/js/accordion.js src/elements/tabs/index.scss → dist/css/tabs.css src/elements/tabs/index.ts → dist/js/tabs.jsTwo globs, and the whole library builds to a flat set of bundles named after
the components — add a directory, get a bundle, no config change.The rename only applies to entries a glob matched. A literal
"in": "src/index.js"still writesdist/index.js, and
"in": "src/scss/index.scss"still writesdist/index.css— you named that
entry point yourself, and moving it tosrc.jsorscss.cssbecause of a
rule about globs would be a rename you never asked for. Same for an explicit
outfile path: that always wins.The name is placed relative to the glob's static prefix — everything
before its first magic segment — which is what keeps the flat case flat
without making same-named components collide.src/elements/*/index.scss
has the prefixsrc/elements, shared by every match, so nothing is left to
nest under. Widen the glob and the part it no longer pins down is kept:"src/*/accordion/index.scss" src/blocks/accordion/index.scss → dist/css/blocks/accordion.css src/elements/accordion/index.scss → dist/css/elements/accordion.cssTwo
accordiondirectories, two stylesheets, no overwrite — and no config
to keep in sync, because the prefix is read off the pattern rather than off
whatever happened to match. Add or remove a component and the layout of the
rest doesn't move. -
Brace patterns count as globs.
hasMagicdoesn't treat braces as magic by
default, so a pattern with alternates but no wildcard —
src/elements/accordion/index.{scss,sass,css}— was read as a literal file
path, and failed withEntry does not exist:naming a file that was never
going to exist. It now resolves as the glob it obviously is, which is what
makes "whichever extension this component happens to use" expressible for a
single component and not only across a*.The watcher learned the same thing. A brace pattern in a
copy,imagesor
markupinis now matched as a glob when deciding whether a changed file
belongs to that task, instead of falling through to a path-segment compare
that could never match it.
Full Changelog: v1.9.4...v1.9.5
v1.9.4
Fixed
-
Watcher events are coalesced — a multi-file burst triggers one rebuild, not
one per file. Chokidar fires one event per written file, but a single build
rarely writes a single file. A styles entry with sourcemaps and minify on
writes three —.css,.css.map,.min.css— and a post-compileexec
step that rewrites the output makes it four. If those land in a directory
another instance watches (a library'sdist/inside a docs site'scopy
source, the setup--quietwas added for), every one of those events ran
the full branch: fiveCopied N pathspasses and three or four style
recompiles per save, all doing the same work on the same files.The copy and style branches now collect events over a trailing 300ms window
— sized to outlive the 150msawaitWriteFinishsettle between files of one
burst — and run once when the burst goes quiet. The window keeps the paths,
so the per-file behavior survives: a css-only burst still hot-swaps each
stylesheet in place, anything else still escalates to one full reload.Browser refreshes were already folded this way —
reload()has debounced
since the livereload server stopped fs-watching. Now the work feeding the
refresh is folded too. One save, one compile, one copy, one refresh.
Full Changelog: v1.9.3...v1.9.4
v1.9.3
Added
-
--quiet/-qhides the banner. Running one Poops instance, the header
and the address block are the useful part of startup. Running several at once
— a library build in the repo root and its docs site undersite/, each with
its ownpoops.json— they stop being useful: three headers, three terminal
bells, and twoLocal serverblocks whose ports you already know, scrolling
past before the first compile line lands.poops -q & poops -q -c site/poops.jsonWhat
-qremoves is exactly the startup furniture:💩 Poops — v1.9.3 ← header, and its terminal bell ----------------- 🏠 Local server: … ← the address block 🛜 Network : … 🔃 LiveReload : …Everything else prints as before —
[style] Compiled:,[markup] Compiled:,
warnings, errors, the non-zero exit on a failed build. The flag is deliberately
not a log level: in a parallel run the compile lines are the one thing you're
watching, and the tags already tell you which stage spoke.It composes with the other flags, so it fits a CI build the same way it fits a
split terminal:poops --build --quiet --base-url /blog
Ports are still resolved and still auto-incremented when one is taken —
-q
only stops them being announced. If you need to see which port an instance
landed on, drop the flag for that one instance and keep it on the rest.
Fixed
justMinifiedno longer throwsENOENTon watch rebuilds. The
post-minify step always deleted the unminified output, but watch rebuilds
hand the compiled code to the minifier in memory — the file was never on
disk, and every rebuild of ajustMinifiedentry printed anunlink
ENOENT stack trace. Harmless but loud. The delete now only runs when the
file actually exists.
Full Changelog: v1.9.2...v1.9.3
v1.9.2
Fixed
-
The dev server resolves extensionless URLs. GitHub Pages serves
/a/b
froma/b.htmlwithout touching the URL. The local server didn't, so a link
written as/changelog/v1.9.1worked in production and 404'd on
localhost:4040— the one place you'd have caught it. Both agree now:/a/b → a/b.html 200, URL stays /a/b /a/b → a/b/index.html 301 to /a/b/, then the indexThe directory redirect was already there; the file fallback is the new part,
and it only fires when neither a file nor a directory matches. Relative
assets on those pages need nothing special — an extensionless URL sits at
the same depth as the file behind it, so../css/styles.min.cssresolves
the same for/changelog/v1.9.1and/changelog/v1.9.1.html. -
404.htmlloads its assets at any depth. The 404 page is the one file
served from a path it doesn't live at: it sits at your site root but answers
for/a/b/c/anything. Its relative asset paths —./css/styles.min.css—
then resolved against/a/b/c/, so a miss at the root rendered fine and a
miss two levels down rendered unstyled. The server now pins them:<head> <base href="/" /> <meta charset="utf-8" /> </head>
Injected only when the page doesn't already declare its own
<base>, and
only in the response — your built404.htmlis untouched on disk, which
matters when you publish under a project path like/poops/. -
serve.base: "/"no longer 404s the whole site. The server keeps every
request inside its base directory by resolving the path and checking it
still starts with that base. A base of/joins to<cwd>/— with the
trailing separator — so the check compared against<cwd>//and nothing
ever matched. Every URL, including/, came back 404. The base is now
normalized before anything is joined to it, so a trailing separator means
what you'd expect. Traversal attempts are still rejected the same way.
Full Changelog: v1.9.1...v1.9.2
v1.9.1
Fixed
-
includePathsno longer breaks the markup glob. Top-levelincludePaths
is a sass/esbuild load path, but it was also folded into the exclude list the
markup compiler globs with — and that list fills a single extglob segment:!(node_modules|.git|.svn|.hg|_*)/**/*.+(md|html)Any entry with a separator in it made the whole pattern match nothing. A site
that legitimately needs"includePaths": ["../node_modules"]— node_modules
at the repo root, docs built from a subdirectory — compiled zero pages, exited
0, and said so only asCompiled: 0 file. Entries with a separator are now
filtered out of the excludes; bare directory names still exclude as before.
Changed
-
The example docs consume
poops-docs-theme
instead of local copies. The docs layout, nav partial, stylesheet, and
script are now an npm dependency — the first real user of the package
templates added in v1.9.0. Front matter points at the
package, and the theme's sources compile straight out ofnode_modules:{ "scripts": [{ "in": "node_modules/poops-docs-theme/src/docs.ts", "out": "example/dist/js/docs.js" }], "styles": [{ "in": "node_modules/poops-docs-theme/scss/docs.scss", "out": "example/dist/css/docs.css" }] }--- layout: poops-docs-theme/docs ---
Four files left the repo and nothing about the docs changed on screen — which
was the point.
Full Changelog: v1.9.0...v1.9.1
v1.9.0
Added
-
Package templates resolve from
node_modules. A layout or partial can now
live in an installed npm package and be referenced by package name, so a
shared theme ships as a dependency instead of files copied into every project.
Anything with a/is resolved from the consumer'snode_modules; a bare
name (no/) stays project-only, so the common path never touches the
resolver.{% raw %}{% extends "my-theme/layout.html" %} {% block content %} <h1>{{ page.title }}</h1> {% endblock %}{% endraw %}
Or from front matter, so the page carries no template syntax at all:
--- layout: my-theme/layout ---
- Nunjucks and Liquid both. The Nunjucks loader falls back to
require.resolveforpkg/template.html; the Liquid engine adds every
ancestornode_moduleson the path to its include roots — so hoisted,
scoped, and pnpm installs all resolve, and liquidjs's containment guard
stays intact. - Project templates always win. Package roots are appended last, so a
same-named template in your own project shadows the package one. - Bundled filters stay global.
toc,breadcrumb,og,canonical, …
are engine-global, so package templates use them with no extra wiring.
A theme package must not restrict subpaths with
exports(or must map its
templates explicitly, e.g."exports": { "./*": "./*" }), and should
reference its own partials relatively —{% raw %}{% import "./nav.html" as nav %}{% endraw %},
not the bare name. See Templating HTML → Templates from an npm package. - Nunjucks and Liquid both. The Nunjucks loader falls back to
Full Changelog: v1.8.0...v1.9.0
v1.8.0
Added
- Native
node:httpdev server. The local server no longer depends on
connect+serve-static, and free-port selection no longer depends on
portscanner. Three dependencies gone (five packages, still 0 audit
vulnerabilities). The replacement handler keeps the behavior you rely on and
adds a few things the old stack didn't:Rangerequest support — single-range206 Partial Content(and416
for unsatisfiable ranges), so<video>/<audio>seeking works against the
dev server.- directory →
index.html, with a/dir→/dir/redirect, - path-traversal containment and null-byte rejection,
405for non-GET/HEADmethods,- your existing
404.htmlfallback, unchanged.
- Unknown config-key warnings. A typo in a top-level
poops.jsonkey
(stlyes,marckup, …) was silently ignored before. Startup now warns per
unknown key so the most common config mistake surfaces immediately. - Declared Node floor.
package.jsonnow sets
"engines": { "node": ">=20" }, and CI runs a Node 20/22/24 × ubuntu/windows
matrix so the real floor is tested, not assumed.
Changed
- Search keywords are Unicode-aware.
extractKeywordsno longer strips all
non-Latin characters — non-English sites now get real keywords instead of
empty ones (regex switched to\p{L}\p{N}with theuflag).
Fixed
- Liquid
{% raw %}{% image %}{% endraw %}no longer breaks on commas in
values. A standard responsivesizesvalue —
sizes: "(max-width: 600px) 100vw, 50vw"— was split mid-value by a naive
split(','), producing broken attributes. Argument parsing is now
quote-aware, so the documented responsive-image examples work in Liquid as
they already did in Nunjucks. - Index artifacts write to subdirectory outputs. Search index, sitemap, nav
androbots.txtnowmkdir -ptheir target directory first, so
"output": "meta/nav.json"no longer throwsENOENT. - Path matching respects segment boundaries. An
in: "src"config no longer
false-matchesmysrc2/file.jsordist/src-maps/x. This previously could
skip a rebuild — or, on the copy path, delete the wrong output file. - Windows dev fixes. CSS hot-swap paths are now posix-normalized (live CSS
swap stopped degrading on Windows), andnpm testis cross-platform (no more
POSIX-onlyNODE_OPTIONS=prefix). - Better data-file diagnostics. A YAML/JSON parse error now logs the real
message instead of a misleading "Data file not found", and two data files
with the same basename warn before the global collides. - No reactor rebuild loop. The chokidar watcher now ignores the
*.reactor-tmp-*/*.reactor-bundle-*scratch files, closing a potential
self-sustaining rebuild loop. - Clean startup failures. Errors during
startServer()and the
livereload-only path now exit with a message instead of surfacing as an
unhandled promise rejection (a hard crash on current Node). - Free-port selection can't return a busy port. When every candidate port in
the range is occupied, the server now exits with a clear "no free port in
range" error instead of crashing later withEADDRINUSE.
Full Changelog: v1.7.1...v1.8.0
v1.7.1
-
page.filePathfor "Edit on GitHub" links. Every page now carries
page.filePath— its source file's path relative to your project root, with
posix separators (e.g.src/markup/docs/index.md). That is exactly the path
GitHub's editor expects, so an edit link is one line in your layout:{% raw %}{% set repoUrl = site.repo or package.homepage %} {% if page.filePath and repoUrl %} <a href="{{ repoUrl }}/edit/{{ site.branch or 'main' }}/{{ page.filePath }}">✏️ Edit this page on GitHub</a> {% endif %}{% endraw %}
Set
repoandbranchin yoursitedata; they fall back to
package.homepageandmain. The field mirrors thefilePathalready on
collection items, so both read the same way. You can't rebuild this path from
page.url— that's the output URL (.html, andindex.mdcollapses to a
directory), so it never reverses to the.mdsource.
Full Changelog: v1.7.0...v1.7.1
v1.7.0
Tags & categories — taxonomies
Declare which front-matter fields become taxonomies on the collection, alongside paginate/sort:
---
title: Changelog
collection: true
paginate: 10
taxonomies:
- name: tags # front-matter field to group on
path: tag # URL segment (defaults to name); use "tag" for a singular URL
paginate: 5 # per-term page size (defaults to the collection's paginate)
---Shorthand: a bare string (taxonomies: [tags, category]) uses the field name as the URL segment and inherits the collection's paginate. Poops then writes a landing page per term — changelog/tag/feature/, blog/category/release/ — paginated, listed in the sitemap, kept out of the search index and nav.
Term pages render with the collection's own index template — no extra file. Branch on activeTerm to show a term view, and build tag links anywhere from collection.taxonomies:
{% raw %}{% if changelog.activeTerm %}
<h1>Tagged {{ changelog.activeTerm | humanize }}</h1>
{% for post in changelog.pageItems %}
<p><a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a></p>
{% endfor %}
{% pagination changelog %}
{% endif %}{% endraw %}Full guide: Tags & categories.
Array-aware groupby
The groupby filter now splits array-valued fields: a post with tags: [js, css] lands under both the js and css groups (previously the whole array was one key). This is what makes multi-tag taxonomies work, and it's just as useful in a template:
{% raw %}{% for group in blog.items | groupby("tags") %}
<h2>{{ group.key | humanize }}</h2>
{% endfor %}{% endraw %}New humanize filter
The inverse of slugify: "static-site" → "Static Site". Handy for turning a slug or a raw tag into a display label. Available in both Nunjucks and Liquid.
Distinct titles for paginated & term pages
Paginated pages no longer all share the landing page's <title> (and its og/jsonld metadata). Pages 2..N get a — Page N suffix, and each term page gets a Tag: Feature title — so search engines and social cards see a distinct title per page.
Localizable pagination labels
The — Page N suffix and the {% raw %}{% pagination %}{% endraw %} tag's Previous/Next/of wording default to English but localize site-wide under site.pagination:
site:
pagination:
title: "{title} — Seite {n}" # {title}, {n}, {total} tokens
prev: Zurück
next: Weiter
of: vonAutomatic breadcrumbs for term pages
The breadcrumb and jsonld filters resolve term pages to a Home › Collection › Tag: Term trail automatically — skipping the non-page tag/category URL segment and labelling the last crumb with the taxonomy. Nothing to configure.
Everything above is additive — existing sites build unchanged.
Full Changelog: v1.6.0...v1.7.0
v1.6.0
One release, several ways to stop hand-authoring the boilerplate search engines, social platforms, feed readers and LLMs consume — all driven from front matter you already write.
SEO metadata — og and jsonld
Two new filters generate the metadata that search engines, generative engines (GEO) and social platforms read — straight from your front matter, no per-page boilerplate. Drop both in your layout <head>:
{% raw %}{{ page | og(site) }}
{{ page | jsonld(site) }}{% endraw %}Open Graph & Twitter cards — og
The og filter emits Open Graph and Twitter-card <meta> tags so links to your pages unfurl into rich previews in chat apps and social feeds. og:type is article when the page has a date, otherwise website. It pulls title, description, url (made absolute via site.url), image, site_name and locale from front matter and site data, adds article:published_time / article:modified_time / article:author for posts, and sets twitter:card to summary_large_image when there's an image. Attribute values are escaped.
Set an og object in front matter to add or override any tag:
---
title: My post
date: 2026-01-01
image: static/cover.jpg
og:
"og:image:alt": Cover illustration
---JSON-LD structured data — jsonld
The jsonld filter turns front matter into a schema.org JSON-LD <script type="application/ld+json"> block — structured data search and generative engines read to understand your content. Liquid uses the colon syntax: {% raw %}{{ page | jsonld: site }}{% endraw %}.
The @type auto-detects: BlogPosting when the page has a date, otherwise WebPage. These front-matter fields are used when present:
| Front matter | JSON-LD |
|---|---|
title |
name / headline |
description (or site.description) |
description |
url (made absolute via site.url) |
url |
date |
datePublished |
updated (or date) |
dateModified |
author string or { name } (or site.author) |
author (Person) |
image |
image |
lang (or site.lang) |
inLanguage |
wordcount |
wordCount |
site.title |
publisher (Organization) |
Article-only fields (headline, dates, author, wordCount) are added only for the BlogPosting case. Front-matter values are escaped so a stray </script> in a title can't break out of the tag.
The same lang that drives inLanguage also declares the document language — wire {% raw %}<html lang="{{ page.lang or site.lang or 'en' }}">{% endraw %} in your layout so the markup and the structured data agree. site.lang sets the default; a page's front-matter lang overrides it.
Set a jsonld object in front matter — its keys merge over (and override) the generated defaults, including @type. Everything from a HowTo to an FAQPage to a Product:
---
title: How to brew coffee
date: 2026-01-01
jsonld:
"@type": HowTo
totalTime: PT5M
---This very page emits both — an article Open Graph set and a BlogPosting JSON-LD block. View source and look in the <head>.
Breadcrumbs
Breadcrumbs land in two forms, both derived from a page's URL depth — no nav tree wiring, no per-page boilerplate.
The SEO half is automatic
The jsonld filter above now auto-appends a schema.org BreadcrumbList block on any nested page (its url has at least one folder). It's a Google breadcrumb rich result with zero extra markup:
{% raw %}{{ page | jsonld(site) }}{% endraw %}The trail is the site root, each ancestor folder (humanized — docs/static-site → Static Site), then the page itself. Item URLs are absolute, so it needs site.url (same requirement as canonical). Sits right alongside the existing homepage WebSite block.
A visible trail — breadcrumb
For a breadcrumb people can see and click, add the new breadcrumb filter in your body. Same crumbs, rendered as a {% raw %}<nav class="breadcrumb"><ol>{% endraw %}:
{% raw %}{{ page | breadcrumb(site, relativePathPrefix) }}{% endraw %}Liquid uses the colon syntax: {% raw %}{{ page | breadcrumb: site, relativePathPrefix }}{% endraw %}.
Passing relativePathPrefix matters: the links resolve against the current page — localhost while you develop, your deployed path in production — instead of jumping to the absolute domain. It's the same convention the nav and header links already use. The last crumb is the current page, rendered as aria-current text rather than a link. Both the JSON-LD and the visible trail return nothing on the homepage or a single-crumb page. Style the .breadcrumb however you like.
Optional home crumb
The leading "Home" crumb is on by default. Drop it 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 itWith home: false, top-level pages fall to a single crumb and render nothing, while nested pages still show their folder trail — handy for a blog where you want Blog › Post, not Home › Blog › Post. Set breadcrumb: false on a page (or on site) to switch off both the visible trail and the JSON-LD entirely.
The docs pages and these changelog posts render a live trail — look just above the content, and view source for the BreadcrumbList in the <head>.
Auto RSS / Atom feeds from collections
Collections already know your posts, their dates and their descriptions. Now they can emit a subscription feed with a single config line — no hand-authored XML template to keep in sync.
Point it at a collection
{% raw %}{
"markup": {
"options": {
"feed": { "collection": "changelog", "output": "changelog/feed.rss" }
}
}
}{% endraw %}That writes changelog/feed.rss — the collection's posts newest-first by date, with the channel title, description, author and language taken from your site data. Item links, guids and the atom:link rel="self" are made absolute via site.url; each <description> uses the post's description, falling back to its auto-excerpt. Posts marked robots: noindex are left out, matching the sitemap.
Advertise it in your layout <head> so browsers and readers discover it:
{% raw %}<link rel="alternate" type="application/rss+xml" href="{{ site.url }}/changelog/feed.rss">{% endraw %}Options
collection— the collection to feed from. Omit it to emit a feed for every collection.output— a bare filename (defaultfeed.xml) lands in the collection's own folder; a slashed path is written as-is.type—"rss"(default) or"atom".limit— item cap, newest first (default 20).title/description/author/lang— override thesitedefaults.content— settrueto embed each post's full article in the feed (see below).
Shorthand "feed": true (or a filename string) turns on RSS for every collection; an array of these objects generates several at once — say an RSS and an Atom for the same posts.
Full post content — content: true
By default items carry a <description> only. Set content: true and each post's whole article rides along — RSS <content:encoded>, Atom <content type="html"> — so readers show the full post without a round-trip to the site:
{% raw %}{ "feed": { "collection": "changelog", "output": "changelog/feed.rss", "content": true } }{% endraw %}The HTML is the post's Markdown source rendered to article-body HTML — no layout, nav or footer chrome, just the content. Only .md/.markdown posts get it (a .njk/.liquid post has no clean body to extract and falls back to <description> alone). This very feed ships it — subscribe and read the whole changelog in your reader.
This changelog is a collection, so the feed you may already subscribe to at /changelog/feed.rss is now generated — the old hand-written template is gone.
An index for LLMs — llms.txt
The same page data that drives your sitemap now writes an llms.txt — a Markdown index of your pages that LLMs and generative engines read to understand a site. Point the option at a filename and it lands in your output dir:
{% raw %}{
"markup": {
"options": {
"llms": { "output": "llms.txt", "full": true }
}
}
}{% endraw %}You get an # H1 title, a > blockquote summary, then - [title](url): description links grouped by URL folder — the first folder becomes a ## section, a second nests as a ### subsection. Collection sections are ordered newest-first by date; site.url makes the links absolute. A string ("llms": "llms.txt") is the shorthand; the object form also takes title, description, sectionTitle and an intro path — a Markdown file dropped in verbatim for free-form context you author for LLMs.
The whole corpus — full: true
llms.txt is the map; full writes the territory. Set "full": true and Poops also emits a companion named after output with a -full suffix (llms.txt → llms-full.txt) — or pass a filename to set it yourself — every page concatenated into one file an LLM can ingest whole. The file opens with a # Full Documentation Archive for {title} header, a one-line intro naming the site and a > blockquote of the description so a whole-file ingest starts with context, then each page becomes an # title (its own leading H1 if it has one) + URL: line + its body, joined by ---. Set fullIntro to a Markdown file path for your own preamble after that header — the full counterpar...