Straightforward, no-bullshit bundler for the web.
When your day is long
And the night, the night is yours alone
When you're sure you've had enough
Of these bundlers, well hang on
Don't let yourself go
'Cause everybody poops
Everybody poops sometimes
Intuitive with a minimal learning curve and minimal docs, utilizing the most efficient transpilers and compilers available (like dart-sass and esbuild) Poops aims to be the simplest bundler option there is. If it's not, please do contribute so we can make it so! π All ideas and contributions are welcome.
It uses a simple config file where you define your input and output paths and it poops out your bundled files. Simple as that.
- Features
- Quick Start
- Configuration
- Contributing
- Why?
- Bundles SCSS/SASS to CSS
- Uses dart-sass for SCSS/SASS bundling
- Design token support β import JSON tokens (W3C DTCG & Style Dictionary) as SCSS variables or maps
- PostCSS pipeline β use any PostCSS plugin including Tailwind CSS
- Bundles JS/TS/JSX/TSX to IIFE/ESM/CJS
- Uses esbuild for bundling and transpiling JS/TS/JSX/TSX to IIFE/ESM/CJS
- React pre-rendering (Reactor) β renders React components to HTML at build time for static sites with optional hydration
- Optional JS and CSS minification using esbuild
- Can produce minified code simultaneously with non-minified code! (cause I always forget to minify my code for production)
- Supports source maps only for non minified - non production code (optional)
- Supports multiple input and output paths
- Resolves node modules
- Can add a templatable banner to output files (optional)
- Static site generation with swappable template engines: Nunjucks (default) or Liquid β with blogging option (optional)
- Collections with pagination, and taxonomies β tags/categories as paginated, crawlable landing pages (with localizable labels)
- Generates a JSON search index,
sitemap.xml,llms.txt,robots.txtand a navigation tree from your pages - RSS and Atom feeds from any collection, no feed template to hand-author
- Responsive image processing β resize, WebP/AVIF, crops, EXIF β via the optional poops-images
- Shell hooks before and after every pipeline stage, so a generator or a post-processor runs on every rebuild and not just on
poops -b - Has a configurable local server (optional)
- Rebuilds on file changes (optional)
- Live reloads on file changes (optional)
For a superfast start, scaffold a project instead of wiring one up:
npm create poops@latest my-appcreate-poops prompts for a template and clones it:
base(the clean π©πͺοΈShitstorm starter),sulphuris(+ the sulphuris CSS framework) orhat(+ htmx, Alpine.js, Tailwind). Name it as the second argument to skip the prompt:npm create poops my-app hat.
Poops requires Node.js 22 or newer.
You can install Poops globally:
npm i -g poopsor locally:
npm i -D poopsIf you have installed Poops globally, create a poops.json or π©.json configuration file in the project root (see Configuration on how to configure) and run:
poops or π©
or pass a custom config. This is useful when you have multiple environments:
poops yourAwesomeConfig.json or π© yourAwesomeConfig.json
CLI Options:
| Flag | Short | Description |
|---|---|---|
--build |
-b |
Build the project and exit |
--config <path> |
-c |
Specify the config file |
--port <number> |
-p |
Specify the server port, overrides config |
--base-url <path> |
-u |
Set the base URL prefix for markup, overrides config |
--quiet |
-q |
Hide the header and the server/livereload info lines |
The --base-url flag is particularly useful for CI/CD pipelines where the deploy path may differ per environment:
poops --build --base-url /blogThe --quiet flag drops the π© Poops β vX.Y.Z header (and its terminal bell) plus the Local server / Network / Live reload lines. Handy when you run several Poops instances side by side and only want to see which one is compiling:
poops -q & poops -q -c site/poops.jsonBuild logs, warnings and errors are unaffected β --quiet only removes the banner.
If you have installed Poops locally you can run it with npx poops or npx π© or add a script to your package.json:
{
"scripts": {
"build": "npx poops" // or "npx π©"
}
}Configuring Poops is simple π. Let's presume that we have a example/src/scss and example/src/js directories and we want to bundle the files into example/dist/css and example/dist/js. If you also have markup files, you can use Nunjucks (default) or Liquid templating engine to generate HTML files from your templates. Let's presume that we have a example/src/markup directory and we want to generate HTML files in the root of the your directory.
Just create a poops.json file in the root of your project and add the following (you can see this sample config in this repo's root):
{
"scripts": [
{
"in": "example/src/js/main.ts",
"out": "example/dist/js/scripts.js",
"options": {
"sourcemap": true,
"minify": true,
"justMinified": false,
"format": "iife",
"target": "es2019"
}
}
],
"reactor": [
{
"component": "example/src/js/App.jsx",
"inject": "app_html",
"in": "example/src/js/app-hydrate.jsx",
"out": "example/dist/js/app-hydrate.js",
"options": {
"minify": true,
"target": "es2019"
}
}
],
"styles": [
{
"in": "example/src/scss/index.scss",
"out": "example/dist/css/styles.css",
"options": {
"sourcemap": true,
"minify": true,
"justMinified": false
}
}
],
"markup": {
"in": "example/src/markup",
"out": "/",
"options": {
"engine": "nunjucks",
"site": {
"title": "Poops",
"description": "A super simple bundler for simple web projects."
},
"data": ["data/links.json", "data/poops.yaml"],
"includePaths": ["_layouts", "_partials"]
}
},
"copy": [
{
"in": "example/src/static",
"out": "example/dist"
}
],
"banner": "/* {{ name }} v{{ version }} | {{ homepage }} | {{ license }} License */",
"serve": {
"port": 4040,
"base": "/"
},
"livereload": true,
"watch": ["src"],
"includePaths": ["node_modules"]
}Every property is optional, but give Poops nothing to compile β no scripts, styles, postcss, markup, reactor, images or copy β and it exits 0 having written nothing. If you don't have anything to consume, you won't poop. π©
You can freely remove the properties that you don't need. For example, if you don't want to run a local server, just remove the serve property from the config.
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 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 schema also drives editor completion and inline docs for every key. Point $schema at the copy in your node_modules:
{
"$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:
{
"$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.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 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 β declared is enough, and Poops never loads it. A key can also arrive one step removed β laxative brings septic, so your package.json says laxative and never septic β and for that a direct dependency vouches for the key in its own manifest: "poops": { "companionKeys": ["septic"] }. Only direct dependencies are read, one directory deep. Nothing declared and nothing vouched, and the key is warned about as before, which is what catches the typo.
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 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 one deliberately does not repeat them, since a URL copied into two repos is a URL that goes stale in one.
The schema is hand-written and version-controlled beside the code, so it can drift from it. Poops' own test suite validates it against the draft-07 meta-schema, then validates poops.json and every complete example in this README and the documentation site against it β so an example that stops being valid config fails the build. Its top-level keys are asserted to be exactly the set poops.js accepts, its exec keys exactly the ones that fire, and its markup.options a superset of the ones the markup engine reads. A per-entry options object β mostly esbuild's and PostCSS's, not Poops' β has no such list, so a wrong type there is caught but a missing option is not. If the editor does not offer an option this page documents, the schema is behind and that is worth reporting.
Scripts are bundled with esbuild. Supports .js, .ts, .jsx, and .tsx files out of the box β including React and other JSX frameworks. You can specify multiple scripts to bundle. Each script has the following properties:
in- the input path, can be a file path, an array of file paths, or a glob pattern (e.g."src/js/*.js","src/elements/*/index.{js,mjs,cjs,jsx,ts,tsx}"). Globs must use/separators (even on Windows)out- the output path, can be a directory or a file path. With multiple inputs it must be a directory β entry points from different directories nest their output under the common ancestor (esbuild'soutbase). A glob-matchedindex.*is named after its directory instead, placed relative to the glob's static prefix:"src/elements/*/index.js"gives<out>/accordion.js, while"src/*/accordion/index.js"keeps the differing segment as<out>/blocks/accordion.js. A literalin: "src/index.js"keeps its own basename. To name outputs yourself,outcan be a template:{{dir}}is the input's directory relative to the glob's static prefix,{{name}}its basename without extension."src/elements/*/widget.ts"without: "dist/js/{{dir}}-{{name}}.js"givesdist/js/accordion-widget.jsanddist/js/tabs-widget.jsβ one bundle per match, named by you instead of by the common ancestor. The template's extension is honoured too, soout: "dist/esm/{{dir}}.mjs"writes.mjsfilesoptions- the options for the bundler. You can apply most of the esbuild options that are not in conflict with Poops. See esbuild's options for more info.
Options:
sourcemap- whether to generate sourcemaps or not, sourcemaps are generated only for non-minified files since they are useful for debugging. Default isfalse. This is a direct esbuild optionminify- whether to minify the output or not, minification is performed byesbuildand is only applied to non-minified files. Default isfalsejustMinified- whether you want to have a minified file as output only. Removes the non-minified file from the output. Useful for production builds. Default isfalseformat- the output format, can beiifeoresmorcjs- this is a direct esbuild optiontarget- the target for the output, can bees2018ores2019ores2020oresnextfor instance - this is a direct esbuild option. Default ises2020jsx- the JSX transform mode, can betransform(default) orautomatic. Useautomaticfor React 17+ JSX runtime which doesn't require importing React in every file - this is a direct esbuild optionnodePaths- extra directories to resolve bare imports from, for this entry only. Merged with the top-levelincludePathsrather than replacing it - this is a direct esbuild option
scripts property can accept an array of script configurations or just a single script configuration. If you want to bundle multiple scripts, just add them to the scripts array:
{
"scripts": [
{
"in": "src/js/main.ts",
"out": "dist/js/scripts.js",
"options": {
"sourcemap": true,
"minify": true,
"justMinified": false,
"format": "iife",
"target": "es2019"
}
},
{
"in": "src/js/other.ts",
"out": "dist/js/other.js",
"options": {
"sourcemap": true,
"minify": true,
"justMinified": false,
"format": "iife",
"target": "es2019"
}
}
]
}To bundle a React app, just point in to your .jsx or .tsx entry file:
{
"scripts": [
{
"in": "src/js/app.jsx",
"out": "dist/js/app.js",
"options": {
"minify": true,
"format": "iife",
"jsx": "automatic"
}
}
]
}Setting jsx to automatic uses React's JSX runtime (React 17+), so you don't need import React from 'react' in every file. If you omit jsx or set it to transform, the classic React.createElement transform is used.
As noted earlier, if you don't want to bundle scripts, just remove the scripts property from the config.
The reactor config key defines React components that are pre-rendered to HTML at build time (SSG) and optionally hydrated on the client. This is a separate pipeline from scripts β reactor entries have their own build step, watcher path, and logging tag.
Each reactor entry has the following properties:
componentβ the file that default-exports a React component (rendered at build time withrenderToString)injectβ template global variable name for the rendered HTML (available in both Nunjucks and Liquid)in(optional) β client entry file for hydration (bundled for the browser)out(optional) β output path for the client bundleoptions(optional) β esbuild options for the client bundle (same as script entries:minify,format,target,sourcemap, etc.)
{
"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"
}
}
]
}In your templates, use the inject name to insert the rendered HTML:
<div id="root">{{ app_html | safe }}</div>
<script src="js/app-hydrate.min.js"></script>If you only need server-side rendering without client hydration, omit in and out:
{
"reactor": [
{
"component": "src/js/App.jsx",
"inject": "app_html"
}
]
}How it works:
- Poops bundles the component with
react-dom/serverfor Node.js and callsrenderToString - The rendered HTML is stored and made available as a template global variable
- If
in/outare specified, the client entry is bundled for the browser - At runtime, React hydrates the pre-rendered HTML, making it interactive
Poops does not need react or react-dom as its own dependency β they are resolved from your project's node_modules. In watch mode, changes to files in the reactor component's directory trigger re-rendering and client re-bundling. Markup is recompiled only when the rendered output actually changes. Changes to other JS/TS files only trigger the scripts pipeline β the two are independent.
Note
If you don't need server-side pre-rendering, you can bundle a React app entirely through the regular scripts pipeline β just point in to your .jsx/.tsx entry file and use createRoot on the client. The reactor config is only needed when you want build-time HTML rendering with optional hydration.
Styles are bundled with Dart Sass. You can specify multiple styles to bundle. Each style has the following properties:
in- the input path, can be a file path, an array of file paths, or a glob pattern (e.g."src/scss/*.scss","src/elements/*/index.{scss,sass,css}"). Globs must use/separators (even on Windows) and skip Sass partials (_*.scss). Each matched file is compiled separatelyout- the output path, can be a directory or a file path. With multiple inputs it must be a directory β each input compiles to<out>/<basename>.css, so inputs sharing a basename (e.g.a/main.scssandb/main.scss) will overwrite each other. A glob-matchedindex.*is named after its directory instead, placed relative to the glob's static prefix:"src/elements/*/index.scss"gives<out>/accordion.css, while"src/*/accordion/index.scss"keeps the differing segment as<out>/blocks/accordion.css. A literalin: "src/scss/index.scss"keeps its own basename. To name outputs yourself,outcan be a template:{{dir}}is the input's directory relative to the glob's static prefix,{{name}}its basename without extension."src/elements/*/theme.scss"without: "dist/{{dir}}-theme.css"givesdist/accordion-theme.cssanddist/tabs-theme.cssβ one output per match, instead of everytheme.scssoverwriting the sametheme.cssoptions- the options for the bundler.
Options:
sourcemap- whether to generate sourcemaps or not, sourcemaps are generated only for non-minified files since they are useful for debugging. Default isfalseminify- whether to minify the output or not, minification is performed byesbuild. Default isfalsejustMinified- whether you want to have a minified file as output only. Removes the non-minified file from the output. Useful for production builds. Defaults tofalse.tokenPaths- a string or array of directory paths containing JSON design token files. Enables thesass-token-importerwhich lets you@useJSON tokens directly in SCSS. Supports W3C DTCG and Style Dictionary formats with auto-detection.tokenOutput- output mode for design tokens:"variables"(default) generates flat SCSS variables,"map"generates nested Sass maps.resolveAliases- whether to resolve{path.to.token}alias references in design tokens. Default istrue.
styles property can accept an array of style configurations or just a single style configuration. If you want to bundle multiple styles, just add them to the styles array:
{
"styles": [
{
"in": "src/scss/main.scss",
"out": "dist/css/styles.css",
"options": {
"sourcemap": true,
"minify": true,
"justMinified": false
}
},
{
"in": "src/scss/other.scss",
"out": "dist/css/other.css",
"options": {
"sourcemap": true,
"minify": true,
"justMinified": false
}
}
]
}You can import JSON design token files directly into your SCSS using the token: prefix. Define your tokens in JSON once and use them as SCSS variables β no manual variable files to keep in sync.
Given a token file src/tokens/colors.json:
{
"color": {
"$type": "color",
"primary": { "$value": "#0066cc" },
"secondary": { "$value": "#ff6600" },
"link": { "$value": "{color.primary}" }
}
}Add tokenPaths to your styles config:
{
"styles": [
{
"in": "src/scss/index.scss",
"out": "dist/css/styles.css",
"options": {
"tokenPaths": ["src/tokens"]
}
}
]
}Then use the token: prefix in your SCSS:
@use "token:colors" as c;
.btn {
color: c.$color-primary;
}
.btn:hover {
color: c.$color-secondary;
}
a {
color: c.$color-link; // resolved from {color.primary} β #0066cc
}For Sass maps instead of flat variables, set "tokenOutput": "map":
@use "sass:map";
@use "token:colors" as c;
.btn {
color: map.get(c.$color, primary);
}As noted earlier, if you don't want to bundle styles, just remove the styles property from the config.
Process CSS files with PostCSS and any PostCSS plugins. This is a separate pipeline from Styles (Sass) β use it for tools like Tailwind CSS, Autoprefixer, or any other PostCSS plugin.
PostCSS and its plugins are not bundled with Poops. You need to install them in your project:
npm i -D postcssEach PostCSS entry has the following properties:
in- the input CSS file pathout- the output path, can be a directory or a file pathoptions- options for the pipeline
Options:
plugins- an array of PostCSS plugin names to load. Each entry can be a string (plugin name) or a tuple["plugin-name", { options }]for passing options to the plugin.minify- whether to minify the output usingesbuild. Default isfalsejustMinified- output only the minified file. Default isfalse
Source maps: an input CSS ending in a sourceMappingURL has its map composed with the one PostCSS produces, written next to the output. Point in at a Sass output built with "sourcemap": true and a rule still traces to the .scss line it came from, through both passes. An input carrying no map produces none.
postcss property can accept an array of configurations or a single configuration:
{
"postcss": {
"in": "src/css/main.css",
"out": "dist/css/main.css",
"options": {
"plugins": ["@tailwindcss/postcss"],
"minify": true
}
}
}You can also pass options to plugins using the tuple form:
{
"postcss": {
"in": "src/css/main.css",
"out": "dist/css/main.css",
"options": {
"plugins": [["autoprefixer", { "grid": true }]]
}
}
}Build order: PostCSS runs after Styles and Markups in the build pipeline. This means PostCSS plugins can reference the compiled markup output (e.g. Tailwind scanning HTML for utility classes). In watch mode, PostCSS is re-triggered after Styles or Markups recompile.
Install the deps, then use a config like this:
npm i -D postcss @tailwindcss/postcss tailwindcss{
"postcss": {
"in": "src/css/main.css",
"out": "dist/css/main.css",
"options": {
"plugins": ["@tailwindcss/postcss"],
"minify": true
}
},
"markup": {
"in": "src/markup",
"out": "dist",
"options": {
"site": {
"title": "Poops + Tailwind",
"description": "A Tailwind CSS example for Poops"
},
"includePaths": ["_layouts", "_partials"]
}
},
"serve": { "port": 4040, "base": "/dist" },
"livereload": true,
"watch": ["src"]
}The CSS entry file (src/css/main.css) simply imports Tailwind:
@import "tailwindcss";Then use Tailwind utility classes directly in your markup templates. Tailwind v4 auto-detects content sources, so no tailwind.config.js is needed.
Using Sass + Tailwind together: If you want both Sass and Tailwind, keep them as separate pipelines writing to separate output files. The Sass pipeline compiles .scss to CSS, while the PostCSS pipeline handles Tailwind independently. They don't need to chain into each other unless you want PostCSS to post-process the Sass output (e.g. with Autoprefixer) β in that case, point postcss.in to the Sass output file and postcss.out to a different file so the original Sass output is preserved for re-processing.
markup has the same shape as a scripts or styles entry: in, out, and
everything else under options.
{
"markup": {
"in": "src/markup",
"out": "dist",
"options": {
"engine": "nunjucks",
"site": { "title": "My Awesome Site" }
}
}
}Warning
Deprecated: Poops 1.x also read these keys directly on markup
("markup": { "site": β¦ }). That still works in 2.x and warns; it stops
working in 3.0. Move them into options.
Options:
engine(optional) - the template engine to use. Can be"nunjucks"(default) or"liquid". Nunjucks is a Mozilla template engine inspired by Jinja2. Liquid is a Shopify-compatible template engine. Both engines support the same tags, filters, collections, search index, sitemap, and navigation tree features documented below.in(entry, not an option) - the input path, can be a directory or a file path, but please just use it as a directory path for now. All files in this directory will be processed and the structure of the directory will be preserved in the output directory with exception to directories that begin with an underscore_will be ignored.out(entry, not an option) - the output path, can be only a directory path (for now)site(optional) - global data that will be available to all templates in the markup directory. Like site title, description, social media links, etc. You can then use this data in your templates{{ site.title }}for instance. Values carry the samepackage.jsontokens a banner does, filled at build time and at any depth:"footer": "v{{ version }}"prints the version yourpackage.jsonholds, so it cannot drift from the released one. A token yourpackage.jsonhas no field for is left as written rather than becoming the wordundefined.data(optional) - is an array of JSON or YAML data files, that once loaded will be available to all templates in the markup directory. If you provide a path to a file for instancelinks.jsonwith afacebookproperty, you can then use this data in your templates{{ links.facebook }}. The base name of the file will be used as the variable name, with spaces, dashes and dots replaced with underscores. Sothe awesome-links.jsonwill be available as{{ the_awesome_links.facebook }}in your templates. The root directory of the data files isindirectory. So if you have adatadirectory in yourindirectory, you can specify the data files like thisdata: ["data/links.json"]. The same goes for the YAML files.includePaths- an array of paths to directories that will be added to the template engine's include paths. Useful if you want to separate template partials and layouts. For instance, if you have a_includesdirectory with aheader.njk(orheader.liquid) partial that you want to include in your markup, you can add it to the include paths and then include the templates like this{% include "header.njk" %}, without specifying the full path to the partial.baseURL(optional) - a base URL prefix to use instead of relative path prefixes. When set,{{ relativePathPrefix }}will always resolve to this value (with a trailing slash ensured) instead of being computed relative to each page's depth. Useful when deploying under a subdirectory (e.g."/blog"fordomain.com/blog/). When not set, relative prefixes (./,../, etc.) are used, which work for any deployment location including subdirectories andfile://URLs.dateFormat(optional) - the default dayjs format thedatefilter uses when called without an argument. With neither set,datereturns the value untouched rather than guessing a formatautoescape(optional) - Nunjucks only. Escape template output by default, so{{ value }}cannot inject HTML and anything meant as markup needs| safe. Defaults tofalse, since a static site mostly renders content you wrote. Turn it on when templates interpolate anything you did not. The Liquid engine ignores it β liquidjs does not escape by default and Poops does not make itcollections(optional) - the collections to build, if you would rather declare them here than in front matter. See Collections & PaginationlastUpdated(optional) - keep a "last updated" date per page without hand-maintaining one.truewrites the index to.poops-updates.json; a string names the file. See Last updated dates
Tip
If, for instance, you are building a simple static onepager for your library, and want to pass a version variable from your package.json, Poops automatically reads your package.json if it exists in your working directory and sets the global variable package to the parsed JSON. So you can use it in your markup files, for example like this: {{ package.version }}.
"Edit this page on GitHub" links. Every page exposes 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:
{% set repoUrl = site.repo or package.homepage %}
{% if page.filePath and repoUrl %}
<a href="{{ repoUrl }}/edit/{{ site.branch or 'main' }}/{{ page.filePath }}">βοΈ Edit this page on GitHub</a>
{% endif %}Put repo and branch in your site data (they fall back to package.homepage and main). Don't rebuild 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.
Here is a sample markup configuration using the default Nunjucks engine:
{
"markup": {
"in": "src/markup",
"out": "dist",
"options": {
"site": {
"title": "My Awesome Site",
"description": "This is my awesome site"
},
"data": ["data/links.json", "data/other.yaml"],
"includePaths": ["_includes"],
"baseURL": "/blog"
}
}
}To use Liquid instead, set the engine property:
{
"markup": {
"in": "src/liquid",
"out": "dist",
"options": {
"engine": "liquid",
"site": {
"title": "My Awesome Site",
"description": "This is my awesome site"
},
"data": ["_data/links.json", "_data/other.yaml"],
"includePaths": ["_layouts", "_partials"]
}
}
}If your project doesn't have markups, you can remove the markup property from the config entirely. No code will be executed for this property.
Both engines support the same feature set (collections, pagination, search index, sitemap, navigation tree, custom tags, and filters). The main differences are in template syntax:
| Feature | Nunjucks | Liquid |
|---|---|---|
| File extension | .njk |
.liquid |
| Inheritance | {% extends "base.html" %} |
{% layout "base.liquid" %} |
| Default values | {{ x or "fallback" }} |
{{ x | default: "fallback" }} |
| Contains check | {% if "x" in items %} |
{% if items contains "x" %} |
| Safe output | {{ html | safe }} |
{{ html }} (no escaping by default) |
| Includes | {% include "partial.njk" %} |
{% render "partial.liquid" %} |
Both engines process .html and .md files in addition to their native extension.
Layouts and partials can live in an installed package, so a shared theme ships as a dependency instead of copied files. Reference it by package name β any include/extend name containing a / is resolved from node_modules:
{% extends "my-theme/layout.html" %}
{% block content %}
<h1>{{ page.title }}</h1>
{% endblock %}Or from front matter, so the page carries no template syntax:
---
layout: my-theme/layout
---A theme package must:
- Not restrict subpaths with
exportsβ or map its templates explicitly, e.g."exports": { "./*": "./*" }. Otherwise Node blocks resolving the.htmlfiles by path. - Reference its own partials relatively β
{% import "./nav.html" as nav %}, not the bare name. A bare name (no/) is always searched in the consumer's project only, never the package.
Bundled filters (toc, breadcrumb, og, canonical, β¦) are engine-global, so package templates use them with no extra wiring.
Liquid resolves package templates the same way β node_modules is on its include roots, so {% layout "my-theme/layout.liquid" %} and {% render "my-theme/partial.liquid" %} resolve by package name too (a Liquid theme ships .liquid files). The exports/relative-partial rules above apply the same, except containment is by include root rather than the / name gate.
The engine option also accepts a module specifier β an npm package name or a path relative to your project root. The module's default export must be an engine class:
{
"markup": {
"in": "src/markup",
"out": "dist",
"options": { "engine": "poops-shopify" }
}
}An engine class implements this contract (see lib/markup/engines/ for the two built-in reference implementations):
export default class MyEngine {
constructor(templatesDir, includePaths, options) {} // options: { autoescape }
get markupExtensions() {
return "html|liquid|md";
} // glob alternation of processed extensions, no dots
get indexableExtensions() {
return new Set([".html"]);
} // extensions eligible for collections, search index and nav
registerFilters({ dateFormat, markupOut }) {}
registerTags(getOutputDir) {}
setGlobal(key, value) {}
removeGlobal(key) {}
async render(templatePath, context) {
return "html";
} // templatePath is an absolute file path
}That is the whole required surface. Five more members are feature-detected with a typeof check β implement what your engine can, skip the rest:
| Member | Gives you | Without it |
|---|---|---|
invalidate(file) |
Drop the compiled templates backed by a changed or deleted path (prefix-match, so a deleted directory is covered) | clearCache() is called on every watch compile |
clearCache() |
Wipe the whole template cache | No cache management at all |
pagesDependingOn(file) |
Re-render only the pages that loaded an edited partial or layout | Any markup edit triggers a full markup compile |
replaceOutExtensions(outputPath) |
Map your source extension to a different output one | The default maps .md/.njk/.liquid to .html |
isMarkupSource(absPath) |
Claim a file the glob would not call markup, so watch routes it to the markup pipeline | Only markupExtensions matches count |
The built-in engines also carry fileExtension and renderString, but the pipeline never calls either β don't implement them and don't rely on them. The full lifecycle (what Poops calls, in what order, with what) is in the engine API docs.
The easiest starting point is extending a built-in engine β deep imports are intentionally supported for this:
import LiquidEngine from "poops/lib/markup/engines/liquid.js";
export default class MyEngine extends LiquidEngine {
registerFilters(opts) {
super.registerFilters(opts);
this.engine.registerFilter("shout", (str) => String(str).toUpperCase());
}
}Collections turn a directory of pages into a sorted, optionally paginated list β blog posts, changelog entries, documentation. A collection maps to a direct subdirectory of your markup in directory: every .html, .njk, .liquid or .md file inside it (except the index.* file) becomes a collection item.
There are two ways to declare a collection:
1. Front matter auto-discovery β add collection to the front matter of the directory's index file:
---
title: Changelog
collection: true
paginate: 10
sort: date
---collection: true uses the directory name as the collection name; a string (e.g. collection: changelog) names it explicitly. paginate and sort are optional.
2. Config β list collections in the markup config. The name must match a subdirectory of in:
{
"markup": {
"in": "src/markup",
"out": "dist",
"options": {
"collections": [
"changelog",
{
"name": "blog",
"paginate": 5,
"sort": { "by": "title", "order": "asc" }
}
]
}
}
}Sorting. By default items are sorted by date, newest first. sort can be a field name shorthand ("sort": "title") or an object { "by": "field", "order": "asc" | "desc" }. Sorting by date compares dates (default order desc); any other field compares alphabetically (default order asc).
Items. Each item exposes its own front matter plus properties Poops adds:
-
url- the item's output path relative to the site root (e.g.changelog/my-post.html) -
title- falls back to the file name if not set in front matter -
date- falls back to the file's modification time if not set, with a build warning. Set a realdatein front matter β mtime is meaningless on CI checkouts (git clone resets it), so undated posts will reshuffle between deploys. -
wordcount,excerpt(first paragraph, plain text β a meta-description fallback),fileName,filePath,collectionA collection item is read without a page context, so an item whose first paragraph is built from template tags gets an empty
excerptrather than a guess β listings and feeds fall back to itsdescription. The same page gets its excerpt resolved when it is built on its own.
An item with published: false in its front matter is excluded from the collection and its page is not built.
Using collections in templates. Every collection is available as a global variable named after it, on every page:
{% for post in changelog.items %}
<a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a> β {{ post.date | date }}
{% endfor %}Pagination. With paginate: N set, the collection's index file is rendered once per page of N items: page 1 to out/changelog/index.html, page 2 to out/changelog/2/index.html, and so on. Inside the index template the collection object carries the page state:
| Variable | Description |
|---|---|
pageItems |
the items on the current page |
pageNumber / totalPages |
current page (1-based) / total page count |
pageUrl |
URL of the current page (changelog, changelog/2, β¦) |
nextPage / nextPageUrl |
next page number / URL, null on the last page |
prevPage / prevPageUrl |
previous page number / URL, null on the first page |
From the example site's changelog/index.html:
{% for post in changelog.pageItems %}
<div class="post">
<h2><a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a></h2>
<div class="date">{{ post.date | date }}</div>
{{ post.description }}
</div>
{% endfor %}
{% if changelog.totalPages > 1 %}
{% if changelog.nextPageUrl %}<a href="{{ relativePathPrefix }}{{ changelog.nextPageUrl }}">Next</a>{% endif %}
{{ changelog.pageNumber }} of {{ changelog.totalPages }}
{% if changelog.prevPageUrl %}<a href="{{ relativePathPrefix }}{{ changelog.prevPageUrl }}">Previous</a>{% endif %}
{% endif %}Or use the {% pagination %} shorthand tag (available in both engines), which renders Previous/Next links and a "page of total" counter β with relativePathPrefix applied β and outputs nothing when there is only one page:
{% pagination changelog %}Pages 2..N automatically get a distinct <title> β Changelog β Page 2 β so 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 {% pagination %} tag's Previous/Next/of wording default to English. Override them site-wide under site.pagination:
{
"markup": {
"options": {
"site": {
"pagination": {
"title": "{title} β Seite {n}",
"prev": "ZurΓΌck",
"next": "Weiter",
"of": "von"
}
}
}
}
}title accepts {title}, {n} and {total} tokens and applies to pages 2..N (and taxonomy term pages); prev/next/of localize the {% pagination %} tag ({n} of {total} β {n} von {total}).
Item pages themselves are compiled like any other markup file, preserving the directory structure: src/markup/changelog/my-post.md β dist/changelog/my-post.html. A collection directory without an index file still builds its items and exposes the collection to templates β only the paginated listing pages are skipped.
A taxonomy turns a front-matter field (tags, categories, authors) into its own paginated, crawlable landing page per term β changelog/tag/feature/, blog/category/release/. Declare which fields become taxonomies on the collection, alongside paginate/sort β either in the index front matter or the config entry:
---
title: Changelog
collection: true
paginate: 10
taxonomies:
- name: tags # front-matter field to group on
path: tag # URL segment (defaults to name); "tag" for a singular URL
paginate: 5 # per-term page size (defaults to the collection's paginate)
---Shorthand: a bare string list (taxonomies: [tags, category]) uses each 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).
Term 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:
{% if changelog.activeTerm %}
<h1>Tagged {{ changelog.activeTerm | humanize }}</h1>
{% for post in changelog.pageItems %}
<a href="{{ relativePathPrefix }}{{ post.url }}">{{ post.title }}</a>
{% endfor %}
{% pagination changelog %}
{% endif %}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:
{% for tax in changelog.taxonomies %}
{% for term in tax.terms %}
<a href="{{ relativePathPrefix }}{{ term.url }}">{{ term.term | humanize }} ({{ term.count }})</a>
{% endfor %}
{% endfor %}Each term exposes term, slug, url, count and totalPages.
Term pages get a distinct <title> and og/jsonld metadata (Tag: Feature, paged Tag: Feature β Page 2), and the breadcrumb/jsonld filters resolve them to a Home βΊ Collection βΊ Tag: Term trail automatically (skipping the non-page tag/category URL segment). The Tag:/Category: label comes from path, so it localizes by naming the path in your language (path: etiqueta β Etiqueta: β¦). Term pages are listed in the sitemap but kept out of the search index and nav.
Poops can generate responsive <img> elements with srcset attributes. Image processing (resize, format conversion) is handled externally β Poops discovers the generated variants on disk and produces the correct HTML markup.
Naming convention: Your image tool should output variants as {name}-{width}w.{ext}. For example, given photo.jpg, the expected variants are: photo-320w.jpg, photo-640w.jpg, photo-320w.webp, photo-640w.webp, etc.
{% image %} tag β generates a full <img> element:
Nunjucks:
{% image 'static/photo.jpg', alt='Hero', class='hero-img', sizes='(max-width: 640px) 100vw, 50vw' %}Liquid:
{% image 'static/photo.jpg', alt: 'Hero', class: 'hero-img', sizes: '(max-width: 640px) 100vw, 50vw' %}Output:
<img
src="static/photo-640w.jpg"
srcset="
static/photo-320w.webp 320w,
static/photo-640w.webp 640w,
static/photo-960w.webp 960w
"
sizes="(max-width: 640px) 100vw, 50vw"
alt="Hero"
class="hero-img"
loading="lazy"
/>- Scans the output directory for files matching
{name}-{width}w.{ext} - Groups by format, prefers
avif>webp> original format for srcset - Uses the middle-sized variant as
srcfallback - Prepends
relativePathPrefixautomatically - Defaults:
sizes="100vw",loading="lazy" - Falls back to a plain
<img src="...">if no variants are found
Named crops with size β pass a size kwarg to build the <img> from a named crop/resize group instead of the default responsive widths. The whole group becomes its own srcset (each crop has its own aspect ratio), so a square thumbnail set, a wide banner set, etc. each get correct srcset/width/height:
{% image 'static/photo.jpg', size='thumb', alt='', sizes='240px' %}<img
src="static/photo-thumb-480w.webp"
srcset="static/photo-thumb-480w.webp 480w, static/photo-thumb.webp 960w"
width="480"
height="480"
sizes="240px"
alt=""
loading="lazy"
/>This requires the poops-images compile cache (named-size widths are read from it). The size name matches a named entry in your images.sizes config. The largest member of the group is written without a width suffix (photo-thumb.webp) β poops still srcsets it at its real width from the cache.
poops-images integration: if a .poops-images-cache.json compile cache is found in the output directory (poops-images writes one next to the images it generates), Poops reads variants from it instead of scanning the directory. On top of the scan behavior above, the cache gives you:
widthandheightattributes on the<img>element (exact dimensions from the cache β prevents layout shift). Pass your ownwidth/heightkwargs to override.- Correct
srcwhen the source format was converted (e.g.photo.heicβphoto.jpg), even when there are no size variants. - By default the srcset is built only from the plain
{name}-{width}w.{ext}width variants. Named sizes (photo-thumb-480w.webp) and preprocessed outputs (photo-blurred-640w.jpg) are kept out of it β they are crops and effects with their own aspect ratios. Reach a named crop group on purpose with thesizekwarg above (or thesrcsetfilter's second argument). - EXIF metadata via the
exiffilter (see below).
Generates Google Fonts <link> tags with preconnect hints. Accepts an array of font names (strings) or font objects with weight/italic options.
Nunjucks (supports inline arrays):
{% googleFonts ["Open Sans", "Roboto"] %}Liquid (pass a variable β inline arrays are not supported in Liquid syntax):
{% googleFonts fonts %}Where fonts is defined in a data file (e.g. fonts.json):
["Open Sans", "Roboto"]Output:
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Open+Sans&family=Roboto&display=swap"
rel="stylesheet"
/>With specific weights and italics (Nunjucks):
{% googleFonts ["DM Sans", {name: "Poppins", weights: [400, 700], ital: true}] %}With specific weights and italics (Liquid β via data file):
["DM Sans", { "name": "Poppins", "weights": [400, 700], "ital": true }]Font object options:
nameβ font family nameweightsβ array of weight values (e.g.[400, 700])italβ set totrueto include italic variantsdisplayβ font-display strategy, defaults toswap(Nunjucks only, as a keyword argument)
Syntax-highlights code blocks at build time using highlight.js, eliminating layout shift caused by client-side highlighting. Code is pre-highlighted in the HTML output β you only need the highlight.js CSS theme on the client, not the JS.
{% highlight %} tag β wraps a code block with syntax highlighting (same syntax in both engines):
{% highlight 'javascript' %}
const greet = (name) => {
return `Hello, ${name}!`;
};
{% endhighlight %}
Output:
<pre><code class="hljs language-javascript"><span class="hljs-keyword">const</span> greet = <span class="hljs-function">...</span></code></pre>The language argument is optional. If omitted, highlight.js will attempt to auto-detect the language.
Markdown code fences are also highlighted automatically at build time:
```json
{ "name": "poops" }
```Registered languages: javascript/js, typescript/ts, css, scss, html, xml, json, bash/sh, shell, python/py, ruby/rb, php, java, c, cpp, csharp/cs, go, rust/rs, yaml/yml, markdown/md, sql, diff.
Fence info strings. Only the first word names the language. Anything after it is carried onto the <code> element instead of being dropped: a bare word becomes a class, a key=value token becomes a data- attribute. This is how a fence marks itself for a later stage β a post-markup exec script that turns code.preview blocks into live demos, for example β without a marker comment in the markdown.
```html preview tab=options widths=375,768
<my-element></my-element>
```<pre><code class="hljs language-html preview" data-tab="options" data-widths="375,768">β¦</code></pre>Values are single tokens β no quotes, no spaces. A trailing = with nothing after it emits a valueless attribute (expanded= β data-expanded=""), for a flag you want to read with hasAttribute rather than as a class. The same applies to the {% highlight %} tag and the highlight filter.
Renders Previous/Next links and a "page of total" counter for a paginated collection, with relativePathPrefix already applied, and outputs nothing when there is only one page. Same syntax in both engines:
{% pagination changelog %}The page state it reads, and how to localize its labels, are under Collections & Pagination.
All filters are available in both engines. The only syntax difference is how arguments are passed: Nunjucks uses parentheses | filter("arg"), Liquid uses a colon | filter: "arg".
-
slugifyβ slugifies a string. Usage:{{ "My Awesome Title" | slugify }}will outputmy-awesome-title -
humanizeβ the inverse ofslugify: turns a slug or raw term into a display label. Usage:{{ "static-site" | humanize }}will outputStatic Site -
jsonifyβ serializes a value to JSON. Usage:{{ myObject | jsonify }} -
markdownβ renders a markdown string to HTML with GitHub Flavored Markdown extras: emoji shortcodes (e.g.:rocket:β π), alert callouts (> [!NOTE],[!TIP],[!IMPORTANT],[!WARNING],[!CAUTION],[!INFO]) and footnotes ([^1]). Code fences are syntax-highlighted and headings get slugids plus permalink anchors β a heading built from a template tag is slugged from the words it renders, not from the tag, so# {{ site.title }}and# {{ page.title }}anchor wherever# My Sitewould. Usage:{{ "**bold** :rocket:" | markdown }} -
tocβ builds an on-this-page table of contents from rendered HTML: a<nav class="toc" aria-label="On this page">listing every<h2>and<h3>that has anid, each<li>classedtoc-h2/toc-h3so you indent with CSS rather than nested lists. It reads the same ids the markdown heading renderer emits, so the links always land. Headings classedsr-onlyare skipped β a visible entry pointing at invisible content only confuses. Returns an empty string when there is nothing to list. Feed it rendered HTML: on a Markdown source, runmarkdownfirst, or code fences containing#lines get read as headings. Usage:{{ page.content | markdown | toc }} -
dateβ formats a date string. Uses dayjs format tokens. A default format can be set via thedateFormatconfig option; with neither, the value is returned untouched.- Nunjucks:
{{ "2024-01-15" | date("MMMM D, YYYY") }} - Liquid:
{{ "2024-01-15" | date: "MMMM D, YYYY" }}
- Nunjucks:
-
concatβ returns a new array with the value appended (does not mutate the original):- Nunjucks:
{{ items | concat("c") }} - Liquid:
{{ items | concat: "c" }}
- Nunjucks:
-
pushβ appends a value to an array in place (mutates the original):- Nunjucks:
{{ items | push("c") }} - Liquid:
{{ items | push: "c" }}
- Nunjucks:
-
svgβ reads an SVG file and injects it inline. The path is resolved relative to the project root. Returns empty string if the file doesn't exist or isn't an SVG. Usage:{{ 'src/icons/logo.svg' | svg }} -
highlightβ syntax-highlights a code string at build time using highlight.js. Takes an optional language argument. If the language is omitted, highlight.js will auto-detect it. Returns a<pre><code class="hljs">block with highlighted markup.- Nunjucks:
{{ someCodeVariable | highlight('javascript') }} - Liquid:
{{ someCodeVariable | highlight: 'javascript' }}
- Nunjucks:
-
ogβ generates Open Graph (and a Twitter card)<meta>tags from a page's front matter and yoursitedata, for link previews on social/chat platforms. Put it in your layout<head>.og:typeauto-detects:articlewhen the page has adate, otherwisewebsite.- Nunjucks:
{{ page | og(site) }} - Liquid:
{{ page | og: site }}
Emits
og:title,og:description(a missingdescriptionfalls back to the page's auto-excerpt, thensite.descriptionβ the excerpt is taken after the template engine has run, so a first paragraph written as{{ site.description }}or supplied by an{% include %}is resolved, and one that resolves to nothing usable falls through rather than shipping the tag's source text),og:type,og:url(made absolute withsite.url),og:site_name(fromsite.title),og:locale(page.lang/site.lang),og:image(page.image/site.image, made absolute), andtwitter:card(summary_large_imagewhen there's an image, elsesummary). For articles it addsarticle:published_time,article:modified_timeandarticle:author. Attribute values are escaped. Set anogobject in front matter to add or override any tag (e.g.og:image:alt, a fixedtwitter:card):--- title: My post date: 2026-01-01 image: static/cover.jpg og: "og:image:alt": Cover illustration ---
- Nunjucks:
-
canonicalβ generates a<link rel="canonical">tag pointing at a page's authoritative absolute URL (site.url+ the page'surl), the dedup signal that stops query-string and duplicate URLs splitting your ranking. Put it in your layout<head>. Front mattercanonicaloverrides the target β an absolute URL as-is, or a path resolved againstsite.url(for cross-domain or hand-picked canonicals). The homepage canonicals to the site root. Returns nothing withoutsite.url.- Nunjucks:
{{ page | canonical(site) }} - Liquid:
{{ page | canonical: site }}
- Nunjucks:
-
descriptionβ generates the<meta name="description">tag, from the same chainogandjsonlduse: front matterdescription, then the page's auto-excerpt, thensite.description. Put it in your layout<head>. Returns nothing when none of the three is set. Prefer it over writing the tag by hand: Poops renders with autoescape off, socontent="{{ page.description }}"ships the front matter verbatim, and one"in a sentence closes the attribute and truncates the description to the words before it.- Nunjucks:
{{ page | description(site) }} - Liquid:
{{ page | description: site }}
- Nunjucks:
-
jsonldβ generates a schema.org JSON-LD<script type="application/ld+json">block from a page's front matter and yoursitedata, for GEO (Generative Engine Optimization) and structured data. Put it in your layout<head>. The@typeauto-detects:BlogPostingwhen the page has adate, otherwiseWebPage.- Nunjucks:
{{ page | jsonld(site) }} - Liquid:
{{ page | jsonld: site }}
It reads these front-matter fields when present:
title,description(falls back to the page's auto-excerpt, thensite.description),url(made absolute withsite.url),dateβdatePublished,updatedβdateModified,author(string or{ name }, falls back tosite.author),image,langβinLanguage, andwordcount.publishercomes fromsite.title; setsite.logoto add apublisher.logoImageObject (made absolute) β Google Article rich results require it. Front-matter values are escaped so they can't break out of the<script>tag.On the homepage (a page with no
url) it also emits a site-levelWebSiteblock withname+url, which declares the site name for search results. On nested pages (aurlwith at least one folder) it auto-appends aBreadcrumbListblock derived from URL depth β a Google breadcrumb rich result, no extra markup (needssite.urlfor the absolute item URLs). See thebreadcrumbfilter below for a visible trail from the same data.For full control, set a
jsonldobject in front matter β its keys are merged over (and override) the generated defaults, including@type:--- title: How to brew coffee date: 2026-01-01 jsonld: "@type": HowTo totalTime: PT5M ---
The same
jsonldobject works in yoursitedata, as a site-wide default β useful when every page on the site is one type. A docs site isTechArticle, notWebPage:"markup": { "options": { "site": { "jsonld": { "@type": "TechArticle" } } } }
Precedence is defaults β
site.jsonldβpage.jsonld, so a single page can still opt out (aFAQPageinside aTechArticlesite). A site-wide@typealso overrides the auto-detectedBlogPostingon dated pages, so on a site mixing docs and a blog set the type per page instead. Both merge into the page's own block only β the auto-emittedWebSiteandBreadcrumbListblocks are untouched.poops auto-picks
BlogPosting(page has adate) orWebPage. Override@typewith thejsonldobject for any schema.org type β common ones search/generative engines act on:Article,NewsArticle,HowTo,FAQPage,QAPage,Product,Recipe,Event,Course,VideoObject,SoftwareApplication,Organization,Person,BreadcrumbList,WebSite. Full list at schema.org/docs/full; validate with the Rich Results Test. A per-@typetable with the notable fields is in the Templating docs. - Nunjucks:
-
breadcrumbβ generates a visible breadcrumb<nav class="breadcrumb"><ol>β¦</ol></nav>trail for the page body (blog posts, nested pages), from the same URL-depth data thejsonldBreadcrumbListuses: the site root, each ancestor folder (humanized, e.g.docs/static-siteβ Static Site), then the current page asaria-currenttext. PassrelativePathPrefixso links resolve against the current output location (localhost in dev, your deployed subpath in prod) β not the absolute domain.- Nunjucks:
{{ page | breadcrumb(site, relativePathPrefix) }} - Liquid:
{{ page | breadcrumb: site, relativePathPrefix }}
The home crumb is optional: set
breadcrumb: { home: false }(or{ homeLabel: "Start" }to rename it) undersiteor in a page's front matter β front matter wins. With the home crumb off, top-level pages fall to a single crumb and render nothing, while nested pages still show their folder trail.breadcrumb: falseon a page or onsitedisables both the visible trail and the autoBreadcrumbListJSON-LD. Returns nothing on the homepage or any single-crumb page. - Nunjucks:
-
groupbyβ groups an array of objects by a field value. Returns an array of{ key, items }objects. Supports an optional second argument for date part extraction (year,month,day). Groups preserve insertion order, so if items are sorted by date descending, groups will be too. Array-valued fields split per element β an item withtags: [js, css]appears in both thejsandcssgroups (the mechanism behind taxonomies).- Nunjucks:
{{ changelog.items | groupby("author") }}or{{ changelog.items | groupby("date", "year") }} - Liquid:
{{ changelog.items | groupby: "author" }}or{{ changelog.items | groupby: "date", "year" }}
Example β group posts by year:
{% set byYear = changelog.items | groupby("date", "year") %} {% for group in byYear %} <h2>{{ group.key }}</h2> {% for post in group.items %} <p>{{ post.title }}</p> {% endfor %} {% endfor %}
- Nunjucks:
-
srcsetβ returns just the srcset attribute value:
<img
src="static/photo-640w.jpg"
srcset="{{ 'static/photo.jpg' | srcset }}"
sizes="100vw"
alt="Hero"
/>Returns: static/photo-320w.webp 320w, static/photo-640w.webp 640w, static/photo-960w.webp 960w
Pass a named crop/resize group as the second argument to get that group's srcset instead of the default widths: {{ 'static/photo.jpg' | srcset: 'thumb' }} β static/photo-thumb-480w.webp 480w, static/photo-thumb.webp 960w.
-
exifβ returns the EXIF metadata object for an image from the poops-images compile cache (.poops-images-cache.jsonin the output directory), ornullif there is no cache or no EXIF data. The object includes camera (make,model,lensModel), exposure (fNumber,exposure.formatted,iso,focalLength35mm),dateTime, andgps(latitude.formatted,longitude.formatted,altitude, and a ready-madegoogleMapsUrl).Example β a photo with date and location caption:
{% set meta = 'static/photo.jpeg' | exif %} <figure> {% image 'static/photo.jpeg', alt='Sendai at dusk' %} {% if meta %} <figcaption> {{ meta.dateTime | date("MMMM D, YYYY") }} {% if meta.gps %} β <a href="{{ meta.gps.googleMapsUrl }}">{{ meta.gps.latitude.formatted }}, {{ meta.gps.longitude.formatted }}</a> {% endif %} {% if meta.model %}Β· {{ meta.model }}{% endif %} </figcaption> {% endif %} </figure>
-
imagesβ lists all images under a site-relative directory from the poops-images compile cache. Returns an array of{ path, width, height, date, exif, outputs }objects, or an empty array if there is no cache:pathβ site-relative source path, feeds straight into the{% image %}tagdateβexif.dateTimewhen the photo has EXIF, file modification time otherwise β so sorting and grouping work for every imageoutputsβ every generated file for the image (site-relative), useful for picking LQIP or preprocessed variants- Pass a subdirectory (
'static/images/2025') to scope the list
The path is relative to your markup
outdir, not toimages.in. It mirrors where the generated images land β i.e.images.outmade relative to markupout. So ifimages.outis_site/static/imagesand markupoutis_site, the images live atstatic/imageson the site and you call'static/images' | images(not'images', which would look in_site/imagesand return[]). This is the same path you already pass to the{% image %}tag.Combined with
groupby, engine-native sorting and the{% image %}tag, a photo gallery is a pure template concern. This is the Instagram-style square grid βsize='thumb'pulls the named crop group and its auto-generated srcset (define athumbcrop inimages.sizes):Nunjucks:
{% for group in 'static/images' | images | sort(reverse=true, attribute='date') | groupby("date", "year") %} <h2>{{ group.key }}</h2> <div class="grid"> {% for img in group.items %} <figure> {% image img.path, size='thumb', alt='', sizes='(max-width: 640px) 50vw, 240px' %} {% if img.exif and img.exif.gps %} <figcaption> <a href="{{ img.exif.gps.googleMapsUrl }}">π</a> {{ img.date | date("MMM D, YYYY") }} </figcaption> {% endif %} </figure> {% endfor %} </div> {% endfor %}
Liquid:
{% assign imgs = 'static/images' | images | sort: 'date' | reverse %} {% assign groups = imgs | groupby: "date", "year" %} {% for group in groups %} <h2>{{ group.key }}</h2> <div class="grid"> {% for img in group.items %} <figure>{% image img.path, size: 'thumb', alt: '' %}</figure> {% endfor %} </div> {% endfor %}
A page can always carry its own updated in front matter, and Poops uses it for dateModified, article:modified_time and the sitemap's <lastmod>. Keeping it right by hand is the part nobody does.
The file's modification time can't stand in for it: git clone sets every file's mtime to checkout time, so on CI every page reads as edited today. lastUpdated keeps an index of content hashes instead β a page's date only moves when its body actually changes, and every build after that reads the same date back:
{
"markup": {
"in": "src/markup",
"out": "dist",
"options": {
"lastUpdated": true
}
}
}That writes .poops-updates.json in your project root; a string names a different file. Pages get page.updated, so a template prints it like any other field:
{% if page.updated %}<p>Last updated {{ page.updated | date("D MMMM YYYY") }}</p>{% endif %}Commit the index file. It is the entire memory of the feature β the dates only survive a clone if it travels with the pages it describes. A build says so whenever it changes:
[markup] Updated dates changed for 3 pages β commit .poops-updates.json
That also means a build has to run before you commit. Commit an edit without one and the index is a build behind: the next build to see that page stamps it with whatever mtime it then has, which on CI is clone time.
What moves a date and what doesn't:
| Change to a page | Date moves | Why |
|---|---|---|
| Body edited | yes | the hash covers the body |
| Title, tags, any front matter | no | front matter sits outside the hash β retagging isn't editing |
| File touched, content the same | no | mtime is not the signal, the hash is |
| Reformatted, one space added | yes | a hash can't tell a typo fix from a rewrite |
updated written by hand |
no | yours wins, and that page stays out of the index entirely |
| Page deleted | β | its entry is dropped on the next full build |
The date itself is the file's mtime at the build that first saw the change β the real edit time, taken on the machine that made the edit.
Poops can automatically generate a JSON search index, an XML sitemap, an llms.txt, a robots.txt and a navigation tree from your compiled pages. All are generated in a single pass during the markup compilation phase.
To enable, add searchIndex, sitemap, llms, robots and/or nav to your markup config:
{
"markup": {
"in": "src/markup",
"out": "dist",
"options": {
"searchIndex": "search-index.json",
"sitemap": "sitemap.xml",
"llms": "llms.txt",
"robots": "robots.txt"
}
}
}The string shorthand sets the output filename with default options. For more control, use the object form:
{
"searchIndex": {
"out": "search-index.json",
"minWordLength": 3,
"maxKeywords": 20,
"globalFrequencyCeiling": 0.8,
"stopWords": "path/to/custom-stop-words.json"
},
"sitemap": {
"out": "sitemap.xml"
},
"llms": {
"out": "llms.txt",
"title": "My Site",
"description": "One-line summary of the site.",
"intro": "src/llms-intro.md",
"full": true
}
}Search Index options:
outβ output filename, written to the markup output directoryminWordLengthβ minimum word length to consider as a keyword (default:3)maxKeywordsβ maximum keywords per page (default:20)globalFrequencyCeilingβ drop words appearing in more than this fraction of all pages (default:0.8, meaning words found in 80%+ of pages are dropped as non-discriminating)stopWordsβ customise stop word filtering:- omit or
undefinedβ uses the bundled English stop words falseβ disables stop word filtering entirely["word1", "word2"]β inline array of stop words"path/to/file.json"β path to a JSON array file (relative to project root)
- omit or
Search Index output format:
All front matter fields are passed through to the index automatically. Internal fields (content, isIndex, layout, published) are stripped. If a page defines keywords in its front matter, those are used as-is instead of auto-extracted ones.
[
{
"title": "My Post",
"date": "2024-01-15",
"description": "A great post about things.",
"collection": "blog",
"tags": ["javascript", "bundler"],
"url": "blog/my-post.html",
"keywords": ["javascript", "bundler", "webpack", "esbuild"]
}
]Sitemap generates a standard sitemap.xml with <loc> and <lastmod> (front matter updated, falling back to date; see Last updated dates). If site.url is set in your markup config, it is prepended to all URLs. Collection index/pagination and taxonomy term pages are included in the sitemap but excluded from the search index.
llms.txt generates an llms.txt β a Markdown index of your pages that LLMs and generative engines (GEO) read to understand your site. It has 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 under it, and root-level pages fall under a lead "Pages" section. So docs/config-reference.html lands directly under ## Docs while docs/quick-start/x.html lands under ### Quick Start inside it. Collection items (which live under collection/β¦) group the same way and are ordered newest-first by their date; other sections keep file order. Set intro to a Markdown file path (relative to the project root) to insert free-form context between the blockquote and the link sections β a file authored for LLMs, e.g. llms-intro.md. Avoid ## headings in it; they read as sections. (A raw README is a poor fit β badges, install noise and its own headings collide.) title and description default to your site.title/site.description; override them (and the lead section name via sectionTitle) with the object form. site.url makes the links absolute. Collection index/pagination pages are skipped, like the search index.
Set full to also write a companion full-content file β every page's Markdown body concatenated into one file an LLM can ingest whole (the index is the link map; this is the 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. The file opens with a # Full Documentation Archive for {title} header, a one-line intro naming the site and a > blockquote of the description so a whole-file ingest starts with context, then each page becomes an # title (its own leading H1 if it has one) + URL: line + 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). Only .md/.markdown sources qualify (a .njk/.liquid source is template code, not prose); noindex and collection-index pages are dropped. Content is the Markdown source, taken before the template engine ran β so the machinery a source still carries is stripped on the way out: {# β¦ #} comments, {% β¦ %} tags and {{ β¦ }} output (a {% set x %}β¦{% endset %} capture leaves behind the prose it wrapped), plus inline <style> and <script> blocks and <script src>/<link> tags. Fenced blocks, inline code spans and {% raw %} bodies keep theirs β a sample documenting template syntax is content. The strip is syntactic, not semantic: text inside a {% if %} that would not have rendered still contributes. A feed's article HTML is built from the same stripped source.
robots.txt generates a robots.txt. The string shorthand 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 out, userAgent, allow/disallow (a path or array of paths), and sitemap (an explicit URL, or false to omit the line):
{
"robots": {
"out": "robots.txt",
"disallow": ["/admin", "/drafts"],
"sitemap": false
}
}Pages with published: false in their front matter are excluded from all outputs.
A page's front matter robots: noindex (or none) drops it from the sitemap and llms.txt β for drafts, thin or utility pages (a 404, say) you don't want crawled or fed to LLMs. It stays in the search index (that's your own on-site search). Emit the matching crawler directive in your layout <head> so the page itself carries it:
{% if page.robots %}<meta name="robots" content="{{ page.robots }}" />{% endif
%}Navigation tree builds your page hierarchy as sidebar-ready data, exposed two ways: as the nav template global (loaded automatically, always reflecting the current build) and as a nested JSON file for client-side rendering. Subpages nest automatically from URL structure: guide/index.md becomes a parent node and guide/getting-started.md, guide/advanced/config.md become its (and its subsections') children. Add nav to your markup config:
{
"markup": {
"in": "src/markup",
"out": "dist",
"options": {
"nav": "nav.json"
}
}
}The string shorthand sets the output filename. For docs sites, use the object form:
{
"nav": {
"out": "nav.json",
"root": "docs",
"collections": "index",
"home": false
}
}Navigation options:
outβ output filename, written to the markup output directorycollectionsβ how to treat collection pages (defaulttrue):trueβ include every collection page, nested under its collectionfalseβ exclude all collection pages (drops a blog's posts from the sidebar)["docs", ...]β allowlist; only these collections' pages are included (non-collection pages are always kept)"index"β include only each collection's landing page as a single leaf, not its posts
homeβfalsedrops the site's root index page (url"") from the tree (defaulttrue)rootβ scope the tree to a subdirectory (e.g."docs"); its children are emitted at the top level and the section's own index page is pinned first as the overview link. URLs are kept full (docs/getting-started), so the homepage is naturally excluded
Front matter fields that shape the tree:
orderβ a number that sorts a page within its sibling level (optional). Pages withoutorderfall to the bottom, sorted alphabetically by title β so a hand-authored docs sequence (1,2,3) wins over alphabetical. This applies to the homepage too: give itorder: 0in its front matter to pin it to the top, otherwise it sorts last like any page withoutorder.nav: falseβ hide a page from the sidebar (it stays in the search index and sitemap).navTitleβ a sidebar label that overridestitle.navGroupβ a section heading to file the page under, without moving the file. The tree otherwise comes from the url alone, so grouping pages that live side by side used to mean a subdirectory and a changed url.navGroup: "No pattern"synthesizes a section beside the page's ungrouped siblings and nests the page in it; the url, and every link to it, stays as it was. The label is used as written, not humanized, and the section sorts where its first child would. Grouping is per directory β the same name in two directories opens two sections, one in each β and a directory named like a group is never merged into it.navGroupis refused, with a warning, on a page that is also a section (a directory's index page, or the homepage): its subpages would stay behind on the ungrouped path and split the section in two.
Navigation output format β each node has a title, a url (omitted on synthesized section nodes β a directory with no index page of its own, or a navGroup), an order when set, and children when it has subpages:
[
{
"title": "Guide",
"url": "guide",
"order": 1,
"children": [
{
"title": "Getting Started",
"url": "guide/getting-started",
"order": 1
},
{
"title": "Advanced",
"url": "guide/advanced",
"children": [{ "title": "Config", "url": "guide/advanced/config" }]
}
]
}
]Pages with published: false or nav: false are excluded. If nothing survives filtering, an empty array [] is written so consumers never have to special-case a missing file.
Rendering the sidebar. The tree is arbitrarily deep, so render it with a recursive template. The nav global is built from front matter in a pre-pass before templating, so it always reflects the current build β no need to load the generated nav.json back in via data (which would be one build behind). The written nav.json is for client-side rendering (fetch('/nav.json')). Prefix each url with relativePathPrefix so links resolve from any page depth.
Nunjucks β a self-recursing macro:
{% macro navtree(items) %}
<ul>
{% for item in items %}
<li>
{% if item.url != null %}<a href="{{ relativePathPrefix }}{{ item.url }}">{{ item.title }}</a>
{% else %}<span>{{ item.title }}</span>{% endif %}
{% if item.children %}{{ navtree(item.children) }}{% endif %}
</li>
{% endfor %}
</ul>
{% endmacro %}
{{ navtree(nav) }}Important
Note the != null check: the homepage node's url is an empty string (a valid link β relativePathPrefix resolves it), while synthesized section nodes have no url at all. A plain {% if item.url %} would wrongly demote the homepage to a <span>. Node titles already have navTitle applied, so {{ item.title }} is all you need.
Liquid β a partial that recurses via render (save as _partials/navtree.liquid). Liquid treats empty strings as truthy, so the plain if is safe here:
<ul>
{% for item in items %}
<li>
{% if item.url %}<a href="{{ relativePathPrefix }}{{ item.url }}">{{ item.title }}</a>
{% else %}<span>{{ item.title }}</span>{% endif %}
{% if item.children %}{% render 'navtree', items: item.children, relativePathPrefix: relativePathPrefix %}{% endif %}
</li>
{% endfor %}
</ul>{% render 'navtree', items: nav, relativePathPrefix: relativePathPrefix %}Generate a subscription feed for a collection β no hand-authored feed template. Each feed lists the collection's posts newest-first (by date), with the channel metadata taken from your site data.
{
"markup": {
"in": "src/markup",
"out": "dist",
"options": {
"feed": { "collection": "blog", "out": "blog/feed.rss" }
}
}
}Feed options:
collectionβ the collection to build the feed from. Omit it to emit a feed for every collection.outβ the file to write. A bare filename (feed.xml, the default) is placed inside the collection's own folder (blog/feed.xml); a value with a slash is used as-is under the output directory.typeβ"rss"(default) or"atom".limitβ max items, newest first (default20).titleβ channel title (default"<Collection> | <site.title>").descriptionβ channel description (defaultsite.description).author,langβ default tosite.author/site.lang.contentβtrueadds each post's full article HTML to its item (RSS<content:encoded>, Atom<content type="html">), so a reader shows the whole post instead of a teaser. Off by default.
Shorthand forms: "feed": true (or a filename string) emits an RSS feed for every collection; an array of the objects above generates several feeds at once (e.g. an RSS and an Atom for the same collection). Item <description> uses each post's description, falling back to its auto-excerpt; links, guids and <atom:link rel="self"> are made absolute with site.url. robots: noindex posts are excluded, matching the sitemap.
content: true renders the post's Markdown source to article-body HTML β the body only, no layout, nav or footer chrome. So only .md/.markdown posts get it; anything else falls back to <description> alone. Unrendered {% β¦ %} tags in a body pass through verbatim.
Point browsers and readers at it from your layout <head>:
<link
rel="alternate"
type="application/rss+xml"
href="{{ site.url }}/blog/feed.rss"
/>Process and optimize images β compression, responsive size variants, format conversion (WebP/AVIF), crops and EXIF extraction β by running poops-images as part of the build. This is what feeds the {% image %} tag, the exif/images filters and the .poops-images-cache.json compile cache described in Custom Tags and Custom Filters.
poops-images (and its sharp dependency) is not bundled with Poops. Install it in your project only if you use the images config:
npm i poops-imagesIf the images key is present but poops-images is not installed, Poops logs a warning and skips image processing β the rest of the build still runs.
The images value is a poops-images config object (see the poops-images options reference). Poops' schema leaves it open, since poops-images owns those keys β poops-images publishes its own schema, and its README shows how to point images at it for completion inside poops.json. The most common keys:
inβ source images directoryoutβ output directory (keep it distinct fromin, and outside your watched source, so generated variants don't retrigger the build)sizesβ responsive widths to generateformatβ target formats (e.g.["webp"], or"smart"to keep whichever of JPEG/WebP is smaller)verbose- defaults tofalse, so you get a single[image]summary line (count + time) instead of one log per file. Set"verbose": trueto restore the per-file logs.
{
"images": {
"in": "src/images",
"out": "dist/images",
"sizes": [{ "width": 640 }, { "width": 1280 }],
"format": "smart"
}
}Images are processed before markup, so {% image %} and the images filter always read a fresh cache. In watch mode, changing a source image reprocesses it and recompiles markup; deleting one removes its generated variants and updates the galleries that referenced it. Custom handlers and composite overlays resolve relative to your poops.json.
Configuration entry to copy files or directories - copy your static files like images and fonts, for instance, from src to dist directory. This feature was added to enable moving static files if you deploy GitHub pages via a GitHub action. If you don't want to use this feature, simply exclude the copy property from your config file.
Here is a sample copy configuration which will copy the static directory and it's contents to the dist directory:
{
"copy": {
"in": "src/static",
"out": "dist"
}
}You can specify a list of input paths and pass them to an output directory, for instance:
{
"copy": {
"in": ["src/static/ogimage.jpg", "src/static/favicon.ico", "src/fonts"],
"out": "dist"
}
}Tip
Copy property can also accept the list of objects containing in and out properties. For instance:
{
"copy": [
{
"in": ["src/static/ogimage.jpg", "src/static/favicon.ico", "src/fonts"],
"out": "dist"
},
{
"in": "images",
"out": "dist/static"
}
]
}Tip
Copy can also accept GLOB and EXTGLOB patterns as input paths, except POSIX character classes (e.g. [[:alpha:]]):
{
"copy": {
"in": [
"images/**/awesome.{jpeg,jpg,png}",
"notes/info[0-9].txt",
"notes/doc?.txt",
"notes/memo*.txt",
"notes/log[!123a].txt",
"assets/!(vendor)/*.js",
"fonts/@(woff|woff2)/*.+(woff|woff2)",
"docs/?(intro|overview).md"
],
"out": "dist"
}
}Shell commands to run around a pipeline stage compiling β a generator that has to write before the stage reads, like fetching content the templates render, or a post-processor that needs the built output, like stripping comments from the unminified CSS. exec is keyed by stage, each value a command string or an array of them run in order. A bare stage key runs after the stage, pre:<stage> runs before it:
{
"exec": {
"pre:markup": "node script/fetch-posts.mjs",
"styles": [
"node script/strip-css-comments.mjs dist/styles.css",
"node script/gen-reference.mjs"
],
"build": "node script/deploy.mjs"
}
}Why not just chain cmd && poops -b && cmd in an npm script: the hooks run on every rebuild, watch mode included, so neither the generated input nor the post-processed output drifts while you work.
Stages:
| Stage | pre: runs before |
the bare key runs after |
|---|---|---|
styles |
Sass compiles | the CSS is final β past PostCSS, in build and in watch alike |
scripts |
scripts compile | scripts compile |
reactor |
reactor components render. Build only β a watch re-render fires markup |
reactor components render. Build only, same reason |
images |
images process | images process |
markup |
markup renders | markup renders |
copy |
files copy | files copy |
build |
once, before the pipeline starts β not again on a watch rebuild | once, after the full initial pipeline β not again on a watch rebuild |
Note the asymmetry on styles: the pair brackets the whole style pipeline, so pre:styles fires ahead of Sass while styles fires past PostCSS.
post:<stage> is the explicit spelling of the bare key, for configs that read better with pre:markup above post:markup than above something that looks like a typo. It is an alias, not a third hook β but a config setting both fires both, bare first.
Commands run from the project root, synchronously, with their output streaming live. A failing command fails a poops -b build's exit code; in watch it is logged and swallowed so the watcher survives. A key that is not one of the stages above, in one of the three spellings, never runs β so Poops names it at startup rather than letting the hook silently no-op:
[exec][warn] unknown stage "style" β never runs. Valid: reactor, scripts, images, markup, styles, copy, build β each also as pre:<stage> and post:<stage>
If you have nothing to generate or post-process, remove the exec property from the config.
Here you can specify a banner that will be added to the top of the output files. It is templatable via mustache. The following variables are available from your project's package.json:
nameversionhomepagelicenseauthordescription
Plus one that isn't from package.json:
yearβ the current year, for a copyright line
Here is a sample banner template.
/* {{ name }} v{{ version }} | {{ homepage }} | {{ license }} License */
You can always pass just a string, you don't have to template it.
The same tokens are filled in markup.options.site values, which is how a footer prints the version without it being written down twice. A token that is not one of these stays as written β a {{ verison }} sitting in the output is the typo, where undefined would just look like a bug in Poops.
If you don't want to add a banner, just remove the banner property from the config.
Sets up a local server for your project.
Server options:
port- the port on which the server will runbase- the base path of the server, where your HTML files are located. Defaults to the markupoutdirectory, so most configs can leave it out
If you don't want to run a local server, just remove the serve property from the config.
Reloads the browser when a build finishes. It is a switch, nothing more:
{
"serve": { "base": "dist" },
"livereload": true
}Live reload rides the serve port β there is no second server and no port to
configure, so it needs serve to be on. With both set, Poops answers
/__poops_reload as a server-sent events
stream and appends a small client script to every HTML page it serves. Your
templates need no snippet. Nothing is injected into your build output β the
script exists only in the response the dev server writes.
A save triggers exactly one reload, after the build that follows it has finished. When everything a build wrote is CSS, the stylesheets are swapped in place instead: the page is not reloaded, so scroll position, open dialogs and form state survive a style edit.
The browser reconnects on its own, so restarting Poops picks the open tabs back up without touching them.
To turn it off, remove the livereload property or set it to false.
Sets up a watcher for your project which will rebuild your files on change.
watch property accepts an array of paths to watch for changes. If you want to watch for changes in the src directory, just add it to the watch array:
{
"watch": ["src"]
}Or set it to true and Poops derives the list from every task's own in path β scripts, styles, reactor (its component too), markup, copy, images, plus any tokenPaths. An entry pointing at a file collapses to its parent directory, so editing a sibling import still triggers the rebuild:
{
"watch": true
}That covers sources living under a task's own directory. Imports reaching outside it β a shared folder above the entry, something in node_modules β are not watched; name those in an explicit array. A directory with a dot in its name is read as a file and collapses to its parent, which is the other reason to pass the array yourself.
If you don't want to watch for file changes, just remove the watch property from the config.
This property is used to specify paths that you want to resolve your imports from. Like node_modules. You don't need to specify the includePaths, node_modules are included by default. But if you do specify includePaths, you need to include node_modules as well, since this change will override the default behavior.
Same as watch property, includePaths accepts an array of paths to include. If you want to include lib directory for instance, just add it to the includePaths array:
{
"includePaths": ["node_modules", "lib"]
}Issues and pull requests are welcome β see CONTRIBUTING.md for how to set up, what a PR needs, and how releases are cut.
Released changes are in CHANGELOG.md, written up in full on the changelog site.
Why doesn't anyone maintain GULP anymore? Why does Parcel hate config files? Why are Rollup and Webpack so complex to setup for simple tasks? Vite???? What's going on?
I'm tired... Tired of bullshit... I just want to bundle my scss/sass and/or my js/ts to css and iife/esm js, by providing input and output paths for both/one. And to be able to have minimal easily maintainable dependencies. I don't need plugins, I'll add the features manually for the practice I use. That's it. The f**king end.
To better illustrate it, here is a sample diff of Poops replacing Rollup:
This is a bundler written by me for myself and those like me. Hopefully it's helpful to you too.
Love β€οΈ and peace βοΈ.
