# Full Documentation Archive for πŸ’© Poops This file contains the complete Markdown documentation for πŸ’© Poops. > A niche bundler for design-driven micro-sites and blogs. This file is the full text of the Poops documentation β€” every page's Markdown concatenated in one place so an LLM can ingest the whole corpus in a single pass. For the link index (a shorter map of the same pages), see [`llms.txt`](llms.txt). Pages are ordered as they appear on the site; each is preceded by its title and canonical URL. Code samples are shown as authored β€” some contain template tags (`{% … %}`, `{{ … }}`) that the site renders at build time. --- # Migrating from Jekyll URL: https://stamat.info/poops/docs/migrating-from-jekyll.html Poops is Jekyll-inspired, so most of the move is renaming directories and deleting a Gemfile. Four things are not a rename, and they are the reason a migration stalls halfway β€” read these first, then the mapping tables. | The thing | What happens | What you do | | --- | --- | --- | | **`permalink`** | Not supported. A page's output path mirrors its source path, always: `src/markup/blog/hello.md` β†’ `dist/blog/hello.html` | Move the files to where the URLs should be. Keep old URLs alive with redirects on the host, or accept the change | | **Dates in `_posts` filenames** | Not parsed. `2024-01-05-hello.md` becomes `2024-01-05-hello.html`, and the post has no date | Rename the files and put `date:` in front matter β€” the build warns on any post without one and falls back to mtime, which a CI checkout resets | | **Liquid dialect** | Poops' Liquid is [LiquidJS](https://liquidjs.com/), not Jekyll's Ruby Liquid. `{% raw %}{% include %}{% endraw %}`, `{% raw %}{% highlight %}{% endraw %}` and Jekyll's filter set differ | Port the tags in the table below, or switch the site to Nunjucks while you are in there | | **Gem plugins** | There is no plugin API | The common ones are config keys β€” see the plugin table. Anything else has to become a build step or go | Everything else is mechanical. ## Directories Jekyll's layout, and where each part lands: | Jekyll | Poops | Note | | --- | --- | --- | | `_config.yml` | `poops.json` | One file, all pipelines. Not YAML β€” JSON with `$schema` completion | | `_layouts/` | `_layouts/` under `markup.in` | Underscore directories are never output. Add it to `includePaths` | | `_includes/` | `_partials/` (any name) under `markup.in` | Also on `includePaths`; `{% raw %}{% render "site-header.liquid" %}{% endraw %}` | | `_data/` | `markup.options.data` | An explicit list of files: `["_data/links.json", "_data/authors.yaml"]` | | `_posts/` | any direct subdirectory of `markup.in` | It becomes a [collection](static-site/blog-collections); the directory name is the collection name | | `_drafts/` | `published: false` in front matter | The page is skipped and kept out of the collection | | `_sass/` | anywhere; `styles[].in` points at your entry | Dart Sass, with `includePaths` for the load path | | `assets/` | `copy`, or `styles`/`scripts` entries | Static files get copied; sources get compiled | | `_site/` | `markup.out` | Whatever you name it β€” `dist` in these docs | ## Config | `_config.yml` | `poops.json` | | --- | --- | | `title`, `description`, `author` | `markup.options.site.title`, `.description`, … β€” anything under `site` reaches every template | | `url` | `markup.options.site.url` β€” used by the `canonical`, `og` and `jsonld` filters and by `sitemap.xml` | | `baseurl` | `markup.options.baseURL`, or the `--base-url` flag in CI. Leave it unset and paths stay relative β€” [see deploying](deploying) | | `collections:` | `markup.options.collections`, or `collection: true` in the directory's index front matter | | `paginate: 10` | `paginate: 10` on the collection | | `defaults:` (front-matter defaults) | **no equivalent** β€” set the field per page, or read `site` in the layout as the fallback | | `exclude:`/`include:` | underscore-prefixed directories are excluded; there is no include list | | `markdown:`/`kramdown:` | fixed: [marked](https://marked.js.org/) with GFM, alerts, footnotes, emoji and build-time syntax highlighting | A minimal blog, whole: ```json { "markup": { "in": "src/markup", "out": "dist", "options": { "engine": "liquid", "site": { "title": "My Site", "url": "https://example.com" }, "includePaths": ["_layouts", "_partials"], "data": ["_data/links.json"], "sitemap": "sitemap.xml", "feed": { "collection": "blog", "out": "feed.rss" } } }, "styles": [{ "in": "src/scss/index.scss", "out": "dist/css/styles.css", "options": { "minify": true } }], "watch": ["src"], "livereload": true } ``` ## Front matter | Jekyll | Poops | | --- | --- | | `layout: default` | same β€” resolved from `includePaths`, or `package/layout` from `node_modules` | | `title`, `description` | same | | `date` | same, and now **required** on posts you care about ordering | | `published: false` | same | | `categories`, `tags` | any field becomes a [taxonomy](static-site/blog-collections) when the collection declares it: `taxonomies: [tags]` | | `permalink` | **gone** β€” the file path is the URL | | `excerpt_separator` | **gone** β€” `page.excerpt` is the first prose paragraph, capped at 160 characters | | `sitemap: false` | `robots: noindex` β€” drops the page from the sitemap and `llms.txt`, keeps it in your on-site search index | ## Liquid, ported Both engines are available; `engine: "liquid"` keeps your templates closest. The dialect still moves: | Jekyll | Poops | | --- | --- | | `{% raw %}{% include site-header.html %}{% endraw %}` | `{% raw %}{% render "site-header.liquid" %}{% endraw %}` β€” LiquidJS names the file with quotes and an extension | | `{% raw %}{{ content }}{% endraw %}` in a layout | `{% raw %}{% block content %}{% endblock %}{% endraw %}` β€” in **both** engines. Poops wraps the page body in a `content` block and the layout declares where it goes; a Liquid layout that still says `{% raw %}{{ content }}{% endraw %}` renders empty | | `{% raw %}{% for post in site.posts %}{% endraw %}` | `{% raw %}{% for post in blog.items %}{% endraw %}` β€” every collection is a global named after its directory | | `{% raw %}{{ site.baseurl }}/css/styles.css{% endraw %}` | `{% raw %}{{ relativePathPrefix }}css/styles.css{% endraw %}` β€” correct at any depth and under any deploy path | | `{% raw %}{% highlight js %}…{% endhighlight %}{% endraw %}` | same tag, quoted language: `{% raw %}{% highlight 'javascript' %}…{% endhighlight %}{% endraw %}`. Markdown fences are highlighted with no tag at all | | `{% raw %}{{ post.date \| date: "%b %-d, %Y" }}{% endraw %}` | `{% raw %}{{ post.date \| date: "MMM D, YYYY" }}{% endraw %}` β€” [dayjs](https://day.js.org/) tokens, with a site-wide `dateFormat` default | | `{% raw %}{{ page.content \| strip_html \| truncatewords: 30 }}{% endraw %}` | `{% raw %}{{ page.excerpt }}{% endraw %}` | | `{% raw %}{% seo %}{% endraw %}` (jekyll-seo-tag) | `{% raw %}{{ page \| og(site) }}{% endraw %}`, `{% raw %}{{ page \| canonical(site) }}{% endraw %}`, `{% raw %}{{ page \| jsonld(site) }}{% endraw %}` | ## Plugins | Gem | Poops | | --- | --- | | `jekyll-feed` | `markup.options.feed` β€” RSS or Atom, one key | | `jekyll-sitemap` | `markup.options.sitemap` | | `jekyll-seo-tag` | the `og`, `canonical` and `jsonld` filters | | `jekyll-paginate` | `paginate` on the collection, plus the `{% raw %}{% pagination %}{% endraw %}` tag | | `jekyll-archives` | `taxonomies` on the collection β€” term pages, paginated, in the sitemap | | `jekyll-assets` / a separate webpack | `styles`, `scripts`, `postcss` β€” the reason to move | | `jekyll-redirect-from` | **nothing** β€” do redirects at the host | | A theme gem | one theme exists, [`poops-docs-theme`](https://github.com/stamat/poops-docs-theme), and it is a devDependency, not a gem | ## The order that works 1. **Copy the site into `src/markup/`.** Rename `_includes` to `_partials` if you like; the name is yours as long as it starts with an underscore and is on `includePaths`. 2. **Rename `_posts` to the URL you want** β€” `blog/` β€” and strip the date prefixes from filenames. 3. **Add `date:` to every post's front matter.** The build lists the ones you missed. 4. **Write `poops.json`** from the config table above, with `"engine": "liquid"`. 5. **Run `poops -b` and read the errors.** Unknown tags are the Liquid dialect; empty output is usually a `site.posts` that is now `blog.items`. 6. **Delete `Gemfile`, `Gemfile.lock`, `_config.yml`, `_site/`.** Add `dist/` to `.gitignore`. 7. **Replace the GitHub Pages branch build with [a workflow](deploying)** β€” Pages no longer builds the site for you, and this is the step that catches people after the site already works locally. > [!NOTE] > These pages are a mapping, not a script. Nothing here was run against an existing Jekyll site β€” > the Poops side of every row is documented behaviour, the Jekyll side is from > [its docs](https://jekyllrb.com/docs/), and your site will have a case neither of us thought of. ## When not to migrate The site is Markdown and layouts with no assets to build, GitHub builds it for free on push, and the theme you use is a gem. That is Jekyll working as designed β€” see [the comparison](comparisons/jekyll-eleventy) for the rows it wins outright. --- # Migrating from Eleventy URL: https://stamat.info/poops/docs/migrating-from-eleventy.html If your Eleventy site is Nunjucks, the templates mostly move as they are β€” same syntax, same `{% raw %}{% include %}{% endraw %}`, same filters where they overlap. What does not move is `eleventy.config.js`, because Poops has no JavaScript config to move it into. That is the trade in one sentence: **you lose the programmable build, you stop maintaining a bundler beside it.** Read the four hard stops first. | The thing | What happens | What you do | | --- | --- | --- | | **`permalink`** | Not supported, templated or otherwise. Output path mirrors source path | Put the files where the URLs go. `about/index.njk` β†’ `dist/about/index.html` still works, because it is a real directory | | **`addFilter` / `addShortcode`** | No API to register them | Use the [bundled filters and tags](config-reference), or move the logic into the template. A whole template language can be [a custom engine](engine-api); a single filter cannot | | **`addCollection`** | Collections are directories, not queries | A collection is a direct subdirectory of `markup.in`. Cross-cutting queries β€” "everything tagged X across the site" β€” become [taxonomies within one collection](static-site/blog-collections), or they go | | **`eleventyComputed`** | No computed data layer | Compute in the template, or put the value in front matter | ## Config, key by key Everything in `eleventy.config.js` that has an equivalent: | Eleventy | Poops | | --- | --- | | `dir.input` | `markup.in` | | `dir.output` | `markup.out` | | `dir.includes`, `dir.layouts` | `markup.options.includePaths` β€” an array, so `_layouts` and `_partials` can both be on it | | `dir.data` + `_data/*.js` | `markup.options.data` β€” an explicit list of JSON/YAML files. **JavaScript data files have no equivalent** | | `addPassthroughCopy("img")` | `"copy": [{ "in": "src/img", "out": "dist/img" }]` | | `addGlobalData("site", …)` | `markup.options.site` | | `setTemplateFormats` | fixed: `.html`, `.md`, and `.njk` or `.liquid` | | `addPlugin(pluginRss)` | `markup.options.feed` | | `addPlugin(EleventyHtmlBasePlugin)` | `{% raw %}{{ relativePathPrefix }}{% endraw %}` in templates, plus `baseURL` or `--base-url` when you [deploy under a subpath](deploying) | | `eleventy-plugin-vite`, or your own bundler | `styles`, `scripts`, `postcss` β€” top-level keys in the same file | | `addFilter`, `addShortcode`, `addTransform`, `addCollection` | **no equivalent** | The whole thing, for a blog that was Eleventy plus Vite: ```json { "markup": { "in": "src", "out": "dist", "options": { "engine": "nunjucks", "site": { "title": "My Site", "url": "https://example.com" }, "includePaths": ["_layouts", "_partials"], "data": ["_data/site.json"], "sitemap": "sitemap.xml", "searchIndex": "search-index.json", "feed": { "collection": "blog", "out": "feed.rss" } } }, "styles": [{ "in": "src/scss/index.scss", "out": "dist/css/styles.css", "options": { "minify": true } }], "scripts": [{ "in": "src/js/main.ts", "out": "dist/js/main.js", "options": { "minify": true, "format": "iife" } }], "copy": [{ "in": "src/img", "out": "dist/img" }], "watch": ["src"], "livereload": true } ``` ## Collections: queries become directories This is the conceptual change, and it is worth understanding before you move files. In Eleventy, `tags: post` in front matter puts a page into `collections.post` no matter where the file lives. In Poops, **the directory is the collection** β€” every page under `src/blog/` is in the `blog` collection, and the index file declares it: ```yaml --- title: Blog collection: true paginate: 10 sort: date taxonomies: [tags] --- ``` | Eleventy | Poops | | --- | --- | | `tags: post` in each file | put the file in `blog/`; the collection is automatic | | `collections.post` in templates | `blog.items` β€” a global named after the directory | | `collections.post` filtered by a second tag | `tags: [release]` in front matter + `taxonomies: [tags]` on the collection, which also builds `blog/tags/release/` as a real page | | `addCollection` with a custom sort | `sort: date` or `sort: { by: "title", order: "asc" }` | | `pagination: { data, size, alias }` in front matter | `paginate: 10` on the collection; the index template reads `blog.pageItems`, `blog.pageNumber`, `blog.totalPages` | | `{% raw %}permalink: "page-{{ pagination.pageNumber }}/"{% endraw %}` | fixed shape: page 1 at `blog/`, page N at `blog/N/` | | `{% raw %}{% for post in collections.post %}{% endraw %}` | `{% raw %}{% for post in blog.items %}{% endraw %}` | A page whose front matter has `eleventyExcludeFromCollections: true` becomes `published: false` β€” which also stops the page being built at all, so if you need the page but not the listing, move it out of the collection directory instead. ## Templates | Eleventy (Nunjucks) | Poops | | --- | --- | | `layout: base.njk` | `layout: base`, and the file is `_layouts/base.html` β€” the Nunjucks engine appends `.html` to the name, so layouts get renamed off `.njk` (Liquid appends `.liquid`). `theme/layout` resolves from `node_modules` | | `{% raw %}{{ content \| safe }}{% endraw %}` in the layout | `{% raw %}{% block content %}{% endblock %}{% endraw %}` β€” the body is rendered into the block | | `{% raw %}{% include "header.njk" %}{% endraw %}` | same | | `{% raw %}{{ page.url }}{% endraw %}` | same, and `page.filePath`, `page.excerpt`, `page.wordcount` come free | | `{% raw %}{{ post.data.title }}{% endraw %}` | `{% raw %}{{ post.title }}{% endraw %}` β€” items are flat, no `data` wrapper | | `{% raw %}{{ "now" \| date: … }}{% endraw %}` via a plugin | `{% raw %}{{ post.date \| date("MMM D, YYYY") }}{% endraw %}` β€” [dayjs](https://day.js.org/) tokens, with a `dateFormat` default | | A shortcode you wrote for images | the `{% raw %}{% image %}{% endraw %}` tag, with [`poops-images`](static-site/images-gallery) doing the resizing | | A shortcode for code samples | `{% raw %}{% highlight 'javascript' %}{% endraw %}`, or a Markdown fence β€” both highlighted at build time | | `eleventy-plugin-syntaxhighlight` | nothing to install; it is on by default | ## The order that works 1. **Point `markup.in` at your existing `dir.input`.** Underscore directories are ignored for output already, so `_layouts` and `_includes` keep working once they are on `includePaths`. Rename layout files from `.njk` to `.html` and drop the extension from the `layout:` line. 2. **Move tagged posts into a directory** named for the collection, and put `collection: true` in its index file. 3. **Delete every `permalink`** and move the file to match the URL it declared. 4. **Replace `collections.x` with `x.items`** and drop `.data` from item property access. 5. **Port shortcodes and filters** to bundled tags, or inline them. This is the step that decides whether the migration is an hour or a weekend β€” count them before you start. 6. **Move the bundler config into `scripts`/`styles`** and delete the Vite config, the `npm-run-all` script and the second watcher. 7. **`poops -b`, then read the warnings.** Missing `date:` front matter is the common one. > [!NOTE] > This is a mapping, not a script. Nothing here was run against an existing Eleventy site β€” the > Poops side of every row is documented behaviour, the Eleventy side is from > [its docs](https://www.11ty.dev/docs/), and your site will have a case neither of us thought of. ## When not to migrate You wrote a dozen shortcodes, your data comes from an API at build time, or your URLs are generated from a permalink template. Eleventy's programmable config is doing real work there, and Poops has nowhere to put it β€” [the comparison](comparisons/jekyll-eleventy) has the rest of the rows. --- # πŸ’© Poops URL: https://stamat.info/poops/docs **Poops is a straightforward, no-bullshit bundler for the web** β€” and a bit more than that. On the surface it takes input and output paths and poops out bundled files. Underneath it is three tools in one: - a **bundler & transpiler** for JavaScript/TypeScript and SCSS/Sass, - a **PostCSS pipeline** (so Tailwind and friends work), - and a **Jekyll-inspired static site generator** with templating, collections, images, search and navigation. If you have ever fought Webpack config, watched Rollup plugins rot, or wondered why a "simple" setup needs fifteen dependencies β€” Poops is the antidote. One JSON config, sane defaults, minimal learning curve. > [!TIP] > In a hurry? Jump to the [Quick Start](quick-start/) and have something building in a minute. ## Why another bundler? Gulp is abandoned. Parcel hates config files. Rollup and Webpack are heavy for simple tasks. Poops exists to do one boring thing well: **give it an `in` and an `out`, get bundled files back.** It leans on the fastest tools available β€” [esbuild](https://esbuild.github.io/) for JS/TS and [Dart Sass](https://sass-lang.com/dart-sass) for styles β€” and stays out of your way. ## What it is Poops is a Jekyll-inspired static site builder. Like Jekyll, you write templates and content, drop in some front matter, and get a static site. Unlike Jekyll, it is also the bundler for your JS and CSS, it runs on Node, and it uses modern transpilers under the hood. You configure everything in a single `poops.json` (or `πŸ’©.json`) file. Each top-level key is a pipeline you can opt into or ignore: | Key | What it does | | -------------------------------- | ----------------------------------------------------- | | `scripts` | Bundle & transpile JS/TS/JSX/TSX with esbuild | | `styles` | Compile SCSS/Sass with Dart Sass | | `postcss` | Run a PostCSS pipeline (Tailwind, Autoprefixer, …) | | `markup` | Generate HTML from Nunjucks/Liquid/Markdown templates | | `reactor` | Pre-render React components to HTML at build time | | `images` | Optimize & generate responsive image variants | | `copy` | Copy static files into the output | | `serve` / `livereload` / `watch` | Local dev server with live reload | > [!NOTE] > Everything is optional except that you need at least one of `scripts`, `styles`, `postcss` or > `markup`. No input, no poop. πŸ’© ## What it is not Poops is not a plugin ecosystem. There is no plugin API to learn, no `poops.config.js` with callbacks. If a feature isn't built in, you compose it from the pipelines above (for example, PostCSS for Tailwind) or you contribute it. That constraint is the point β€” the config stays small and readable. ## Who it's for Poops is a niche tool, not a general-purpose framework. It earns its keep in three spots: - **The anti-config build.** A marketing page or a couple of static templates with some SCSS doesn't need a `webpack.config.js` or a dozen Vite plugins. One JSON block, `in` and `out`, done. - **Design-token-driven micro-sites.** Poops reads the W3C [DTCG](https://www.designtokens.org/) format natively and turns Figma-exported token JSON into SCSS variables β€” no separate Style Dictionary or Gulp pipeline. Useful for design-system docs and standalone component libraries. - **Static sites with just enough React.** Write the site in Nunjucks or Liquid, then drop in one or two interactive React components (a calculator, a filter UI) via the `reactor` pipeline's zero-config SSR and hydration. SEO-friendly HTML, without the footprint of a full React framework. ## Where it has zero value - **Large SPAs.** No React Fast Refresh, no granular code splitting, no plugin ecosystem. Reach for Next.js or Vite instead. - **Non-React component frameworks.** Vue, Svelte, Solid β€” not supported, not planned. - **Mission-critical enterprise infrastructure.** Poops is maintained by one person as a side project. That's fine for a brochure site; think twice before betting production revenue on it. > [!NOTE] > If you're not sure whether Poops fits, the litmus test is size: small static site or design > system docs, yes; full application, no. Row by row against the alternatives β€” Vite, webpack, Rollup, Parcel, Jekyll, Eleventy, Astro, Hugo and Next, including the rows Poops loses: [Comparisons](comparisons/). ## Install Globally: ```bash npm i -g poops ``` or per-project: ```bash npm i -D poops ``` > [!TIP] > For the fastest possible start, clone the template repo > [πŸ’©πŸŒͺ️ Shitstorm](https://github.com/stamat/shitstorm) and start editing. Ready? Head to the [Quick Start](quick-start/) β€” and when the site builds, [publish it on GitHub Pages](deploying). --- # Markup engine API URL: https://stamat.info/poops/docs/engine-api.html The markup pipeline doesn't render templates itself β€” it drives an **engine**. Nunjucks and Liquid ship built in, but the engine slot accepts any module that implements the interface on this page. This is how [poops-shopify](https://github.com/stamat/poops-shopify) plugs a Shopify-flavored Liquid engine into the same pipeline. The engine interface is public API: it follows semver from v2.0.0 on. A breaking change to it means a major version of Poops. ## Pointing Poops at an engine ```json { "markup": { "in": "src/markup", "out": "dist", "options": { "engine": "nunjucks" } } } ``` `engine` resolves in three ways: - **Builtin name** β€” `"nunjucks"` (default) or `"liquid"`. - **Path** β€” anything starting with `.`, `/`, or an absolute path is imported as a file relative to the project root: `"./tools/my-engine.js"`. - **Package** β€” any other string is imported as a bare specifier from your `node_modules`: `"poops-shopify"`. In all cases the module's **default export** must be the engine class. ## Lifecycle Poops instantiates the engine once, lazily, before the first markup compile: ```js new EngineClass(templatesDir, includePaths, { autoescape }) ``` - `templatesDir` β€” absolute path of `markup.in`. - `includePaths` β€” the `markup.includePaths` array from config (layout/partial directories, relative to `templatesDir`). - `options.autoescape` β€” the config's autoescape flag. Immediately after construction, Poops calls: 1. `registerFilters({ dateFormat, markupOut })` β€” once. `dateFormat` is the configured date format string, `markupOut` the output directory (project-relative). 2. `registerTags(getOutputDir)` β€” once. `getOutputDir` is a function returning the absolute output directory; call it at render time, not registration time. 3. A series of `setGlobal(key, value)` calls: `package` (the project's parsed `package.json`), `site` (the `markup.site` object), data file globals, reactor-rendered HTML, and `nav` (the navigation tree). Globals are re-set on every compile; `removeGlobal(key)` clears ones whose source file disappeared. Then, for every page, Poops awaits `render(templateName, context)`. ## Required interface | Member | Kind | Contract | |---|---|---| | `constructor(templatesDir, includePaths, options)` | β€” | See lifecycle above. | | `markupExtensions` | getter β†’ string | Pipe-separated extension list used to glob page sources, e.g. `'html|xml|rss|atom|json|njk|md'`. No dots. | | `indexableExtensions` | getter β†’ `Set` | Dot-prefixed extensions eligible for collections, search index and nav, e.g. `new Set(['.html', '.md'])`. | | `registerFilters(opts)` | method | Register template filters. Called once. | | `registerTags(getOutputDir)` | method | Register template tags/extensions. Called once. | | `setGlobal(key, value)` | method | Set a template global. Called repeatedly, across compiles. | | `removeGlobal(key)` | method | Remove a template global. | | `render(templateName, context)` | method, awaited | Render one page template to an HTML string. `templateName` is the page's source path; `context` carries `page`, `site`, collections and pagination. | ## Optional interface Each of these is feature-detected with a `typeof` check β€” implement what your engine can support, skip the rest. | Member | Contract | Without it | |---|---|---| | `invalidate(file)` | Drop cached compiled template(s) backed by `file` β€” a changed or deleted path; prefix-match to cover deleted directories. Presence signals your cache survives across compiles. | Poops calls `clearCache()` (if present) on every watch compile. | | `clearCache()` | Wipe the whole compiled-template cache. Only used when `invalidate` is absent. | No cache management at all. | | `pagesDependingOn(file)` | Return the page paths whose last render loaded `file` β€” powers incremental rebuilds: only affected pages re-render on a partial/layout edit. | Any markup edit triggers a full markup compile. | | `replaceOutExtensions(outputPath)` | Remap the output filename's extension when your engine's source extension differs from the emitted one. | Poops's default extension mapping applies. | | `isMarkupSource(absPath)` | Claim a file the glob wouldn't classify as markup (engine-specific source formats), so watch routes its changes to the markup pipeline. | Only `markupExtensions` matches count. | `renderString` and `fileExtension` exist on the builtin engines but the pipeline never calls them β€” don't rely on them, don't feel obliged to implement them. ## Reference implementations The builtin engines are the contract's living documentation: - [`lib/markup/engines/nunjucks.js`](https://github.com/stamat/poops/blob/main/lib/markup/engines/nunjucks.js) β€” the full-featured one: cache proxy feeding a dependency index (`pagesDependingOn`), targeted `invalidate`, front matter and Markdown handling in a custom loader. - [`lib/markup/engines/liquid.js`](https://github.com/stamat/poops/blob/main/lib/markup/engines/liquid.js) β€” the smaller one; start here when writing your own. A practical skeleton: ```js export default class MyEngine { constructor(templatesDir, includePaths, options) { /* set up */ } get markupExtensions() { return 'html|md|mytpl' } get indexableExtensions() { return new Set(['.html', '.md', '.mytpl']) } registerFilters({ dateFormat, markupOut }) { /* filters */ } registerTags(getOutputDir) { /* tags */ } setGlobal(key, value) { /* globals */ } removeGlobal(key) { /* globals */ } async render(templateName, context) { return '…' } } ``` --- # Publishing to GitHub Pages URL: https://stamat.info/poops/docs/deploying.html The build is green, the deploy is green, and the live site is unstyled with every link one level off. Nothing failed: a project site is served from `https://user.github.io/repo/`, not from `/`, and any absolute `/css/styles.css` you wrote points at a directory GitHub never made. Poops writes **relative** path prefixes by default β€” `./`, `../` β€” precisely so a build works wherever it lands, subdirectory included. Use `{% raw %}{{ relativePathPrefix }}{% endraw %}` for every asset and link and you can stop reading after the workflow. Hardcode a leading slash anywhere, or need absolute URLs in `sitemap.xml` and `og:` tags, and the fix is one flag: `--base-url /repo`. ## Turn Pages on first **Settings β†’ Pages β†’ Build and deployment β†’ Source: GitHub Actions.** The workflow below uploads an artifact and asks Pages to publish it; while the source is still *Deploy from a branch*, there is nothing on the other end of that request. ## The workflow `.github/workflows/pages.yml`: ```yaml {% raw %}name: Deploy to GitHub Pages on: push: branches: [main] permissions: contents: read pages: write id-token: write concurrency: group: pages cancel-in-progress: true jobs: deploy: runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: 24 cache: npm - run: npm ci # The repo name is the subdirectory the site is served from. - run: npx poops --build --base-url /${{ github.event.repository.name }} - uses: actions/upload-pages-artifact@v4 with: path: dist - id: deployment uses: actions/deploy-pages@v4{% endraw %} ``` That is the shape this site deploys with β€” [pages.yml](https://github.com/stamat/poops/blob/main/.github/workflows/pages.yml) in the Poops repo is the same file with `example/dist` as the artifact path, because the docs site lives inside the repo that builds it. Point `path:` at whatever your `markup.out` is. Three lines carry the weight: | Line | Why it is there | | --- | --- | | `permissions: pages: write, id-token: write` | `deploy-pages` authenticates with an OIDC token minted per run. Without `id-token: write` the deploy step fails; no secret is stored anywhere either way. | | `concurrency: group: pages` | Two pushes in a minute otherwise race for the same Pages deployment. The later one wins, and which one that is depends on timing. | | `npm ci` | Installs exactly the lockfile. `npm install` may resolve a newer minor of a dependency than the one you tested β€” on a static site that shows up as a layout that moved, months later. | > [!TIP] > `cache: npm` on `setup-node` needs a lockfile in the repo. No lockfile, no cache β€” and no `npm ci` > either; that step wants one too. ## When you need `--base-url`, and when you do not | Where the site lives | Flag | Why | | --- | --- | --- | | `user.github.io/repo/` (project site) | `--base-url /repo` | `{% raw %}{{ relativePathPrefix }}{% endraw %}` resolves to `/repo/` everywhere instead of a per-page `../`. Required the moment anything is absolute. | | `user.github.io` (user site, repo `user.github.io`) | none | The site *is* the root. A base URL of `/user.github.io` would be wrong. | | A custom domain | none | Same reason β€” the site is at the root of that domain. | The flag overrides `markup.options.baseURL` from the config, which is the point: one config file, different deploy paths per environment. Leaving both unset keeps prefixes relative, which also works from `file://` β€” open `dist/index.html` in a browser and the site still works. Absolute URLs β€” `sitemap.xml`, canonical links, `og:image`, JSON-LD β€” come from `site.url` instead, so set that to the deployed address: ```json "markup": { "options": { "site": { "url": "https://user.github.io/repo" } } } ``` ## A custom domain Two things, and the order does not matter: 1. Point the DNS at GitHub, and set the domain under **Settings β†’ Pages β†’ Custom domain**. 2. Keep a `CNAME` file in the published output. Pages reads it from the artifact root, so put it in your markup input directory or `copy` it in: ```json "copy": [{ "in": "src/CNAME", "out": "dist" }] ``` Then drop `--base-url` from the workflow and set `site.url` to the domain. A stale `--base-url` on a custom domain is the same 404 as before, in the other direction. ## What still bites | Symptom | Cause | Fix | | --- | --- | --- | | Posts reshuffle between deploys | A post with no `date` in front matter falls back to file mtime, and `git clone` on a runner sets mtime to checkout time | Put a real `date` in front matter β€” the build already warns about this | | Assets 404 only in production | An absolute `/css/...` in a layout | `{% raw %}{{ relativePathPrefix }}{% endraw %}css/...`, or `--base-url` | | Deploy publishes an empty site | `path:` in `upload-pages-artifact` does not match `markup.out` | They are two separate strings; keep them in step | | Nothing deploys, no error | `poops -b` exits 0 when it has nothing to compile β€” a wrong path is a green build | Assert an artifact exists: `test -f dist/index.html` | The last one is worth a step of its own in any workflow you care about: ```yaml - name: Verify build output run: test -f dist/index.html && test -f dist/sitemap.xml ``` ## The other route: deploy from a branch Committing `dist` to a `gh-pages` branch still works, and it is what you fall back to when the site is built somewhere other than Actions. | | Actions artifact | `gh-pages` branch | | --- | --- | --- | | What is stored | nothing β€” the artifact is transient | every build, forever, in git history | | Jekyll runs on it | no, the artifact is served as uploaded | **yes** β€” branch sources are built with Jekyll unless a `.nojekyll` file sits at the root | | Needs | Pages source set to GitHub Actions | a push token and a branch | | Rollback | re-run an older workflow | `git revert` | Jekyll on a branch deploy eats any directory starting with an underscore, which is exactly what a Poops `_layouts` or `_partials` directory is called if it ever reaches the output. Add `.nojekyll` and the problem disappears; on the Actions route it never appears. --- # Configuration reference URL: https://stamat.info/poops/docs/config-reference.html Every `poops.json` key, with a short explanation and example. The pipeline keys (`scripts`, `styles`, `postcss`, `markup`, `reactor`, `images`) each link to a full guide for the deep dive; everything else is documented in full on this page. **Every key** | Key | Purpose | Documented in | | -------------------- | ------------------------------------------------- | ------------------------ | | `$schema` | Editor completion and validation for this file | [↓](#schema) | | `scripts` | Bundle / transpile JS & TS (esbuild) | [↓](#scripts) | | `styles` | Compile Sass / CSS | [↓](#styles) | | `postcss` | PostCSS / Tailwind pass over compiled CSS | [↓](#postcss) | | `reactor` | Render React components to static HTML | [↓](#reactor) | | `images` | Responsive image processing | [↓](#images) | | `markup` | Templates β†’ static site | [↓](#markup) | | `markup.searchIndex` | JSON search index of every page | [↓](#markup-searchindex) | | `markup.sitemap` | `sitemap.xml` generation | [↓](#markup-sitemap) | | `markup.llms` | `llms.txt` index for LLMs / GEO | [↓](#markup-llms) | | `markup.robots` | `robots.txt` generation | [↓](#markup-robots) | | `markup.nav` | Navigation-tree data | [↓](#markup-nav) | | `markup.feed` | RSS / Atom feed from a collection | [↓](#markup-feed) | | `copy` | Copy static assets into the output | [↓](#copy) | | `exec` | Shell hooks run after a pipeline stage | [↓](#exec) | | `banner` | Comment stamped on every output file | [↓](#banner) | | `serve` | Local dev server | [↓](#serve) | | `livereload` | Reload the browser on changes | [↓](#livereload) | | `watch` | Paths to watch (or `true` to auto-derive) | [↓](#watch) | | `includePaths` | Import-resolution roots (Sass `@use`, JS imports) | [↓](#includepaths) | The remaining `markup` sub-keys β€” `in`, `out`, `engine`, `site`, `data`, `includePaths` and `baseURL` β€” are covered in [Templating HTML](quick-start/templating-html), and `collections` in [Building a blog with collections](static-site/blog-collections). `dateFormat` and `autoescape` are below, under [`markup`](#markup). ## `$schema` A mistyped key is not a build error. A top-level `"stlyes"`, an `"inn"` in a styles entry, an `"engnie"` in `markup.options` β€” each is read by nothing, and the build stays green with the file it should have written simply missing. You find out when you look. Poops names every one of them at startup, reading the same schema your editor does: ``` [info][warn] Unknown key "inn" in styles[0] β€” ignored. Valid: in, out, options ``` Key names only, and only in the blocks Poops owns. `images` belongs to [poops-images](https://github.com/stamat/poops-images) and `site` is yours to name, so an unrecognised key in either passes without comment. Types are checked by nobody here: `"minify": "yes"` reaches the compiler and fails there, loudly. The same [JSON Schema](https://json-schema.org) drives editor completion and inline docs for every key on this page. Point `$schema` at the copy in your `node_modules`: ```json { "$schema": "./node_modules/poops/schema/poops.schema.json", "scripts": [{ "in": "src/js/main.ts", "out": "dist/js/app.js" }] } ``` Or at the hosted copy, which needs nothing installed: ```json { "$schema": "https://stamat.info/poops/poops.schema.json" } ``` VS Code, JetBrains and anything else speaking the language server protocol read it from the file itself. To attach it without touching your config, map it in VS Code's `settings.json` instead β€” the same file, matched by name: ```json { "json.schemas": [ { "fileMatch": ["poops.json", "πŸ’©.json"], "url": "https://stamat.info/poops/poops.schema.json" } ] } ``` The `$schema` key itself is inert β€” Poops reads it, recognises it, and does nothing with it. The URL is your editor's business: the startup check reads the copy inside `node_modules/poops`, so pointing `$schema` at the hosted file, at a stale one, or leaving it out changes nothing about what the CLI says. Nothing is added to what Poops installs into your project either way. ### Blocks belonging to another package `poops.json` is shared. [septic](https://github.com/stamat/septic) reads a `septic` block out of the same file, and Poops has no business calling that a mistake. So an unknown top-level key is accepted in silence when a package by that name is in your `dependencies`, `devDependencies`, `peerDependencies` or `optionalDependencies` β€” declaring it is enough, and Poops never loads it: ```json { "styles": [{ "in": "src/scss/index.scss", "out": "dist/css/app.css" }], "septic": { "db": "data/app.db" } } ``` With nothing by that name declared, the key is warned about as before β€” which is what catches the typo. > [!WARNING] > Your editor cannot see your `node_modules`, so the schema cannot make that distinction. It > allows an **object** under any name it does not know, and rejects everything else: > `"stlyes": [ … ]` is still flagged, `"srve": { … }` is not. That is the price of one shared > config file, and the CLI still catches what the editor lets through. A companion that owns a block describes it in its own schema β€” [septic](https://github.com/stamat/septic) does β€” and `$schema` takes one URL, so having both checked means composing them in a local file. Each package's README carries its schema URL and that two-line `allOf`; this page deliberately does not repeat them, since a URL copied into two repos is a URL that goes stale in one. > [!INFO] > The schema is hand-written, so it can drift from the code. Poops' test suite validates it > against the draft-07 meta-schema, then validates its own `poops.json` and every complete example > on this site against it. Its top-level keys are asserted to be exactly the set the CLI accepts, > its `exec` stages exactly the ones that fire, and its `markup.options` a superset of what the > markup engine reads. A per-entry `options` object β€” mostly esbuild's and PostCSS's, not Poops' β€” > has no such list, so if the editor does not offer an option this page documents, the schema is > behind, and that is worth reporting. ## `scripts` Bundles and transpiles JavaScript / TypeScript with [esbuild](https://esbuild.github.io/). A single `{ in, out }` object or an array of them; `in` accepts a path, an array of paths, or globs β€” a glob-matched `index.*` is named after its directory, relative to the glob's static prefix, so `src/elements/*/index.ts` builds one bundle per component. `out` also accepts a template β€” `{% raw %}{{dir}}{% endraw %}` (the match's directory relative to that static prefix) and `{% raw %}{{name}}{% endraw %}` (its basename without extension) β€” naming one output per matched entry, extension included. Per-entry `options` cover `sourcemap`, `minify`, `justMinified`, `format`, `target`, `jsx` and `nodePaths` β€” the last one adding import-resolution roots for this entry alone, merged with the top-level [`includePaths`](#includepaths) rather than replacing it. The same `options` apply to a [`reactor`](#reactor) entry's client bundle. ```json { "scripts": { "in": "src/js/main.ts", "out": "dist/js/app.js", "options": { "sourcemap": true, "minify": true, "format": "iife", "target": "es2019" } } } ``` Full guide: [Transpiling JS](quick-start/transpiling-js). ## `styles` Compiles Sass/SCSS (and plain CSS) to CSS. Same `{ in, out, options }` shape as `scripts`, including the `index.*` glob rule and the `out` templates; `options` adds `tokenPaths` for design-token inputs. Pair it with [`postcss`](#postcss) for Autoprefixer or Tailwind. ```json { "styles": { "in": "src/scss/index.scss", "out": "dist/css/app.css", "options": { "sourcemap": true, "minify": true } } } ``` Full guide: [Transpiling CSS](quick-start/transpiling-css). ## `postcss` Runs a [PostCSS](https://postcss.org/) pipeline β€” separate from the Sass `styles` step β€” for [Tailwind](https://tailwindcss.com/), Autoprefixer or any PostCSS plugin. `options.plugins` lists the plugins to load. Accepts one entry or an array. Needs `postcss` installed (`npm i -D postcss`). ```json { "postcss": { "in": "src/css/main.css", "out": "dist/css/main.css", "options": { "plugins": ["@tailwindcss/postcss"], "minify": true } } } ``` Full guide: [PostCSS & Tailwind](quick-start/postcss-tailwind). ## `markup` Turns a directory of templates (Nunjucks or Liquid, plus Markdown) into a static site. Same shape as a `scripts` or `styles` entry: `in` and `out`, everything else under `options` β€” `engine`, `site`, `data`, `includePaths`, `dateFormat`, `collections`, `baseURL`, `autoescape`, plus [`searchIndex`](#markup-searchindex), [`sitemap`](#markup-sitemap) and [`nav`](#markup-nav) below. > [!WARNING] > **Deprecated placement.** Poops 1.x also read these keys directly on `markup` > (`{% raw %}"markup": { "site": … }{% endraw %}`). That still works in 2.x and logs a warning > naming the key; it stops working in 3.0. Move them into `options`. ```json { "markup": { "in": "src/markup", "out": "dist", "options": { "engine": "nunjucks", "site": { "title": "My Site", "description": "Built with Poops." } } } } ``` The `site` object holds global data every template reads. The SEO filters pick up `title`, `description`, `url`, `logo`, `author` and `lang` β€” `lang` feeds both the `` attribute (`{% raw %}{% endraw %}`) and the JSON-LD `inLanguage`; a page's front-matter `lang` overrides it. A `site.jsonld` object sets site-wide JSON-LD defaults β€” `{% raw %}"jsonld": { "@type": "TechArticle" }{% endraw %}` for a docs site β€” merged over the generated ones and still overridable per page. Add anything else you want globally available β€” e.g. `repo` and `branch` to drive "Edit on GitHub" links (see [Building a documentation site](static-site/docs-site)). Two options that live nowhere else: | Option | Meaning | | --- | --- | | `dateFormat` | Default [dayjs](https://day.js.org/) format for the `date` filter when it is called without an argument. With neither set, `date` returns the value untouched rather than guessing a format. | | `autoescape` | **Nunjucks only.** Escape template output by default, so `{% raw %}{{ value }}{% endraw %}` cannot inject HTML and anything meant as markup needs `\| safe`. Default `false`. The Liquid engine ignores it β€” liquidjs does not escape by default and Poops does not make it. | Full guide: [Templating HTML](quick-start/templating-html). ## `reactor` Renders React components to static HTML at build time and emits a hydration bundle. `component` is the component rendered to markup, `inject` names the global the HTML is exposed as, and `in`/`out` are the client hydration entry/bundle. ```json { "reactor": { "component": "src/js/App.jsx", "inject": "app_html", "in": "src/js/app-hydrate.jsx", "out": "dist/js/app-hydrate.js" } } ``` Full guide: [React](quick-start/react). ## `images` Responsive image processing β€” resize, convert (WebP/AVIF), crop and read EXIF β€” via [poops-images](https://github.com/stamat/poops-images). `sizes` is the responsive ladder plus any named crops; `format` lists output formats. Poops' schema leaves this block open, because poops-images owns the keys inside it. poops-images publishes a schema of its own, and its README shows how to point `images` at it so the block is completed and checked inside your `poops.json` too. ```json { "images": { "in": "src/images", "out": "dist/images", "sizes": [ { "width": 640 }, { "width": 1280 }, { "name": "thumb", "width": 200, "height": 200, "crop": true } ], "format": ["webp"] } } ``` Full guide: [Images & galleries](static-site/images-gallery). ## `copy` Copies files or directories into the output β€” static assets like fonts, favicons, OG images. Accepts a single `{ in, out }` object or an array of them; `in` can be a path or an array of paths: ```json { "copy": [ { "in": ["src/static/ogimage.jpg", "src/static/favicon.ico", "src/fonts"], "out": "dist" }, { "in": "images", "out": "dist/static" } ] } ``` Input paths accept **glob** and **extglob** patterns (everything except POSIX character classes like `[[:alpha:]]`): ```json { "copy": { "in": [ "images/**/awesome.{jpeg,jpg,png}", "notes/info[0-9].txt", "assets/!(vendor)/*.js", "fonts/@(woff|woff2)/*.+(woff|woff2)" ], "out": "dist" } } ``` ## `exec` Shell commands to run after a pipeline stage compiles β€” a post-processor that needs the built output, like stripping comments from the unminified CSS or regenerating a reference page. Keyed by stage, each value a command string or an array run in order: ```json { "exec": { "styles": [ "node script/strip-css-comments.mjs dist/styles.css", "node script/gen-reference.mjs" ], "build": "node script/deploy.mjs" } } ``` Unlike chaining `poops -b && cmd` in an npm script, the hook runs on **every** rebuild β€” in watch/dev too β€” so the post-processed output never drifts while you work. Commands run from the project root; a failing command fails a `-b` build's exit code but is logged and swallowed in watch so the watcher survives. Stages: | Stage | Runs after | | --------- | ----------------------------------------------------------------------- | | `styles` | CSS is final (after PostCSS) β€” use this for anything reading the built CSS | | `scripts` | scripts compile | | `reactor` | reactor components render (build only) | | `images` | images process | | `markup` | markup renders | | `copy` | files copy | | `build` | once, after the full initial pipeline (not per watch rebuild) | ## `banner` A comment stamped on top of every output file. Templatable via mustache from your project's `package.json` β€” available variables: `name`, `version`, `homepage`, `license`, `author`, `description`, plus `year` (the current year, for a copyright line) which comes from the clock rather than the manifest: ```nunjucks {% raw %}{ "banner": "/* {{ name }} v{{ version }} | {{ homepage }} | {{ license }} License */" }{% endraw %} ``` A plain string works too β€” templating is optional. ## `serve` A local dev server: | Option | Meaning | | ------ | ---------------------------------------------------------------------- | | `port` | Port to serve on (CLI `--port`/`-p` overrides). | | `base` | Base path of the server β€” where your built HTML lives, e.g. `"/dist"`. Defaults to the markup `out` directory. | ## `livereload` Reloads the browser when a build finishes. A switch, not an object β€” there is nothing to configure: ```json { "serve": { "base": "dist" }, "livereload": true } ``` It rides the `serve` port, so it needs `serve` to be on. Poops answers `/__poops_reload` as a server-sent events stream and appends the client script to every HTML page it serves β€” **your templates need no snippet**, and nothing is written into your build output. One save means one reload, after the build it triggered has settled. When everything a build wrote is CSS, stylesheets are swapped in place instead: no page reload, so scroll position and form state survive a style edit. The browser reconnects on its own after a Poops restart. ## `watch` An array of paths to watch; changes rebuild the affected pipeline: ```json { "watch": ["src"] } ``` Set it to `true` to derive the list automatically from every task's `in` path (file entries like a script/style bundle collapse to their parent dir so sibling imports still trigger a rebuild): ```json { "watch": true } ``` This covers sources that live under a task's own directory. Imports that reach _outside_ it β€” a shared folder above the entry, `node_modules` β€” aren't watched; use an explicit array for those. ## `includePaths` Paths to resolve imports from (Sass `@use`, script imports). `node_modules` is the default β€” **if you set this key, include `node_modules` yourself**, since the value replaces the default: ```json { "includePaths": ["node_modules", "lib"] } ``` ## `markup.searchIndex` Writes a JSON search index of every page. A string sets the output filename with defaults; the object form takes options: | Option | Meaning | | ------------------------ | ------------------------------------------------------------------------------------------------------- | | `out` | Output filename, written to the markup output directory. | | `minWordLength` | Minimum word length considered a keyword. Default `3`. | | `maxKeywords` | Maximum keywords per page. Default `20`. | | `globalFrequencyCeiling` | Drop words appearing in more than this fraction of pages. Default `0.8`. | | `stopWords` | `undefined` = bundled English list, `false` = disable, an inline array, or a path to a JSON array file. | All front matter fields pass through to the index; internal fields (`content`, `isIndex`, `layout`, `published`) are stripped. A page's own `keywords` front matter overrides the auto-extracted ones. Pages with `published: false` are excluded. ```json [ { "title": "My Post", "description": "A great post about things.", "url": "blog/my-post.html", "keywords": ["javascript", "bundler", "esbuild"] } ] ``` ## `markup.sitemap` Writes a standard `sitemap.xml` with `` and `` (from front matter `date`). If `site.url` is set, it is prepended to all URLs. Collection index/pagination pages are included here but excluded from the search index. A string sets the filename; the object form takes `out`. A page's front matter `robots: noindex` (or `none`) drops it from the sitemap **and** `llms.txt` β€” for drafts, thin or utility pages. Emit `{% if page.robots %}{% endif %}` in your layout `` so the page carries the directive itself. ## `markup.llms` Writes an [`llms.txt`](https://llmstxt.org) β€” a Markdown index of your pages that LLMs and generative engines (GEO) read to understand the site. An `# H1` title, a `> ` blockquote summary, then `- [title](url): description` links grouped by URL path: the first folder is a `## section`, a second folder nests as a `### subsection` (so `docs/quick-start/x.html` β†’ `### Quick Start` under `## Docs`), and root-level pages fall under the lead section. Collection sections are ordered newest-first by `date`; other sections keep file order. `site.url` makes the links absolute; collection index/pagination pages are skipped. A string sets the filename; the object form takes options: | Option | Meaning | | -------------- | ----------------------------------------------------------------------- | | `out` | Output filename, written to the markup output directory. | | `title` | H1 title. Defaults to `site.title`. | | `description` | Blockquote summary. Defaults to `site.description`. | | `intro` | Path (from project root) to a Markdown file inserted as free-form body context. | | `sectionTitle` | Heading for the lead (uncollected) section. Default `"Pages"`. | | `full` | Also write the full-content file (below). `true` derives its name from `out` (`llms.txt` β†’ `llms-full.txt`); a string sets it explicitly. | | `fullIntro` | Path (from project root) to a Markdown preamble inserted into the full-content file after its header. The `full` counterpart to `intro`. | Point `intro` at a file authored for LLMs (e.g. `llms-intro.md`) β€” not a raw README, whose badges, install noise and `##` headings collide with the generated sections. `full` writes the companion full-content file β€” every page's full content concatenated into one file (the index is the link map; this is the whole corpus). `true` names it after `out` with a `-full` suffix (`llms.txt` β†’ **`llms-full.txt`**, `ai.txt` β†’ `ai-full.txt`); pass a string to set the path yourself. Content is each page's Markdown **source**, so only `.md`/`.markdown` pages are included (an `.njk`/`.liquid` source is template code, not prose); `noindex` and collection index pages are dropped. The file opens with a `# Full Documentation Archive for {title}` header, a one-line intro naming the site and a `> ` blockquote of the `description`, then each page becomes an `# title` + `URL:` line + body, joined by `---`. Set `fullIntro` to a Markdown file path (from the project root) to insert your own preamble after that header β€” the `full` counterpart to `intro`; inserted verbatim (a missing file warns and is skipped). Unrendered `{% raw %}{% … %}{% endraw %}` tags or shortcodes in a Markdown body pass through verbatim. ## `markup.robots` Writes a `robots.txt`. A string writes an allow-all file (`User-agent: *`, empty `Disallow:`) with a `Sitemap:` line pointing at your generated sitemap β€” absolute when `site.url` is set. The object form takes options: | Option | Meaning | | ----------- | ------------------------------------------------------------------------------- | | `out` | Output filename, written to the markup output directory. | | `userAgent` | The `User-agent` line. Default `"*"`. | | `disallow` | A path or array of paths to disallow. | | `allow` | A path or array of paths to explicitly allow. | | `sitemap` | An explicit `Sitemap:` URL, or `false` to omit the line. Auto-derived by default. | ## `markup.nav` Builds the page hierarchy as sidebar-ready data β€” the `nav` template global plus a nested JSON file. See [Building a documentation site](static-site/docs-site) for the walkthrough; the options: | Option | Meaning | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `out` | Output filename, written to the markup output directory. | | `collections` | `true` = include every collection page nested under its collection (default); `false` = exclude all; `["docs"]` = allowlist; `"index"` = only each collection's landing page as a leaf. | | `home` | `false` drops the site's root index page from the tree. Default `true`. | | `root` | Scope the tree to a subdirectory (e.g. `"docs"`); its children are emitted at the top level with the section index pinned first. | Each node has `title`, `url` (omitted on synthesized section nodes), `order` when set, and `children` when it has subpages: ```json [ { "title": "Guide", "url": "guide", "order": 1, "children": [ { "title": "Getting Started", "url": "guide/getting-started", "order": 1 } ] } ] ``` Front matter shaping the tree: `order` (sort among siblings), `navTitle` (sidebar label), `nav: false` (hide from sidebar). If nothing survives filtering, an empty array is written. ## `markup.feed` Generates an RSS or Atom subscription feed from a [collection](static-site/blog-collections) β€” no hand-authored feed template. Items are the collection's posts newest-first by `date` (capped at `limit`), with channel metadata pulled from your `site` data. `robots: noindex` posts are excluded, and links / `guid`s are made absolute with `site.url`. The object form: | Option | Meaning | | ------------- | ---------------------------------------------------------------------------------------------------------------- | | `collection` | Collection to feed from. Omit to emit a feed for **every** collection. | | `out` | File to write. A bare filename (default `feed.xml`) goes in the collection's folder; a slashed path is used as-is. | | `type` | `"rss"` (default) or `"atom"`. | | `limit` | Max items, newest first. Default `20`. | | `title` | Channel title. Default `" \| "`. | | `description` | Channel description. Default `site.description`. | | `author` | Feed author. Default `site.author`. | | `lang` | Feed language. Default `site.lang`. | | `content` | `true` adds each post's full article HTML (RSS ``, Atom ``). Default off. | Shorthand: `true` (or a filename string) emits an RSS feed for every collection; an array of these objects generates several feeds at once (e.g. an RSS and an Atom for one collection). Item ``/`` uses each post's `description`, falling back to its auto-`excerpt`. Link readers to it from your layout ``: ```html {% raw %}{% endraw %} ``` `content: true` renders each post's Markdown **source** to article-body HTML (not the whole page β€” no layout/nav chrome), so only `.md`/`.markdown` posts get a ``; others fall back to `` alone. Unrendered `{% raw %}{% … %}{% endraw %}` tags or shortcodes in a body pass through verbatim. --- # A complete React static site URL: https://stamat.info/poops/docs/static-site/react-static-site.html A full site whose pages are React-rendered at build time and hydrated in the browser: fast first paint (real HTML), full interactivity after hydration, and no runtime SSR server β€” it's static files. This page builds directly on [Build a React App](../react-app/). Same project, same `App.jsx`, same styles and markup config. **Three changes** turn the client-only SPA into a pre-rendered, hydrated static site: ## 1. Swap `scripts` for `reactor` Replace the SPA's `scripts` entry with a `reactor` entry. Everything else in the config β€” `styles`, `markup`, `serve`, `watch` β€” stays exactly as it was: ```json { "reactor": [ { "component": "src/js/App.jsx", "inject": "app_html", "in": "src/js/app-hydrate.jsx", "out": "dist/js/app-hydrate.js", "options": { "minify": true, "target": "es2019" } } ] } ``` `component` is rendered to HTML at build time; the result is exposed to templates as `app_html`. `in`/`out` bundle the client entry that hydrates it. Full option reference in [React β€Ί Build-time pre-rendering](../quick-start/react#2-build-time-pre-rendering-the-reactor-key). ## 2. Hydrate instead of mount The SPA's `main.tsx` called `createRoot`. Here the client entry *hydrates* the HTML that is already on the page: ```jsx // src/js/app-hydrate.jsx import { hydrateRoot } from 'react-dom/client' import App from './App.jsx' hydrateRoot(document.getElementById('root'), ) ``` ## 3. Inject the rendered HTML The SPA shipped an empty `
`. Now the template drops the build-time HTML into it, then loads the hydration bundle: ```html {% raw %}{% extends "default.html" %} {% block content %}
{{ app_html | safe }}
{% endblock %}{% endraw %} ``` ## How the build flows 1. Poops bundles `App.jsx` with `react-dom/server`, calls `renderToString`, stores the HTML as `app_html`. 2. Markup renders `index.html`, injecting `app_html` into `#root`. 3. `app-hydrate.jsx` is bundled to `dist/js/app-hydrate.min.js`. 4. In the browser, React hydrates the pre-rendered HTML β€” the page is interactive. > [!TIP] > This gives you SSG with hydration and no separate server. Deploy the `dist/` folder to any > static host (GitHub Pages, Netlify, S3). > [!WARNING] > The server-rendered markup and the client's first render must match, or React logs a hydration > mismatch. Keep `App.jsx` deterministic at build time β€” no `Date.now()`, `Math.random()` or > browser-only APIs during the initial render. Full runnable examples live in the Poops repository's `example/` directory (`react.html` and `react-client.html`). --- # React components in a static site URL: https://stamat.info/poops/docs/static-site/react-components.html Sometimes you want a React component *inside* an otherwise-static page β€” an interactive counter, a clock, a filterable table β€” without turning the whole site into an SPA. That's what `reactor` is for: render the component to HTML at build time, inject it into a template, and optionally hydrate it on the client. ## The component A plain component with a default export: ```jsx // src/js/Counter.jsx import { useState } from 'react' export default function Counter() { const [n, setN] = useState(0) return ( ) } ``` ## The hydration entry A tiny client entry that hydrates the pre-rendered markup: ```jsx // src/js/counter-hydrate.jsx import { hydrateRoot } from 'react-dom/client' import Counter from './Counter.jsx' hydrateRoot(document.getElementById('counter'), ) ``` ## The config ```json { "reactor": [ { "component": "src/js/Counter.jsx", "inject": "counter_html", "in": "src/js/counter-hydrate.jsx", "out": "dist/js/counter.js", "options": { "minify": true, "target": "es2019" } } ] } ``` Poops renders `Counter` with `renderToString` and stores the HTML under the `inject` name. The client entry is bundled to `out`. ## The template Drop the rendered HTML in, then load the hydration bundle: ```html {% raw %}

Dashboard

{{ counter_html | safe }}
{% endraw %} ``` The page ships as real HTML β€” the button is visible before any JS runs β€” and becomes interactive once React hydrates it. > [!NOTE] > React comes from your project's `node_modules` (`npm i react react-dom`), and `reactor` is its > own pipeline, independent from `scripts` β€” details in [React](../quick-start/react). ## Server-only components Need the HTML but no interactivity (an icon, a formatted block)? Omit `in`/`out` and skip the hydration bundle entirely: ```json { "reactor": [ { "component": "src/js/Icon.jsx", "inject": "icon_html" } ] } ``` > [!TIP] > Multiple components? Add multiple `reactor` entries, each with its own `inject` name, and use > each in the templates that need it. Next: [A complete React static site](react-static-site). --- # Building pages URL: https://stamat.info/poops/docs/static-site/pages.html A page is any `.html`, `.md`, or engine-native (`.njk`/`.liquid`) file under your markup `in` directory. Its output path mirrors its source path: `src/markup/about.md` β†’ `dist/about.html`. ## Front matter Start a page with a YAML front-matter block. `layout` picks the wrapping template; everything else is available under `page.*`: ```markdown --- layout: default title: About description: Who we are and why. order: 2 --- # About us We build things with Poops. ``` Common fields: `title`, `description`, `layout`, `date`, `order`, `published`, `nav`, `navTitle`. Any custom field you add is yours to use in templates and it flows into the search index. Poops also computes read-only fields on `page`: `content` (the rendered body), `url`, `wordcount`, `excerpt`, and `filePath`. `excerpt` is the first prose paragraph as plain text (headings and comments skipped, capped at 160 chars) β€” use it as the fallback for a missing `description`, e.g. in the meta tag below or the `og`/`jsonld` filters. `filePath` is the source file's path relative to your project root (posix separators), for building "Edit on GitHub" links β€” see [Building a documentation site](docs-site). ## Layouts Put base templates in `_layouts/` (a directory ignored for output, but on your `includePaths`). A Nunjucks layout defines a `content` block: ```nunjucks {% raw %} {{ page.title or site.title }} {% include "site-header.html" %}
{% block content %}{% endblock %}
{% include "site-footer.html" %} {% endraw %} ``` The page body is rendered, then dropped into `{% raw %}{% block content %}{% endraw %}`. > [!TIP] > Always prefix asset and link URLs with `relativePathPrefix`. It resolves to the correct number > of `../` for the page's depth, so a page at `dist/blog/post.html` still finds `css/styles.css`. ## Partials & includes Reusable snippets live in `_partials/` (also on `includePaths`). Include them by file name: ```nunjucks {% raw %}{% include "site-header.html" %}{% endraw %} ``` In Liquid, use `render`: ```liquid {% raw %}{% render "site-header.liquid" %}{% endraw %} ``` ## Global and page data Three sources of data reach your templates: - **`site`** β€” set once in the markup config (`site.title`, `site.url`, …). - **`data` files** β€” JSON/YAML loaded as globals named after the file. `_data/links.json` becomes `links`, so `{% raw %}{{ links.github }}{% endraw %}` works everywhere. - **`page`** β€” the current page's front matter. ```json { "markup": { "in": "src/markup", "out": "dist", "options": { "site": { "title": "My Site" }, "data": ["_data/links.json", "_data/authors.yaml"] } } } ``` > [!NOTE] > File names are normalized: spaces, dashes and dots become underscores. `the awesome-links.json` > is available as `{% raw %}{{ the_awesome_links }}{% endraw %}`. ## Markdown Markdown files are rendered to HTML and then run through the template engine, so template expressions work inside Markdown too. Fenced code blocks are **syntax-highlighted at build time** (highlight.js) β€” you ship a CSS theme, not a highlighter. ````markdown ```js const greet = (name) => `Hello, ${name}!`; ``` ```` > [!INFO] > Registered highlight languages include `js`, `ts`, `css`, `scss`, `html`, `json`, `bash`, > `python`, `ruby`, `php`, `go`, `rust`, `yaml`, `sql`, `diff` and more. Omit the language to let > highlight.js auto-detect. ## Useful filters Poops adds template filters usable in both engines β€” `slugify`, `markdown`, `toc`, `date`, `jsonify`, `svg`, `highlight`, `groupby`, the array helpers `concat` (returns a new array with the value appended) and `push` (appends in place), and the image helpers `srcset`, `exif`, `images`: ```nunjucks {% raw %}

{{ page.title }}

{{ "src/icons/logo.svg" | svg }}{% endraw %} ``` For markdown source, run `markdown` before `toc` so code-fence content doesn't get misread as headings: ```nunjucks {% raw %}{{ page.content | markdown | toc }}{% endraw %} ``` Next: [Images & galleries](images-gallery). --- # Build a Static Site URL: https://stamat.info/poops/docs/static-site This is where Poops stops being "just a bundler." The `markup` pipeline is a full static site generator: templates, front matter, collections, pagination, an image tag, a search index, a sitemap and a navigation tree β€” all generated in a single pass. A typical static-site project looks like this: ```text my-site/ β”œβ”€ poops.json β”œβ”€ package.json └─ src/ β”œβ”€ scss/ β†’ compiled to dist/css β”œβ”€ js/ β†’ bundled to dist/js β”œβ”€ static/ β†’ copied to dist └─ markup/ β”œβ”€ _layouts/ β†’ base templates (not emitted) β”œβ”€ _partials/ β†’ includes (not emitted) β”œβ”€ _data/ β†’ JSON/YAML globals β”œβ”€ index.md β”œβ”€ about.md └─ blog/ β”œβ”€ index.html └─ first-post.md ``` A config that ties it together: ```json { "styles": [ { "in": "src/scss/index.scss", "out": "dist/css/styles.css", "options": { "minify": true } } ], "scripts": [ { "in": "src/js/main.ts", "out": "dist/js/scripts.js", "options": { "minify": true } } ], "markup": { "in": "src/markup", "out": "dist", "options": { "site": { "title": "My Site", "description": "Built with Poops.", "url": "https://example.com" }, "includePaths": ["_layouts", "_partials"], "searchIndex": "search-index.json", "sitemap": "sitemap.xml", "nav": "nav.json" } }, "copy": [{ "in": "src/static", "out": "dist" }], "serve": { "port": 4040, "base": "/dist" }, "livereload": true, "watch": ["src"] } ``` > [!TIP] > This very documentation site is built with Poops. The layout, sidebar, search box and code copy > buttons you're using right now come out of exactly this pipeline β€” it's dogfooded. Work through the pieces: - [Building pages](pages) β€” layouts, partials, front matter, Markdown. - [Images & galleries](images-gallery) β€” responsive images and a photo grid. - [A documentation site](docs-site) β€” the sidebar nav tree, like this site. - [A blog with collections](blog-collections) β€” posts, sorting, pagination, RSS. - [React components](react-components) β€” pre-render components into pages. - [A complete React static site](react-static-site) β€” a full hydrated SSG. > [!INFO] > Poops generates a **search index**, **sitemap** and **navigation tree** automatically when you > add `searchIndex`, `sitemap` and `nav` to the markup config. All three come from your pages' > front matter in one build pass. --- # Using images & creating a gallery URL: https://stamat.info/poops/docs/static-site/images-gallery.html Images in Poops are two cooperating parts: 1. **Processing** β€” the `images` key runs [poops-images](https://github.com/stamat/poops-images) to resize, convert (WebP/AVIF), crop and read EXIF. It writes variants and a `.poops-images-cache.json`. 2. **Markup** β€” the `{% raw %}{% image %}{% endraw %}` tag and the `exif`/`images` filters read that cache and emit correct HTML. > [!WARNING] > poops-images (and its `sharp` dependency) is **not** bundled with Poops. Install it only if you > use the `images` key: `npm i poops-images`. If the key is present but the package isn't > installed, Poops logs a warning and skips image processing β€” the rest of the build still runs. ## Processing images ```json { "images": { "in": "src/images", "out": "dist/images", "sizes": [{ "width": 640 }, { "width": 1280 }], "format": "smart" } } ``` - **`in` / `out`** β€” keep `out` distinct from `in` and outside your watched sources, so generated variants don't retrigger the build. - **`sizes`** β€” responsive widths to generate. - **`format`** β€” e.g. `["webp"]`, or `"smart"` to keep whichever of JPEG/WebP is smaller. Images are processed **before** markup, so the `{% raw %}{% image %}{% endraw %}` tag and `images` filter always read a fresh cache. ## Emitting a responsive image ```nunjucks {% raw %}{% image 'images/hero.jpg', alt='Sunrise', sizes='(max-width: 640px) 100vw, 50vw' %}{% endraw %} ``` With the poops-images cache present you also get exact `width`/`height` attributes (no layout shift), correct `src` when the source format was converted, and EXIF via the `exif` filter. ## Custom sizes (named crops) A plain size (`{ "width": 640 }`) is one rung of the responsive ladder. A **named** size is a fixed crop of its own β€” WordPress-style `add_image_size` β€” for a thumbnail, a hero banner, a social card: each has its own dimensions and crop anchor, independent of the responsive widths. ```json { "images": { "in": "src/images", "out": "dist/images", "sizes": [ { "width": 640 }, { "width": 1280 }, { "name": "thumb", "width": 200, "height": 200, "crop": true }, { "name": "hero", "width": 1600, "height": 500, "crop": ["center", "top"] } ], "format": ["webp"] } } ``` For `photo.jpg` that writes `photo-640w.webp` and `photo-1280w.webp` (the responsive ladder) plus `photo-thumb-200w.webp` and `photo-hero-1600w.webp` (the named crops). The plain-width variants feed the responsive `srcset` automatically; named crops (and preprocessed variants like `photo-blurred-640w.webp`) are deliberately kept out of it β€” they have their own aspect ratios. To emit one, pass the size name as the **`size` kwarg** β€” Poops pulls that whole crop group from the compile cache and builds its own srcset, so soft crops (no fixed height) resolve too (needs [poops-images](https://github.com/stamat/poops-images) β‰₯ 1.2.1): ```nunjucks {% raw %}{# responsive: uses the -640w / -1280w ladder #} {% image 'images/photo.jpg', alt='Hero', sizes='100vw' %} {# just the 200Γ—200 thumb crop #} {% image 'images/photo.jpg', size='thumb', alt='', sizes='200px' %} {# just the hero banner crop #} {% image 'images/photo.jpg', size='hero', alt='' %}{% endraw %} ``` Each named reference resolves to that crop group, e.g. ``. ## A photo gallery The `images` filter lists every image under a directory from the cache. Combine it with `groupby`, engine-native sorting and the image tag, and a gallery is pure templating β€” no manual list to maintain. ```nunjucks {% raw %}{% for group in 'images' | images | sort(reverse=true, attribute='date') | groupby("date", "year") %}

{{ group.key }}

{% for img in group.items %}
{% image img.path, alt='', sizes='(max-width: 640px) 50vw, 25vw' %} {% if img.exif and img.exif.gps %}
πŸ“ {{ img.date | date("MMM D, YYYY") }}
{% endif %}
{% endfor %}
{% endfor %}{% endraw %} ``` Each `img` exposes `path` (feeds straight into the image tag), `width`, `height`, `date` (EXIF date if present, else file mtime), `exif`, and `outputs` (every generated file). ## EXIF captions The `exif` filter returns camera, exposure, timestamp and GPS metadata: ```nunjucks {% raw %}{% set meta = 'images/photo.jpeg' | exif %}
{% image 'images/photo.jpeg', alt='At dusk' %} {% if meta %}
{{ meta.dateTime | date("MMMM D, YYYY") }} {% if meta.gps %} β€” {{ meta.gps.latitude.formatted }}, {{ meta.gps.longitude.formatted }}{% endif %} {% if meta.model %} Β· {{ meta.model }}{% endif %}
{% endif %}
{% endraw %} ``` > [!TIP] > In watch mode, adding a source image processes it and rebuilds the galleries that reference it; > deleting one removes its variants and updates the galleries. You never hand-edit an image list. Next: [A documentation site](docs-site). --- # Building a documentation site URL: https://stamat.info/poops/docs/static-site/docs-site.html You are reading one. The topbar, the left sidebar, the search box, the copy buttons on the code and the coloured callouts are [`poops-docs-theme`](https://www.npmjs.com/package/poops-docs-theme) β€” a layout, a stylesheet and a script this site pulls out of `node_modules`. Underneath it is a plain Poops build: the theme reads the same `nav` tree and `search-index.json` that any Poops site can generate, so nothing it does is closed to you. Two routes, and the second is not a consolation prize: | | The theme | Chrome you write | | --- | --- | --- | | You write | a front-matter line and two config keys | a layout, a recursive macro, some CSS and JS | | You get | topbar, sidebar, search, TOC, breadcrumb, dark mode, copy buttons, edit link | exactly what you built, nothing else | | The design is | the theme's β€” tokens are overridable, the markup is not | yours | | It costs | one devDependency, and living with its opinions | an afternoon, and then maintaining it | The theme comes first below, then the pieces underneath it β€” which is also the order you want if you are writing your own, because the tree and the index are the same either way. ## The theme route **1. Install it.** It peer-depends on Poops **β‰₯ 2.0.0**. ```bash npm install --save-dev poops-docs-theme ``` **2. Point your pages at the layout** in front matter: ```yaml --- layout: poops-docs-theme/docs --- ``` `poops-docs-theme/prose` is the other one: the same topbar, one article, no sidebar and no search β€” for a one-page project. Pick one per page; the two bundles are alternatives, not layers, and loading both means loading everything twice. **3. Compile its stylesheet and script** into your output. The layout links `css/docs.min.css` and `js/docs.min.js`, so those are the names to land on: ```json { "styles": [{ "in": "node_modules/poops-docs-theme/scss/docs.scss", "out": "dist/css/docs.css", "options": { "minify": true, "justMinified": true } }], "scripts": [{ "in": "node_modules/poops-docs-theme/src/docs.ts", "out": "dist/js/docs.js", "options": { "minify": true, "justMinified": true, "format": "iife" } }] } ``` `justMinified` drops the unminified twin, so `docs.css` is emitted as `docs.min.css` and nothing beside it. Swap in `scss/prose-only.scss` and `src/prose.ts` for the prose layout. To skip compiling altogether, `copy` the theme's own `dist/css` and `dist/js` β€” it ships both built. **4. Generate what the layout reads.** The sidebar is the `nav` tree and the search box is `search-index.json`. Both are markup options, and without them the layout renders a docs site with no navigation and a search field that finds nothing: ```json "markup": { "options": { "nav": { "out": "nav.json", "root": "docs", "collections": "index" }, "searchIndex": "search-index.json" } } ``` ### The topbar Everything in the bar comes out of `site`: ```json "markup": { "options": { "site": { "brand": "Poops", "brandMark": "πŸ’©", "repo": "https://github.com/stamat/poops", "branch": "main", "links": [{ "title": "Changelog", "url": "changelog" }], "iconLinks": [ { "title": "npm", "url": "https://www.npmjs.com/package/poops", "icon": "npm" } ] } } } ``` | Key | What it puts in the bar | | --- | --- | | `brand` | the title, falling back to `site.title`. Links to the site root, or to `brandUrl`. | | `brandMark` | the emoji beside it β€” also the tab icon, drawn inline, so there is no favicon file to make. | | `repo` | the GitHub button, and with `branch` the edit link at the foot of each page. Falls back to `package.homepage`; omit both and the button disappears. | | `links` | labelled nav links. Site-relative urls get the page's path prefix, absolute ones open in a new tab, and the section you are in is marked with `aria-current` rather than hidden. | | `iconLinks` | the same list without labels β€” a package registry, a chat room. `title` becomes the `aria-label`. | | `footer` | html, unescaped, replacing the default brand/version/license line. | | `theme` | pins light or dark and drops the switch. | Both lists take an `icon`: `github`, `npm` and `package` draw built-in marks, and anything else is printed as given, so an emoji or a pasted `` works too. The links row measures itself rather than folding at a width someone typed β€” a link that stops fitting moves into a **More** panel, and when the window is under 40rem the whole row becomes a drawer. The sidebar does the same at 60rem. Neither is modal: `Tab` reaches every link and Escape closes what is open. The rest of the theme's config β€” pinning the colour scheme, overriding the tokens, embedding live samples β€” is in [its README](https://github.com/stamat/poops-docs-theme#readme). ### Typing the pages The `jsonld` filter types a dateless page as `WebPage`. Documentation is `TechArticle`, and it is one setting for the whole site rather than a line in every page's front matter: ```json "markup": { "options": { "site": { "jsonld": { "@type": "TechArticle" } } } } ``` ## The navigation tree Both routes need this one. Add `nav` to the markup config and Poops builds a nested navigation tree from your pages' front matter and URL structure β€” `guide/index.md` becomes a parent node; `guide/getting-started.md` becomes its child. ```json { "markup": { "in": "src/markup", "out": "dist", "options": { "nav": { "out": "nav.json", "collections": "index", "home": true }, "searchIndex": "search-index.json", "sitemap": "sitemap.xml" } } } ``` The tree is exposed two ways: - as the **`nav` global** on every page (built in a pre-pass, always current), - and as **`nav.json`** for client-side rendering. > [!TIP] > Render the sidebar from the `nav` global, not from `nav.json` loaded via `data`. The global > always reflects the current build; the loaded file would be one build behind. ### Front matter that shapes the tree | Field | Effect | | --- | --- | | `order` | Number that sorts a page among its siblings. Unordered pages fall to the bottom, alphabetically. | | `navTitle` | Sidebar label that overrides `title`. | | `nav: false` | Hide the page from the sidebar (still indexed and in the sitemap). | So a hand-authored sequence wins over alphabetical: give your intro `order: 0`, the next section `order: 1`, and so on. ## Rendering the sidebar The theme does this for you. Writing your own: the tree is arbitrarily deep, so render it with a self-recursing macro, and prefix each `url` with `relativePathPrefix` so links resolve from any depth: ```nunjucks {% raw %}{% macro navtree(items) %}
    {% for item in items %}
  • {% if item.url != null %} {{ item.title }} {% else %} {{ item.title }} {% endif %} {% if item.children %}{{ navtree(item.children) }}{% endif %}
  • {% endfor %}
{% endmacro %} {{ navtree(nav) }}{% endraw %} ``` > [!WARNING] > Use `item.url != null`, not `if item.url`. The homepage node's `url` is an empty string β€” a > valid link β€” while synthesized section nodes have no `url` at all. A plain truthiness check > wrongly demotes the homepage to a ``. ## Admonitions (info / tip / warning) Poops parses GitHub-style alert blockquotes during markdown render (via `marked-github-alerts`). Author them as: ```markdown > [!TIP] > This becomes a green "Tip" callout. Markdown **inside** it still renders. > [!WARNING] > A red "Warning" callout. > [!INFO] > A blue "Info" callout. ``` They render as alert `
` blocks with type classes (`-tip`, `-warning`, etc.), so markdown inside the callout still works. The theme styles all five flavours already. Without it, include the default styles once: ```html ``` ## Copy buttons on code The theme's script does this. Otherwise it is another few lines of JS: wrap every `
` and
inject a **Copy** button that calls `navigator.clipboard.writeText`. No build step, no dependency
β€” it runs on the rendered output.

## Search

`searchIndex` writes a `search-index.json` β€” every page's front matter plus auto-extracted
keywords. The theme's script fetches it and filters by title/description/keywords as you type;
that is the search box at the top of this page. On your own chrome the index is the same file and
the filtering is yours to write.

> [!INFO]
> The search index strips internal fields (`content`, `layout`, …) and, per page, keeps up to
> `maxKeywords` keywords. Provide your own `keywords` array in front matter to override the
> auto-extracted ones.

## "Edit this page on GitHub"

Every page carries `page.filePath` β€” its source file 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 β€” and it is the line the theme already writes
from `site.repo` and `site.branch`:

```nunjucks
{% raw %}{% set repoUrl = site.repo or package.homepage %}
{% if page.filePath and repoUrl %}
✏️ Edit this page on GitHub
{% endif %}{% endraw %}
```

Set the repo and branch in your `site` data (or let it fall back to `package.homepage` and `main`):

```json
{
  "markup": {
    "options": {
      "site": { "repo": "https://github.com/you/your-repo", "branch": "main" }
    }
  }
}
```

Don't reconstruct the path from `page.url` β€” that is the output URL (`.html`, and `index.md`
collapses to a directory), so it can't be reversed to the `.md` source. Use `page.filePath`.

## The result

Two config keys and a line of front matter, if the theme's design suits you. The same two keys,
a recursive macro and a sprinkle of vanilla JS, if it does not. No separate documentation
framework either way: the tree, the index and the sitemap belong to the `markup` pipeline, and
the chrome around them is a choice.

Next: [A blog with collections](blog-collections).

---

# Building a blog with collections
URL: https://stamat.info/poops/docs/static-site/blog-collections.html

A **collection** turns a directory of pages into a sorted, optionally paginated list β€” blog posts,
changelog entries, docs. Each direct subdirectory of your markup `in` can be a collection; every
file inside it (except `index.*`) becomes an item.

## Declaring a collection

**Option A β€” front matter** on the directory's `index` file:

```yaml
---
title: Blog
collection: true
paginate: 10
sort: date
---
```

`collection: true` uses the directory name; a string names it explicitly. **Option B β€” config**,
listing collections by name (must match a subdirectory of `in`):

```json
{
  "markup": {
    "in": "src/markup",
    "out": "dist",
    "options": {
      "collections": [
        "changelog",
        { "name": "blog", "paginate": 5, "sort": { "by": "date", "order": "desc" } }
      ]
    }
  }
}
```

## Writing a post

A post is a normal Markdown file with front matter:

```markdown
---
layout: post
title: Hello world
date: 2026-07-09
description: My first post built with Poops.
tags: [poops, static-site]
published: true
---

Welcome to the blog.
```

> [!WARNING]
> Always set a real `date` in front matter. Undated posts fall back to the file's modification
> time β€” meaningless on CI, where a fresh `git clone` resets mtimes, so posts would reshuffle
> between deploys. A post with `published: false` is excluded and its page isn't built.

## Listing posts

Every collection is a global named after it. Loop its `items`:

```nunjucks
{% raw %}{% for post in blog.items %}
  

{{ post.title }}

{{ post.description }}

{% endfor %}{% endraw %} ``` Each item carries its front matter plus `url`, `title`, `date`, `wordcount`, `fileName`, `filePath` and `collection`. ## Pagination With `paginate: N`, the collection's index renders once per page: page 1 β†’ `blog/index.html`, page 2 β†’ `blog/2/index.html`, and so on. Inside the index, the collection object carries page state β€” `pageItems`, `pageNumber`, `totalPages`, `pageUrl`, `nextPageUrl`, `prevPageUrl`: Set `paginate: N` on the collection index front matter (or the collection entry in `markup.collections`); without it, there is only one page and the pagination globals stay at their single-page defaults. ```nunjucks {% raw %}{% for post in blog.pageItems %}

{{ post.title }}

{% endfor %} {% pagination blog %}{% endraw %} ``` `{% raw %}{% pagination blog %}{% endraw %}` works in both Nunjucks and Liquid. > [!NOTE] > `{% raw %}{% pagination blog %}{% endraw %}` is just a convenience tag. The generated globals > are always available, so you can render pagination manually when you need custom markup. ```nunjucks {% raw %}{% if blog.totalPages > 1 %} {% endif %}{% endraw %} ``` Pages 2..N automatically get a distinct `` β€” `Blog β€” Page 2` β€” so the paginated pages don't all share the landing page's title (and its `og`/`jsonld` metadata). Page 1 keeps its own title. ### Localizing the labels The `β€” Page N` title suffix and the `{% raw %}{% pagination %}{% endraw %}` tag's wording default to English. Override them site-wide under `site.pagination` (the same term-page titles and breadcrumbs localize too β€” see [Tags & categories](#tags-categories-taxonomies)): ```yaml site: pagination: title: "{title} β€” Seite {n}" # {title}, {n}, {total} tokens; used on pages 2..N prev: ZurΓΌck next: Weiter of: von # the "{n} von {total}" separator ``` ## Grouping posts by year The `groupby` filter groups any array of objects by a field, with optional date-part extraction. Groups keep insertion order, so sort descending and years come out newest-first: ```nunjucks {% raw %}{% for group in blog.items | groupby("date", "year") %} <h2>{{ group.key }}</h2> {% for post in group.items %} <p><a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a></p> {% endfor %} {% endfor %}{% endraw %} ``` ## Tags & categories (taxonomies) Grouping lists terms on one page. A **taxonomy** goes further: it gives every term its own paginated landing page β€” `changelog/tag/feature/`, `changelog/category/release/` β€” crawlable and shareable. Declare which front-matter fields become taxonomies on the collection, alongside `paginate`/`sort`: ```yaml --- 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`. Array-valued fields split per element β€” a post with `tags: [js, css]` lands under **both** `tag/js/` and `tag/css/`. Terms are slugified for the URL (`Static Site` β†’ `static-site`). Pages render with the **collection's own index template** β€” no extra file. On a term page the collection object carries the term context; branch on `activeTerm` to render a term view: ```nunjucks {% 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 %} ``` On a term page `items`/`pageItems` are scoped to that term (so `pagination` and `groupby` narrow to it too); `activeTaxonomy` holds the URL segment and `activeTermSlug` the slug. Build tag links anywhere from `collection.taxonomies`: ```nunjucks {% raw %}{% for tax in changelog.taxonomies %} {% for term in tax.terms %} <a href="{{ relativePathPrefix }}{{ term.url }}">{{ term.term | humanize }} ({{ term.count }})</a> {% endfor %} {% endfor %}{% endraw %} ``` Each term exposes `term`, `slug`, `url`, `count` and `totalPages`. The `slugify` and `humanize` filters (inverses of each other) are handy for building and displaying terms. Each term page also gets a distinct `<title>` and `og`/`jsonld` metadata β€” `Tag: Feature` (paged: `Tag: Feature β€” Page 2`) β€” instead of the shared landing title. The `breadcrumb` and `jsonld` filters resolve term pages to a **Home β€Ί Collection β€Ί Tag: Term** trail automatically (the last crumb carries the same taxonomy label, and the non-page `tag`/`category` URL segment is skipped), so nothing extra is needed there. > [!NOTE] > Term pages are treated like pagination pages: listed in the **sitemap** (crawlable) but kept out > of the **search index**, **llms.txt** and **nav**, so those point at posts, not term listings. ## Sorting `sort` is a field shorthand (`"sort": "title"`) or an object `{ "by": "field", "order": "asc" | "desc" }`. Sorting by `date` compares dates (default `desc`); any other field compares alphabetically (default `asc`). ## RSS / Atom feed Point the `feed` option at the collection and Poops writes a subscription feed β€” no hand-authored XML template. Items are the posts newest-first by `date`, channel metadata comes from your `site` data: ```json { "markup": { "options": { "feed": { "collection": "blog", "out": "blog/feed.rss" } } } } ``` `type: "atom"` switches format, `limit` caps the item count (default 20), and omitting `collection` emits a feed for every collection. Then advertise it in your layout `<head>`: ```html {% raw %}<link rel="alternate" type="application/rss+xml" href="{{ site.url }}/blog/feed.rss">{% endraw %} ``` Full option table in the [config reference](../config-reference#markup-feed). > [!INFO] > Collection index and pagination pages are included in the **sitemap** but excluded from the > **search index**, so search results point at posts, not list pages. Next: [React components](react-components). --- # Build a React App URL: https://stamat.info/poops/docs/react-app When you want a straightforward client-rendered single-page app β€” no build-time rendering, no hydration β€” Poops is just your bundler. One `scripts` entry points at your JSX/TSX entry, esbuild does the rest. ## Project layout ```text react-app/ β”œβ”€ poops.json β”œβ”€ package.json # react + react-dom installed here └─ src/ β”œβ”€ scss/index.scss β”œβ”€ js/ β”‚ β”œβ”€ main.tsx # entry: mounts the app β”‚ β”œβ”€ App.tsx β”‚ └─ components/ └─ markup/ └─ index.html # the shell with <div id="root"> ``` ## The entry ```tsx // src/js/main.tsx import { createRoot } from "react-dom/client"; import App from "./App"; createRoot(document.getElementById("root")!).render(<App />); ``` ```tsx // src/js/App.tsx import { useState } from "react"; export default function App() { const [count, setCount] = useState(0); return ( <main> <h1>Hello from Poops</h1> <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button> </main> ); } ``` ## The config ```json { "scripts": [ { "in": "src/js/main.tsx", "out": "dist/js/app.js", "options": { "minify": true, "format": "iife", "target": "es2019", "jsx": "automatic" } } ], "styles": [ { "in": "src/scss/index.scss", "out": "dist/css/styles.css", "options": { "minify": true } } ], "markup": { "in": "src/markup", "out": "dist", "options": { "site": { "title": "My React App" }, "includePaths": ["_layouts", "_partials"] } }, "copy": [{ "in": "src/static", "out": "dist" }], "serve": { "port": 4040, "base": "/dist" }, "livereload": true, "watch": ["src"] } ``` `"jsx": "automatic"` enables React 17+'s JSX runtime, so you don't `import React` in every file. ## The HTML shell Poops still builds your `index.html` β€” use `markup` for the shell so `relativePathPrefix` and `site` data work: ```html {% raw %}<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>{{ site.title }}
{% endraw %} ``` > [!NOTE] > React comes from your project's `node_modules` (`npm i react react-dom`), not from Poops β€” > see [React](../quick-start/react). ## Develop and build ```bash poops # watch + serve + livereload at http://localhost:4040 poops -b # one-off production build into dist/ ``` > [!TIP] > This is a pure SPA: nothing renders until the JS loads. If you want real HTML on first paint > (better SEO and perceived speed), pre-render with Reactor instead β€” see > [A complete React static site](../static-site/react-static-site). > [!INFO] > For a library rather than an app β€” shipping ESM/CJS/IIFE builds of your components β€” see > [Transpiling JavaScript β€Ί Maintaining a JS library](../quick-start/transpiling-js). --- # Transpiling JavaScript URL: https://stamat.info/poops/docs/quick-start/transpiling-js.html The `scripts` key bundles and transpiles JavaScript with [esbuild](https://esbuild.github.io/). It handles `.js`, `.ts`, `.jsx` and `.tsx` out of the box β€” TypeScript and JSX need no extra setup. ## A single script ```json { "scripts": [ { "in": "src/js/main.ts", "out": "dist/js/scripts.js", "options": { "sourcemap": true, "minify": true, "justMinified": false, "format": "iife", "target": "es2019" } } ] } ``` Each entry has `in`, `out` and `options`: - **`in`** β€” an entry file, an array of entry files, or a [glob pattern](#globs-and-multiple-entry-files). - **`out`** β€” the output file, a directory when `in` has multiple entries, or a [template](#naming-outputs-yourself) naming one output per entry. - **`options`** β€” mostly passed straight through to esbuild. ### Options | Option | Meaning | | --- | --- | | `sourcemap` | Emit a source map. Only for the non-minified output. Default `false`. | | `minify` | Also emit a minified file. Default `false`. | | `justMinified` | Emit **only** the minified file. Great for production. Default `false`. | | `format` | `iife`, `esm` or `cjs`. | | `target` | e.g. `es2018`, `es2019`, `esnext`. | | `jsx` | `transform` (default) or `automatic` (React 17+ runtime). | > [!TIP] > `minify: true` with `justMinified: false` emits **both** `scripts.js` and `scripts.min.js` in > one pass β€” because everyone forgets to build the minified bundle for production. ## Multiple scripts Pass an array to bundle several entries: ```json { "scripts": [ { "in": "src/js/main.ts", "out": "dist/js/scripts.js", "options": { "minify": true, "format": "iife", "target": "es2019" } }, { "in": "src/js/admin.ts", "out": "dist/js/admin.js", "options": { "minify": true, "format": "iife", "target": "es2019" } } ] } ``` ## Globs and multiple entry files `in` also accepts a glob pattern or an array of entry files β€” each becomes its own bundle, handy for per-page scripts or theme sections: ```json { "scripts": [ { "in": "src/js/pages/*.js", "out": "dist/js/", "options": { "minify": true, "format": "iife" } } ] } ``` Arrays and globs mix freely: ```json { "in": ["src/js/main.ts", "src/js/pages/*.ts"], "out": "dist/js/" } ``` Brace alternates count as a glob on their own, so a pattern needs no `*` to match "whichever extension this entry happens to use": ```json { "in": "src/js/pages/*.{js,ts,jsx,tsx}", "out": "dist/js/" } ``` > [!NOTE] > With more than one entry file, `out` must be a directory. Entry points from different > directories nest their output under their common ancestor β€” `src/js/a/main.js` and > `src/js/b/main.js` become `dist/js/a/main.js` and `dist/js/b/main.js`, so same-named > entries never collide. Glob patterns always use `/` as the separator, even on Windows. ### One bundle per component directory Libraries of components usually give each component a directory, which makes every entry point `index.*` β€” and nesting under the common ancestor would bury each bundle a level deep. A **glob-matched** `index.*` is named after the directory holding it instead: ```json { "in": "src/elements/*/index.{js,mjs,cjs,jsx,ts,tsx}", "out": "dist/js/" } ``` ``` src/elements/accordion/index.ts β†’ dist/js/accordion.js src/elements/tabs/index.ts β†’ dist/js/tabs.js ``` Add a component directory, get a bundle β€” no config change. The rename only applies to entries a glob matched: a literal `"in": "src/index.ts"` still writes `index.js`, and an explicit `out` file path always wins. The name is placed relative to the glob's **static prefix** β€” everything before the first `*`, `{…}` or other magic segment. Above that prefix is `src/elements`, which every match shares, so nothing is left to nest under and the output is flat. Widen the glob and the part it no longer pins down is kept, so same-named components stay apart: ```json { "in": "src/*/accordion/index.ts", "out": "dist/js/" } ``` ``` src/blocks/accordion/index.ts β†’ dist/js/blocks/accordion.js src/elements/accordion/index.ts β†’ dist/js/elements/accordion.js ``` > [!NOTE] > The prefix comes from the pattern, not from what matched, so the layout doesn't shift when you > add or remove a component. To place the bundles somewhere else, move the magic segment β€” a > narrower glob per group with its own `out` gives you full control. ### Naming outputs yourself The `index.*` rule only rescues entry points actually named `index`. Everything else keeps its own basename, nested under the common ancestor. When you want the bundles named something else, `out` can be a **template**: - **`{% raw %}{{dir}}{% endraw %}`** β€” the match's directory, relative to the glob's static prefix (the same name an `index.*` entry would get) - **`{% raw %}{{name}}{% endraw %}`** β€” the match's basename without extension ```json {% raw %}{ "in": "src/elements/*/widget.ts", "out": "dist/js/{{dir}}-{{name}}.js" }{% endraw %} ``` ``` src/elements/accordion/widget.ts β†’ dist/js/accordion-widget.js src/elements/tabs/widget.ts β†’ dist/js/tabs-widget.js ``` One bundle per match, named by you rather than by the common ancestor. Tokens may carry spaces (`{% raw %}{{ dir }}{% endraw %}`) and can sit in directory segments, so `{% raw %}"out": "dist/js/{{dir}}/widget.js"{% endraw %}` writes `dist/js/accordion/widget.js`. For a literal entry, `{% raw %}{{dir}}{% endraw %}` is that entry's own directory name, so arrays mixing globs and plain paths keep working. An extension in the template is honoured, which is the cheap way to ship one format per entry: ```json {% raw %}{ "in": "src/elements/*/index.ts", "out": "dist/esm/{{dir}}.mjs", "options": { "format": "esm" } }{% endraw %} ``` > [!NOTE] > A template wins over the `index.*` rename β€” you named the outputs, so nothing renames them behind > your back. A template that can't tell two matches apart > (`{% raw %}"out": "dist/js/{{name}}.js"{% endraw %}` across component directories) fails the build > with esbuild's *"Two output files share the same path"* rather than overwriting β€” that's what > `{% raw %}{{dir}}{% endraw %}` is for. Styles take the same templates β€” see > [Transpiling CSS](transpiling-css#naming-outputs-yourself). ## Maintaining a JS library Poops is genuinely good at library work: author once in TypeScript, ship every module format your users need. The trick is one `scripts` entry per target `format`, all reading the same entry file. ```json {% raw %}{ "scripts": [ { "in": "src/index.ts", "out": "dist/mylib.esm.js", "options": { "format": "esm", "target": "es2019", "minify": true } }, { "in": "src/index.ts", "out": "dist/mylib.cjs.js", "options": { "format": "cjs", "target": "es2019", "minify": true } }, { "in": "src/index.ts", "out": "dist/mylib.global.js", "options": { "format": "iife", "target": "es2019", "minify": true } } ], "banner": "/* {{ name }} v{{ version }} | {{ homepage }} | {{ license }} License */" }{% endraw %} ``` That gives you: - **TypeScript β†’ vanilla JS** β€” esbuild strips the types and downlevels to your `target`. - **ESM** for modern bundlers and `{% endraw %} ``` Server-only (no hydration)? Omit `in`/`out`: ```json { "reactor": [ { "component": "src/js/App.jsx", "inject": "app_html" } ] } ``` > [!INFO] > `reactor` is its own pipeline with its own watcher and build step. Changes to the component > re-render and re-bundle; markup recompiles only when the rendered output actually changes. > Plain JS/TS edits only trigger the `scripts` pipeline β€” the two are independent. > [!NOTE] > Poops does **not** depend on `react`/`react-dom`. They are resolved from *your* project's > `node_modules`. Install them yourself: `npm i react react-dom`. ## Which should I use? | Goal | Use | | --- | --- | | Interactive SPA, SEO not critical | `scripts` + `createRoot` | | Static HTML pages that don't need JS | `reactor` (no `in`/`out`) | | Static HTML that hydrates into an app | `reactor` with `in`/`out` | Worked examples: [React components](../static-site/react-components), [A complete React static site](../static-site/react-static-site), and [Build a React App](../react-app/). --- # PostCSS & Tailwind URL: https://stamat.info/poops/docs/quick-start/postcss-tailwind.html The `postcss` key runs a [PostCSS](https://postcss.org/) pipeline. It is **separate** from the Sass `styles` pipeline β€” use it for [Tailwind CSS](https://tailwindcss.com/), [Autoprefixer](https://github.com/postcss/autoprefixer), or any other PostCSS plugin. > [!WARNING] > PostCSS and its plugins are **not** bundled with Poops. Install what you use in your own project: > `npm i -D postcss @tailwindcss/postcss tailwindcss`. ## Shape ```json { "postcss": { "in": "src/css/main.css", "out": "dist/css/main.css", "options": { "plugins": ["@tailwindcss/postcss"], "minify": true } } } ``` - **`in` / `out`** β€” input and output CSS files. - **`options.plugins`** β€” array of plugin names. Each is a string, or a tuple `["name", { opts }]`. - **`options.minify` / `options.justMinified`** β€” same behaviour as the other pipelines. Pass options to a plugin with the tuple form: ```json { "postcss": { "in": "src/css/main.css", "out": "dist/css/main.css", "options": { "plugins": [["autoprefixer", { "grid": true }]] } } } ``` ## Tailwind CSS v4 Install the deps, then your entry CSS just imports Tailwind: ```css @import "tailwindcss"; ``` Config: ```json { "postcss": { "in": "src/css/main.css", "out": "dist/css/main.css", "options": { "plugins": ["@tailwindcss/postcss"], "minify": true } }, "markup": { "in": "src/markup", "out": "dist", "options": { "includePaths": ["_layouts", "_partials"] } }, "watch": ["src"] } ``` Then use Tailwind utility classes in your markup. Tailwind v4 auto-detects content sources, so no `tailwind.config.js` is required. > [!INFO] > **Build order matters.** PostCSS runs *after* Styles and Markups. That is deliberate β€” Tailwind > scans the compiled HTML for utility classes, so the markup must exist first. In watch mode, > editing a template re-triggers the PostCSS pass. ## Sass and Tailwind together Keep them as two pipelines writing to two files: Sass compiles your `.scss`, PostCSS handles Tailwind independently. They don't need to chain. If you *do* want PostCSS to post-process the Sass output (say, Autoprefixer over compiled Sass), point `postcss.in` at the Sass output file and `postcss.out` at a **different** file so the original isn't overwritten mid-build. > [!TIP] > Most projects want either Sass *or* Tailwind, not both. Reach for the `styles` pipeline for > authored SCSS, and the `postcss` pipeline for utility-first Tailwind. Next: [React](react). --- # Quick Start URL: https://stamat.info/poops/docs/quick-start Poops is driven by a single config file: **`poops.json`** (or `πŸ’©.json`) in your project root. Every feature is a top-level key. You opt into the pipelines you need and delete the rest. ## Run it If Poops is installed globally, from your project root run: ```bash poops # or πŸ’© ``` Pass a custom config when you juggle multiple environments: ```bash poops staging.json # or πŸ’© staging.json ``` Installed locally, use `npx` or a `package.json` script: ```json file=package.json { "scripts": { "build": "npx poops" } } ``` ## CLI options | Flag | Short | Description | | ---------------------------- | ----- | -------------------------------------------- | | `--build` | `-b` | Build once and exit (no watch/serve) | | `--config ` | `-c` | Use a specific config file | | `--port ` | `-p` | Server port, overrides config | | `--base-url ` | `-u` | Base URL prefix for markup, overrides config | `--base-url` is the one you'll reach for in CI, where the deploy path differs per environment: ```bash poops --build --base-url /blog ``` ## The shape of the config Here is a config that exercises most pipelines at once. You will rarely need all of it β€” treat it as a menu. ```json { "scripts": [ { "in": "src/js/main.ts", "out": "dist/js/scripts.js", "options": { "minify": true, "format": "iife", "target": "es2019" } } ], "styles": [ { "in": "src/scss/index.scss", "out": "dist/css/styles.css", "options": { "sourcemap": true, "minify": true } } ], "markup": { "in": "src/markup", "out": "dist", "options": { "site": { "title": "My Site", "description": "A site built with Poops." }, "includePaths": ["_layouts", "_partials"] } }, "copy": [{ "in": "src/static", "out": "dist" }], "serve": { "port": 4040, "base": "/dist" }, "livereload": true, "watch": ["src"] } ``` Every key is independent: - **`scripts`** β€” bundle JS/TS/JSX/TSX. See [Transpiling JavaScript](transpiling-js). - **`styles`** β€” compile SCSS/Sass. See [Transpiling CSS](transpiling-css). - **`postcss`** β€” a separate CSS pipeline for Tailwind & PostCSS plugins. See [PostCSS & Tailwind](postcss-tailwind). - **`markup`** β€” the static site generator. See [Templating HTML](templating-html). - **`reactor`** β€” build-time React rendering. See [React](react). - **`copy` / `serve` / `livereload` / `watch`** β€” static files and the dev loop. Bolting Poops onto WordPress, Laravel, Rails or Django instead of building a full static site? See [Use with frameworks](frameworks). > [!INFO] > Poops reads your project's `package.json` automatically and exposes it to templates as the > `package` global. So `{% raw %}{{ package.version }}{% endraw %}` just works β€” handy for a > library landing page. > [!WARNING] > Removing a pipeline key is how you disable it. There is no `"enabled": false` flag β€” if a key > isn't in the config, that pipeline never runs. ## The idea The whole design is: **inputs and outputs, nothing hidden.** You should be able to read a `poops.json` top to bottom and know exactly what files come out and where. No implicit magic directories, no convention you have to memorize, no plugin resolution order. That readability is worth more than cleverness β€” it is the reason Poops exists. Next: pick a pipeline. Most people start with [Transpiling JavaScript](transpiling-js) or jump straight to [Build a Static Site](../static-site/). --- # Use with WordPress, Laravel, Rails, Django… URL: https://stamat.info/poops/docs/quick-start/frameworks.html Poops does not own your project. It reads input paths and writes output paths β€” so it slots in as the **front-end asset toolchain** for any server-side framework. Your framework serves the pages; Poops builds the CSS and JS. The pattern is always the same: keep your sources somewhere sensible, and point `out` at wherever your framework serves static assets from. ## WordPress Build into your theme directory: ```json { "scripts": [ { "in": "assets/js/theme.ts", "out": "wp-content/themes/mytheme/js/theme.js", "options": { "minify": true, "format": "iife", "target": "es2019" } } ], "styles": [ { "in": "assets/scss/theme.scss", "out": "wp-content/themes/mytheme/css/theme.css", "options": { "minify": true } } ], "watch": ["assets"], "livereload": true } ``` Then `wp_enqueue_style`/`wp_enqueue_script` the built files from your theme. Skip Poops' `markup` key entirely β€” WordPress renders the HTML. ## Laravel Replace Vite/Mix for simple projects. Build into `public/`: ```json { "scripts": [ { "in": "resources/js/app.ts", "out": "public/js/app.js", "options": { "minify": true, "format": "iife", "target": "es2019" } } ], "styles": [ { "in": "resources/scss/app.scss", "out": "public/css/app.css", "options": { "minify": true } } ], "watch": ["resources"] } ``` Reference them with `asset('css/app.css')` in your Blade templates. ## Ruby on Rails Build into `app/assets/builds/` (or `public/`) and let the asset pipeline or `propshaft` pick them up: ```json { "scripts": [ { "in": "app/javascript/application.ts", "out": "app/assets/builds/application.js", "options": { "minify": true, "format": "esm", "target": "es2019" } } ], "styles": [ { "in": "app/assets/stylesheets/application.scss", "out": "app/assets/builds/application.css", "options": { "minify": true } } ], "watch": ["app/javascript", "app/assets/stylesheets"] } ``` ## Django Build into a `static/` directory that `collectstatic` will gather: ```json { "scripts": [ { "in": "frontend/js/main.ts", "out": "myapp/static/js/main.js", "options": { "minify": true, "format": "iife", "target": "es2019" } } ], "styles": [ { "in": "frontend/scss/main.scss", "out": "myapp/static/css/main.css", "options": { "minify": true } } ], "watch": ["frontend"] } ``` Load them with `{% raw %}{% static 'css/main.css' %}{% endraw %}` in your Django templates. > [!TIP] > Run `poops` (no `-b`) in a second terminal during development for a watch + LiveReload loop > while your framework's own dev server serves the app. Use `poops -b` in your build/CI step. > [!WARNING] > When you use Poops purely as a bundler, drop the `markup`, `serve` and (usually) `copy` keys. > Let the framework handle routing, HTML and static file serving β€” Poops only produces the asset > bundles. > [!INFO] > The same approach works for Rails/Django/Laravel or anything else: Symfony, Phoenix, Express, > Hugo, plain PHP. If it serves files from a folder, Poops can fill that folder. Next: [PostCSS & Tailwind](postcss-tailwind). --- # Poops vs webpack, Rollup & Parcel URL: https://stamat.info/poops/docs/comparisons/webpack-rollup-parcel.html The webpack config that nobody on the team can read is a genre. It starts as twenty lines, acquires a loader for Sass, a loader for the loader, a plugin to extract the CSS the loaders just inlined, a `resolve.alias` block, and a comment saying *do not touch this, it took a day*. Two years later the project is a landing page with a contact form, and the toolchain has more dependencies than the site has pages. Poops is the reaction to that: `in`, `out`, options β€” and no place for that config to grow into. ## Where the three stand All read from npm in August 2026: | | Latest | Notes | | --- | --- | --- | | [webpack](https://www.npmjs.com/package/webpack) | 5.109.x | actively maintained; the [2026 roadmap](https://webpack.js.org/blog/2026-02-04-roadmap-2026/) targets native CSS support, built-in TypeScript and a path to webpack 6 | | [Rollup](https://www.npmjs.com/package/rollup) | 4.62.x | frequent releases; still the reference for library bundling, and the plugin API Rolldown kept | | [Parcel](https://www.npmjs.com/package/parcel) | 2.16.x | zero-config by design; last release six months before this page was written | | [Rspack](https://rspack.rs) | 2.x | not compared here β€” a Rust, webpack-compatible bundler, and the thing to look at if the reason you are reading this page is *webpack is slow* | ## Feature by feature | | Poops | webpack | Rollup | Parcel | | --- | --- | --- | --- | --- | | Config | one JSON file | `webpack.config.js`, loaders + plugins | `rollup.config.js`, plugins | none for the common case | | Learning surface | the keys in [the config reference](../config-reference) | loaders, rules, plugins, resolve, optimization | plugin lifecycle hooks | its conventions, when you need to leave them | | Escape hatch | esbuild options pass through | a loader or plugin | a plugin | a `.parcelrc` transformer | | Sass | built in | `sass-loader` + `css-loader` + `MiniCssExtractPlugin` | a plugin | built in | | PostCSS / Tailwind | own config key | `postcss-loader` | a plugin | built in | | HTML pages, layouts, front matter | built in, Nunjucks or Liquid | `html-webpack-plugin`, one page at a time | no | entry HTML, no templating | | Collections, taxonomies, RSS, sitemap, search index | built in | no | no | no | | Content hashing + HTML rewriting | **no** | yes | via plugins | yes | | Code splitting | esbuild's `splitting` β€” hashed chunks, but no manifest and no HTML rewriting | yes, `SplitChunksPlugin` | yes, and precise | yes | | Module federation | **no** | yes β€” its own territory | no | no | | Tree-shaken library output | esbuild's, three entries for IIFE/ESM/CJS | yes | **the best of the four** for this | yes | | Dev experience | rebuild + reload; CSS swapped in place | dev server with HMR | plugin-provided watch | dev server with HMR | | Dependencies you install | 18 direct, 41 packages | webpack + cli + loaders + plugins | rollup + plugins | parcel | ## The same job, spelled out Bundle a TypeScript entry, compile SCSS beside it, minify both, keep sourcemaps on the unminified twin. In webpack that is `ts-loader` (or `babel-loader` plus a preset), `sass-loader` β†’ `css-loader` β†’ `MiniCssExtractPlugin.loader`, a `rules` array to wire them to extensions, the plugin in `plugins`, and `optimization.minimizer` for the CSS half. In Poops it is the file itself: ```json { "scripts": [{ "in": "src/js/main.ts", "out": "dist/js/main.js", "options": { "minify": true, "sourcemap": true, "format": "iife", "target": "es2019" } }], "styles": [{ "in": "src/scss/index.scss", "out": "dist/css/styles.css", "options": { "minify": true, "sourcemap": true } }] } ``` Both emit `main.js` + `main.min.js` and `styles.css` + `styles.min.css`. The difference is that the second one has no loader order to get wrong, and the `$schema` reference at the top of the file makes the editor complete every key β€” a typo is caught as you type it rather than at build time. Add `"markup"` and the same file also builds the pages, which is the part webpack, Rollup and Parcel were never trying to do. ## The part Poops refuses There is no plugin API and there will not be one. A feature that fits in a few lines of config does not get an extension point, and one that does not fit gets argued about in an issue instead. That refusal is the whole design: it is why the dependency list is boring, why the config is JSON, and why there is no version of this project where you write a `poops.config.js` that imports four packages. The cost is real and it is exactly webpack's strength: **when Poops does not do something, you cannot bolt it on.** A custom transform for a file type nobody has heard of is a loader in webpack and a fork in Poops β€” or a [markup engine](../engine-api), which is the one interface left open, because a template language is a whole job rather than a hook. ## Use webpack, Rollup or Parcel when | If | Use | Why | | --- | --- | --- | | You need module federation or a micro-frontend split | webpack | Nothing else here does it | | You publish a library and want the smallest, cleanest ESM output with precise external control | Rollup | It has been the reference for a decade, and Poops' esbuild output is fine but blunter | | You want zero config for an app with many asset types | Parcel | Its conventions cover more file types than Poops does | | The existing config works and nobody is fighting it | leave it | A migration you do not need is a bug you introduce | | webpack is only *slow* | Rspack, or Vite | Both are drop-in-ish and Rust-fast; Poops is a different tool, not a faster webpack | ## Use Poops when - The project is a site with a front end: pages, SCSS and TypeScript in one config, one command, plain files out. - The build should be readable by whoever picks the project up next year β€” including you. JSON, no imports, with `$schema` completing the keys in the editor. - You want the toolchain to be one dependency instead of a bundler plus five loaders plus three plugins that have to agree on versions. - Nobody on the team wants to own a `webpack.config.js` again. The size of the JavaScript is not the deciding factor β€” esbuild handles large bundles fine. What decides it is whether you need managed chunks, a manifest and federation, or whether you need the config to stay small enough to read. --- # Poops vs Vite URL: https://stamat.info/poops/docs/comparisons/vite.html Vite is the default answer, and for an app it usually still is. The reason to look elsewhere shows up on the other kind of project: a marketing site, a blog, a docs site, a WordPress theme. There the work is SCSS, a bit of TypeScript, some pages with front matter β€” and Vite gives you a JavaScript config file, a plugin for Markdown, another for the sitemap, and a framework on top to turn any of it into HTML. Poops covers that project with one JSON file. It does not cover Vite's project β€” see the bottom of this page before you migrate anything. ## What each one is Vite 8 [shipped in March 2026](https://vite.dev/blog/announcing-vite8) with [Rolldown](https://rolldown.rs), a Rust bundler, replacing the old esbuild-in-dev, Rollup-in-prod split β€” one bundler for both, with the plugin API preserved. It is a dev server and a build tool for applications; HTML output beyond `index.html` is a job for a framework or a plugin. Poops is a bundler ([esbuild](https://esbuild.github.io/)), a Sass compiler ([Dart Sass](https://sass-lang.com/dart-sass)), a PostCSS pipeline and a Jekyll-inspired site generator, all driven from `poops.json`. Eighteen direct dependencies, forty-one packages installed, Node β‰₯ 22. ## Feature by feature | | Poops | Vite | | --- | --- | --- | | Config | `poops.json` β€” no JS, no imports, no plugin objects | `vite.config.ts` β€” JavaScript you run | | Dev server | static files + SSE; **CSS is swapped in place**, everything else is a full page reload | native ESM, **HMR** with module-level updates | | Dev/prod parity | one code path β€” dev serves what the build wrote | dev is unbundled ESM, prod is bundled; differences are rare but real | | Bundler | esbuild | Rolldown (Rust) | | Build speed | not benchmarked here | Rolldown is Vite's headline number β€” [InfoQ reports builds up to 30Γ— faster](https://www.infoq.com/news/2026/05/vite-v8-rust/); that figure is Vite's own, measured against Vite, not against Poops | | Sass | built in, plus [design-token JSON imports](../quick-start/transpiling-css) | install `sass`, Vite handles the rest | | PostCSS / Tailwind | a separate `postcss` key, so it does not run twice | built in | | Markdown, front matter, layouts | built in β€” Nunjucks or Liquid | plugin, or a framework | | Collections, taxonomies, pagination, RSS | built in | no | | Sitemap, `robots.txt`, `llms.txt`, search index, nav tree, JSON-LD | built in | plugins, if they exist | | Hashed filenames, asset manifest, HTML rewriting | **no β€” you write the paths, they stay as written** | yes, automatic | | Code splitting | esbuild's own: `"splitting": true` with `"format": "esm"` and a directory `out`, which emits a hashed chunk per dynamic import β€” nothing rewrites your HTML or emits preload hints | managed, with preload directives | | Plugin ecosystem | **none** | very large β€” the strongest reason to pick Vite | | Framework SPAs (React, Vue, Svelte) | React via `scripts` and `reactor`; no framework HMR | first-class for all of them | | Library builds (IIFE + ESM + CJS from one source) | yes, [three entries in the config](../quick-start/transpiling-js) | yes, `build.lib` | ## The same site, in each A blog with SCSS, a bit of TypeScript, Markdown posts, a feed and a sitemap. In Poops that is the whole config: ```json { "markup": { "in": "src/markup", "out": "dist", "options": { "sitemap": "sitemap.xml", "feed": { "collection": "blog", "out": "feed.rss" } }}, "styles": [{ "in": "src/scss/index.scss", "out": "dist/css/styles.css", "options": { "minify": true } }], "scripts": [{ "in": "src/js/main.ts", "out": "dist/js/main.js", "options": { "minify": true, "format": "iife" } }], "watch": ["src"], "livereload": true } ``` In Vite it is `vite.config.ts` plus a Markdown plugin, a front-matter convention, a sitemap plugin and an RSS script β€” or a framework on top that brings all four and its own model with them. Vite's answer is better once the site becomes an app; it is more moving parts while the site is a site. ## The honest losses **No HMR.** Poops' live reload is an EventSource that swaps a changed stylesheet in place β€” the page does not reload for CSS, and scroll and state survive. Any other change reloads the page. Editing a React component with a filled-in form ten fields deep is exactly as annoying as that sounds. **No content hashing, no managed chunk graph.** Nothing rewrites your HTML, so `styles.css` stays `styles.css`. Cache-bust with a query string, a versioned directory, or your CDN's rules. Splitting itself works β€” esbuild's `splitting` option passes through and emits hashed chunks for dynamic imports β€” but you get chunks in a directory, not a manifest, preload hints or ` ``` A snippet you forget is harmless β€” it 404s quietly β€” but the `livereload_port` template global it reads is gone, so it renders nothing anyway. Config shrinks to a switch, and now needs `serve` alongside it: ```json { "serve": { "base": "dist" }, "livereload": true } ``` `livereload.port`, `livereload.exclude`, `livereload.extraExts` and `livereload.exts` are removed, along with the `--livereload-port` / `-l` flag. The last three had already stopped doing anything back in 1.5.1, when the reload server stopped watching files. Behaviour is unchanged: one reload per save once the build settles, and a CSS-only build swaps stylesheets in place instead of reloading the page. That hot-swap now actually fires when you build with `minify` β€” see *Fixed* below. ### 3. Rename `ssg` to `reactor` The last compatibility shim in the codebase. If your config still says `ssg`, rename it; Poops tells you so on startup: ``` [info] Config key "ssg" is renamed to "reactor" in 2.0 β€” ignored. ``` ### 4. Move markup settings into `options` (deprecated, not yet required) `markup` now has the shape every other entry has β€” `in`, `out`, and everything else in `options`: ```json { "markup": { "in": "src/markup", "out": "dist", "options": { "engine": "nunjucks", "site": { "title": "My Site" }, "dateFormat": "MMM D, YYYY", "searchIndex": "search-index.json", "nav": { "out": "nav.json" } } } } ``` Three renames come with it: | 1.x | 2.0 | | --- | --- | | `markup.` | `markup.options.` | | `timeDateFormat` | `dateFormat` | | `llms.output`, `nav.output`, `feed.output`, … | `…out` | All three are **still honoured in 2.x** and warn once, naming the replacement. They stop working in 3.0. Nothing breaks silently: if you keep the old spelling, your search index and feeds keep being written. Two conveniences ride along. `serve.base` now defaults to the markup `out` directory, so most configs can drop it. And if you set `target` on your `scripts` or `reactor` entries, nothing changes β€” but the default moved from `es2019` to `es2020`, because `?.` and `??` are ES2020 and lowering them for browsers that have supported them since 2020 only made bundles bigger. ### 5. Custom engines: one parameter renamed If you maintain a markup engine, `registerFilters({ timeDateFormat, markupOut })` is now `registerFilters({ dateFormat, markupOut })`. That is the only signature change, and it is a hard rename rather than a deprecation β€” it lands *before* the engine interface becomes public API in this same release. ## The engine interface is public API now `markup.options.engine` has always accepted any importable module, and [poops-shopify](https://github.com/stamat/poops-shopify) ships a production engine against it. Nothing declared that interface stable, so a method rename in a patch release could have broken it silently. It is now documented in [the engine API reference](../docs/engine-api), semver applies to it from 2.0 on, and a contract test asserts both builtin engines still expose the shape β€” a failing test is a breaking change caught before it ships. ## Under the hood - **chokidar 3 β†’ 5.** Two majors of watcher fixes and a smaller dependency tree. Nothing in your config changes: `watch` has always been a list of directories, never globs. - **esbuild 0.25 β†’ 0.28.** Expect small differences in your bundles β€” the CommonJS interop helper is more careful around a throwing module, and `Symbol.for` calls are annotated side-effect free, which makes minified output slightly *smaller*. - The `livereload` package and its websocket stack are gone from the tree. ## Fixed - **A style edit hot-swaps the stylesheet the page actually links.** With `minify` on, the reload chain was told about `site.css` while the page linked `site.min.css` β€” nothing matched, so every style edit reloaded the whole page. Both spellings are reported now. - **"Edit this page on GitHub" links work for collection items on Windows.** A collection item's `filePath` kept native separators while a regular page's was posix, so on Windows a post's link came out as `…/edit/main/src/posts\hello.md`. - **The image cache is read correctly across platforms.** A cache written on Windows matched nothing, so galleries came up empty. Its containment check also no longer accepts a sibling directory whose name merely starts with the output dir's β€” `dist-old` for `dist`. --- # v1.9.8 β€” fence info strings carry through URL: https://stamat.info/poops/changelog/v1.9.8.html ## Added - **The rest of a fence's info string becomes classes and `data-` attributes.** 1.9.7 stopped meta words leaking into the language class, which fixed the highlighting bug but threw the words away. So a fence could be labelled and nothing downstream could see the label β€” the marker for "this block is a live demo" had to live beside the fence as an HTML comment, one thing to keep in sync with another. Everything after the language now rides along: a bare word becomes a class, a `key=value` token becomes a data attribute. ````markdown ```html preview tab=options widths=375,768 ``` ```` ```html
…
``` A post-`markup` `exec` script then matches `code.preview` and reads the settings off the element, with nothing in the Markdown but the fence itself. Values are single tokens β€” no quotes, no spaces β€” which keeps the parser a `split`; anything needing a sentence belongs in the prose around the fence, not in its opening line. A bare word is a class rather than a valueless attribute, since a class is what the consumer selects on. When you do want the attribute β€” a flag read with `hasAttribute` instead of off `classList` β€” write the key with nothing after the `=`: ````markdown ```html preview expanded= ``` ```` ```html
…
``` Backwards compatible in the strict sense: a single-token info string renders exactly as before, which is every fence on every site today. Both the `{% raw %}{% highlight %}{% endraw %}` tag and the `highlight` filter take the same info string. ## Fixed - **All six renderers agree on what a code block is.** The same three lines were copy-pasted across the Markdown renderer, the standalone highlight renderer, and the filter and tag in each engine β€” and 1.9.7 only fixed two of them. The engine copies still interpolated the whole info string into the class attribute, so they disagreed with the Markdown renderer about a fence's output and were right about `language-html preview` purely by accident. One `codeBlock` helper now emits every code block, so there is one place left to get wrong. --- # v1.9.7 β€” site-wide JSON-LD defaults URL: https://stamat.info/poops/changelog/v1.9.7.html ## Added - **`site.jsonld` sets a JSON-LD default for the whole site.** The `jsonld` filter picks `BlogPosting` for a dated page and `WebPage` for everything else, and a `jsonld` object in front matter overrode any of it. That escape hatch was per page, which is the wrong shape when the whole site is one type: a docs site is `TechArticle`, a knowledge base is `FAQPage`, a product catalogue is `Product`. The only way to say so was the same four lines of front matter in every file β€” and the failure mode is quiet, since a page that misses them still emits valid JSON-LD, just the generic type. The same object now works in your `site` data: ```json {% raw %}{ "markup": { "site": { "jsonld": { "@type": "TechArticle" } } } }{% endraw %} ``` Every page renders as `TechArticle` β€” no front matter, nothing to forget. It isn't limited to `@type`; any key you would have set per page works site-wide, which is where the fields that genuinely don't vary belong: ```json {% raw %}"jsonld": { "@type": "TechArticle", "license": "https://opensource.org/licenses/MIT", "isAccessibleForFree": true }{% endraw %} ``` Precedence is defaults β†’ `site.jsonld` β†’ `page.jsonld`, so a single page still opts out of the site-wide type while keeping the rest of it β€” a `FAQPage` inside a `TechArticle` site keeps the `license` and only replaces `@type`: ```yaml --- title: Frequently asked questions jsonld: "@type": FAQPage --- ``` Two things it deliberately doesn't do. A site-wide `@type` beats the auto-detected `BlogPosting` as well β€” it's a default you set, not one poops guessed β€” so on a site that mixes docs with a blog, set the type per page rather than site-wide, or the posts stop being articles. And it merges into the page's own block only: the `WebSite` block on the homepage and the auto-appended `BreadcrumbList` on nested pages are structural, and a site-wide `@type` has no business rewriting them. Same shallow merge as the front-matter object, for the same reason β€” nested schema is rare, and the escape hatch is meant for whole-key replacement. --- # v1.9.6 β€” output path templating for styles and scripts URL: https://stamat.info/poops/changelog/v1.9.6.html ## Added - **`out` can be a template, for styles and scripts alike.** `v1.9.5` named a glob-matched `index.*` after its directory, which fixed the case a component library actually hits. It did nothing for anything else: point a glob at `src/elements/*/theme.scss` and every match still falls back to its own basename, so the components overwrite each other in the output directory and the last one to build wins. There was no way to say what you wanted the files called. Now there is. An `out` carrying `{% raw %}{{dir}}{% endraw %}` or `{% raw %}{{name}}{% endraw %}` resolves per entry point instead of naming one shared destination: ```json {% raw %}{ "styles": { "in": "src/elements/*/theme.scss", "out": "dist/css/{{dir}}-{{name}}.css" }, "scripts": { "in": "src/elements/*/widget.ts", "out": "dist/js/{{dir}}-{{name}}.js" } }{% endraw %} ``` ``` src/elements/accordion/theme.scss β†’ dist/css/accordion-theme.css src/elements/tabs/theme.scss β†’ dist/css/tabs-theme.css src/elements/accordion/widget.ts β†’ dist/js/accordion-widget.js src/elements/tabs/widget.ts β†’ dist/js/tabs-widget.js ``` `{% raw %}{{dir}}{% endraw %}` is the match's directory relative to the glob's **static prefix** β€” the same name an `index.*` entry gets, so the two rules agree on what a component is called, and widening the glob keeps the segments it no longer pins down. `{% raw %}{{name}}{% endraw %}` is the basename without its extension. Whitespace inside the braces is fine (`{% raw %}{{ dir }}{% endraw %}`), matching the `banner` templates. Tokens work in directory segments too, so the flat layout isn't the only one available: ```json {% raw %}{ "in": "src/elements/*/theme.scss", "out": "dist/css/{{dir}}/theme.css" }{% endraw %} ``` A literal entry fills `{% raw %}{{dir}}{% endraw %}` with its own directory name, which keeps mixed arrays of globs and plain paths working: ``` {% raw %}"out": "dist/css/{{dir}}.css"{% endraw %} src/scss/main.scss β†’ dist/css/scss.css src/elements/accordion/index.scss β†’ dist/css/accordion.css ``` For scripts the template's extension is honoured as well, which is the cheap way to ship one format per entry point rather than one bundle per format: ```json {% raw %}{ "in": "src/elements/*/index.ts", "out": "dist/esm/{{dir}}.mjs", "options": { "format": "esm" } }{% endraw %} ``` A template wins over the `index.*` rename β€” you named these outputs, and renaming them behind your back is the thing globs were already doing wrong. It's also exempt from the "more than one entry file needs a directory `out`" guard, since a template already resolves to a different file per entry rather than to one file everything overwrites. What it does not do is invent uniqueness: `{% raw %}"out": "dist/{{name}}.css"{% endraw %}` across component directories collides exactly like a plain directory `out` would, and its scripts equivalent fails the build outright with esbuild's *"Two output files share the same path"*. That's what `{% raw %}{{dir}}{% endraw %}` is there for. ## Fixed - **Live CSS reload no longer guesses the output path.** The watch chain fed livereload a path derived from the config β€” a directory `out` joined with the basename of `in`. For a single named entry point that guess was right. For a glob it was the pattern's own basename, so `src/elements/*/index.scss` reported `dist/css/index.css`, a file that was never written; a templated `out` reported the template verbatim, braces and all. The livereload client looks for a loaded stylesheet matching the path it is handed and, finding none, falls back to reloading the whole page β€” so editing a component's Sass flashed a full reload instead of swapping the stylesheet in place, and any scroll position or open state went with it. The styles compiler now records the files it wrote and the watch chain reloads exactly those. One entry, one glob or twenty templated outputs, the reported paths are the ones on disk, because nothing derives them a second time. - **The watcher's output zones understand templates.** A compiler writing into a watched directory must not retrigger itself, which the watcher prevents by zoning each task's `out`. A templated `out` is only a fixed path up to its first token, so `dist/{{dir}}/theme.css` zoned a directory literally named `{{dir}}`, protecting nothing. Zones are now taken from the static prefix. --- # v1.9.5 β€” index entries take their directory's name URL: https://stamat.info/poops/changelog/v1.9.5.html ## 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 called `index`. Point a glob at them and the naming falls apart, differently for each pipeline. Styles compiled every match to `/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.js` where you wanted `dist/accordion.js`. ```json { "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.js ``` Two 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 writes `dist/index.js`, and `"in": "src/scss/index.scss"` still writes `dist/index.css` β€” you named that entry point yourself, and moving it to `src.js` or `scss.css` because of a rule about globs would be a rename you never asked for. Same for an explicit `out` file 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 prefix `src/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.css ``` Two `accordion` directories, 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.** `hasMagic` doesn'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 with `Entry 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`, `images` or `markup` `in` is 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. --- # v1.9.4 β€” one build, one copy URL: https://stamat.info/poops/changelog/v1.9.4.html ## 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-compile `exec` step that rewrites the output makes it four. If those land in a directory another instance watches (a library's `dist/` inside a docs site's `copy` source, the setup `--quiet` was added for), every one of those events ran the full branch: five `Copied N paths` passes 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 150ms `awaitWriteFinish` settle 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. --- # v1.9.3 β€” a --quiet flag for parallel runs URL: https://stamat.info/poops/changelog/v1.9.3.html ## Added - **`--quiet` / `-q` hides 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 under `site/`, each with its own `poops.json` β€” they stop being useful: three headers, three terminal bells, and two `Local server` blocks whose ports you already know, scrolling past before the first compile line lands. ```bash poops -q & poops -q -c site/poops.json ``` What `-q` removes 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: ```bash 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 - **`justMinified` no longer throws `ENOENT` on 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 a `justMinified` entry printed an `unlink` ENOENT stack trace. Harmless but loud. The delete now only runs when the file actually exists. --- # v1.9.2 β€” extensionless URLs, like GitHub Pages URL: https://stamat.info/poops/changelog/v1.9.2.html ## Fixed - **The dev server resolves extensionless URLs.** GitHub Pages serves `/a/b` from `a/b.html` without touching the URL. The local server didn't, so a link written as `/changelog/v1.9.1` worked 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 index ``` The 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.css` resolves the same for `/changelog/v1.9.1` and `/changelog/v1.9.1.html`. - **`404.html` loads 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: ```html ``` Injected only when the page doesn't already declare its own ``, and only in the response β€” your built `404.html` is 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 `/` β€” with the trailing separator β€” so the check compared against `//` 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. --- # v1.9.1 β€” load paths stop eating your pages URL: https://stamat.info/poops/changelog/v1.9.1.html ## Fixed - **`includePaths` no longer breaks the markup glob.** Top-level `includePaths` 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 as `Compiled: 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`](https://www.npmjs.com/package/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](/changelog/v1.9.0). Front matter points at the package, and the theme's sources compile straight out of `node_modules`: ```json { "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" } ] } ``` ```yaml --- layout: poops-docs-theme/docs --- ``` Four files left the repo and nothing about the docs changed on screen β€” which was the point. --- # v1.9.0 β€” templates from npm packages URL: https://stamat.info/poops/changelog/v1.9.0.html ## 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's `node_modules`; a bare name (no `/`) stays project-only, so the common path never touches the resolver. ```nunjucks {% raw %}{% extends "my-theme/layout.html" %} {% block content %}

{{ page.title }}

{% endblock %}{% endraw %} ``` Or from front matter, so the page carries no template syntax at all: ```yaml --- layout: my-theme/layout --- ``` - **Nunjucks and Liquid both.** The Nunjucks loader falls back to `require.resolve` for `pkg/template.html`; the Liquid engine adds every ancestor `node_modules` on 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](/docs/quick-start/templating-html/). --- # v1.8.0 β€” native dev server, fewer dependencies, hardening URL: https://stamat.info/poops/changelog/v1.8.0.html ## Added - **Native `node:http` dev 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: - **`Range` request support** β€” single-range `206 Partial Content` (and `416` for unsatisfiable ranges), so `