Skip to content

Releases: stamat/poops

v3.0.0

Choose a tag to compare

@stamat stamat released this 22 Aug 14:13

A ```mermaid fence came out of the build as syntax-highlighted CSS. highlight.js has no mermaid grammar, so the fence fell through to auto-detection, which took graph TD; A-->B; for selectors and wrapped the diagram source in hljs-selector-tag spans. Mermaid could still read it — textContent drops the spans — but only if you told it to look at code.language-mermaid, and the page flashed the wrongly-coloured source first.

Added

  • A mermaid fence compiles to <pre class="mermaid">, with no <code> element and the diagram source escaped inside. That is the markup mermaid documents and the default its run() selects on, so loading mermaid on the page is all it takes. The fence info string keeps working: ```mermaid wide tab=deps still becomes class="mermaid wide" data-tab="deps".

Changed

  • book-of-spells 1.5 → 2.5, poops-docs-theme 4.2 → 4.5. The one that shows: removeAccents in book-of-spells now folds letters whose mark is written through the glyph — Đ Ł Ø Ħ Þ ß and friends — which slugify inherits, and slugify is what Poops builds heading anchors, collection slugs and permalinks from. Đorđe Balašević used to slug to ore-balasevic because the unfolded letter was deleted outright; it is dorde-balasevic now. A site with those letters in a heading or a title will move the URLs they generate — worth a redirect if anything links to the old ones.

  • mermaid is never syntax-highlighted, through fences, the {% highlight %} tag and the highlight filter alike. Only the language name is special — a JavaScript fence that mentions mermaid is still highlighted as JavaScript.

Poops ships no mermaid and injects no script, so a page with no diagram carries no library, and a diagram on a page that never loads one shows its source as readable text. The trade against the code-fence story is real and stated in the README: highlighting happens at build time to avoid layout shift, and a browser-rendered diagram pops in after paint.

Full Changelog: v2.5.0...v3.0.0

v2.5.0

Choose a tag to compare

@stamat stamat released this 11 Aug 10:47

A page whose body was built from template tags described itself with their
source. # {{ site.title }} over {{ site.description }} shipped an
og:description reading "site.description" and an anchor at #site-title,
while the page itself rendered the real title and the real description right
underneath. The build stayed green through both, and the only symptom was a link
preview nobody looks at until someone shares the page.

Added

  • A description filter emits <meta name="description">, escaped.
    {{ page | description(site) }} (Liquid: {{ page | description: site }})
    reads the same chain og and jsonld do — front matter description, then
    the page's auto-excerpt, then site.description — so the three cannot
    disagree, and emits nothing when none of them is set. Poops renders with
    autoescape off, so a layout writing that tag by hand ships the front matter
    verbatim: one " in a sentence closes the attribute and truncates the
    description to the words before it, silently, on a green build. Poops' own
    v2.4.0 post described itself as A for exactly that reason. Escaping now
    happens in one named place rather than in every layout that remembers.

Fixed

  • page.excerpt is taken after the template engine has run. A first
    paragraph written as {{ site.description }}, or supplied by an
    {% include %} ahead of the prose, is resolved before the text is taken, so
    the description is the one a reader gets. The excerpt now reads the rendered
    body's first <p> rather than guessing at the markdown source — headings,
    comments, code fences and tables are skipped because they are not paragraphs,
    and link text comes through without its URL. When nothing usable resolves the
    excerpt is empty and og/jsonld/<meta name="description"> fall through to
    site.description — the old behaviour was to strip the braces and ship
    site.description as a literal string, which then beat the very value it was
    standing in front of. Only pages whose first paragraph is a tag pay for the
    extra render.

    A collection item is read without a page context, so its excerpt is empty in
    that case rather than resolved; listings and feeds fall back to the item's
    description. The item's own page still resolves it.

  • Heading ids are slugged from the rendered heading. # {{ site.title }},
    # {{ page.title }} and # My Site now all anchor at #my-site, so swapping
    which variable feeds a heading no longer remints every bookmarked URL. The
    toc filter slugs from the same text, so its links still land.

Full Changelog: v2.4.0...v2.5.0

v2.4.0

Choose a tag to compare

@stamat stamat released this 10 Aug 18:13

A "last updated" line under a post had two ways to get its date and both were
bad. Keep updated current by hand and it is one more thing to forget in every
edit. Read the file's modification time instead and git clone erases it — CI
checks out every file at once, so the whole site reads as edited today.

Added

  • markup.options.lastUpdated gives every page an updated date, without
    shelling out to git.
    Set it to true and Poops keeps an index of content
    hashes at .poops-updates.json (a string names a different file). A page's date
    moves only when its body actually changes — front matter is outside the hash,
    so retitling or retagging a post is not editing it — and every build after
    that reads the same date back. Templates get page.updated like any other
    front-matter field; it also feeds dateModified, article:modified_time and
    the sitemap. A page carrying its own updated keeps it and stays out of the
    index. The index file has to be committed, and a build has to run before
    the commit — it is the only memory of when a page was edited, so a build says
    Updated dates changed for 3 pages — commit .poops-updates.json whenever it moves.

Fixed

  • The sitemap's <lastmod> now means last modified. It read front matter
    date — a publication date — so a post revised for a year still advertised
    itself to crawlers as untouched since the day it went up. updated wins where
    a page has one, with date as the fallback.
  • A date front matter cannot fail the build. A date (or updated) that
    is not a date reached toISOString() on an Invalid Date while the sitemap was
    being written, and took the whole build down with a RangeError that named no
    page. The entry now goes out without a <lastmod>.

Full Changelog: v2.3.0...v2.4.0

v2.3.0

Choose a tag to compare

@stamat stamat released this 10 Aug 02:03

A footer wanting to print your version had nowhere to get it. poops.json is
JSON, so a value cannot compute one, and a theme rendering site.footer renders
it verbatim — which left the number hardcoded in a second file, silently wrong
from the next release on.

Added

  • exec hooks can run before a stage, not only after it. A generator that has to write
    before the stage reads — fetching posts the templates render, writing a data file — had
    nowhere to go. Hijacking an earlier stage's hook worked in a build, where the pipeline order
    put it first, and then silently stopped working in watch, because a markup-only edit never
    fires the earlier stage. Every stage now also takes pre:<stage>, fired in build and watch
    alike: "pre:markup": "node script/fetch-posts.mjs". pre:build runs once before the
    pipeline starts, which is where clearing dist or fetching content belongs, and pre:styles
    fires ahead of Sass — the pair brackets the whole style pipeline, since styles fires past
    PostCSS. post:<stage> is accepted as the explicit spelling of a bare <stage>, so a config
    can read pre:markup above post:markup instead of above something that looks like a typo.

  • markup.options.site values are filled from package.json, with the same tokens a
    banner uses.
    "footer": "MyLib v{{ version }} — MIT licensed" now prints the version
    the manifest holds, filled at build time from the one place it lives. {{ name }},
    {{ homepage }}, {{ license }}, {{ author }}, {{ description }} and {{ year }}
    fill the same way, at any depth — a title inside site.links gets them too — and the
    pkg option still says which package.json to read.

  • A page can join a sidebar section without moving. The nav tree is built from urls, so
    grouping pages that already sit side by side meant a subdirectory — and a subdirectory
    changes their url, every link pointing at them and every search result that has been
    indexed, to buy one heading in a sidebar. navGroup: "No APG pattern" in front matter
    synthesizes that section beside the page's ungrouped siblings and nests the page inside it,
    url untouched. The label is used as written rather than humanized, the section sorts where
    its first child would, and grouping is per directory — the same name in two directories
    opens a section in each, instead of one reaching across the tree. A page that is itself a
    section (a directory's index page, or the homepage) is refused with a warning: its
    subpages would stay behind on the ungrouped path and split the section in two.

Fixed

  • A banner token with no matching package.json field no longer prints undefined.
    {{ homepage }} in a package that declares none put that literal word at the top of
    every built file, which reads as Poops being broken rather than as a field you never
    filled in. The token is now left as written, naming what is missing.

  • A PostCSS pass no longer leaves a stale source map beside its output. Poops wrote the
    processed CSS and threw away the map PostCSS had composed for it, so whatever map the Sass
    stage left on disk stayed there describing lines the pass had since moved — a debugger
    pointing confidently at the wrong rule. The composed map is now written next to the output,
    shifted past the banner line like every other stage. An input with no map still produces none.

Full Changelog: v2.2.0...v2.3.0

v2.2.0

Choose a tag to compare

@stamat stamat released this 07 Aug 14:19

A companion's block passed in silence only when you depended on the companion by
name, which a package that arrives through another one never is. Separately, the
permalink anchor beside every heading was hidden from screen readers and still
reachable by keyboard — a tab stop with nothing to announce, once per heading,
on every page Poops has built.

Added

  • A direct dependency can vouch for a companion's config key. A septic block
    passed in silence only when septic itself sat in your package.json — but laxative
    brings septic, so a laxative app declares laxative and the warning fired on every
    build. A dependency now vouches through its own manifest,
    "poops": { "companionKeys": ["septic"] }, read one directory deep and never loaded.
    Nothing declared and nothing vouched still warns, which is what catches the typo.

Fixed

  • The heading permalink is no longer a tab stop on nothing. Every heading gets an
    anchor, and it carried aria-hidden="true" while staying focusable — so a keyboard
    reached it, and a screen reader had nothing to announce when it landed. That is a link
    with no accessible name in the tab order, once per heading, on every page Poops has ever
    built. It also carried aria-label="Permalink", which aria-hidden had already made
    unreadable to anyone.

    The anchor now says the same thing three ways instead of three different things: it is
    decoration, so it takes tabindex="-1" beside the aria-hidden it always had, and the
    dead aria-label is gone. The heading beside it is already the name of the place.
    DOM change: <a class="heading-anchor" href="#id" tabindex="-1" aria-hidden="true">,
    where it was aria-label="Permalink" aria-hidden="true". Nothing changes for a mouse —
    the "#" a theme reveals through .heading-anchor::before still clicks — and a theme
    styling [aria-label="Permalink"] rather than the class is the one selector this breaks.

  • Config Poops has always accepted is finally written down. exec had no section in
    the README at all, only two passing mentions of a key nothing introduced. watch: true,
    the per-entry nodePaths, markup.options.autoescape and dateFormat, the feed's
    content, the toc filter and the banner's {{ year }} were each real, tested and
    undocumented — some in the README, some on the docs site, nodePaths and {{ year }}
    in neither. Two engine-contract listings also still advertised fileExtension and
    renderString, which the pipeline stopped calling, while omitting the invalidate /
    pagesDependingOn pair that incremental rebuilds actually run on. Nothing changed in the
    code; the pages now say what it does. A new test walks every link in the README and the
    documentation site, so the eleven dead ones found writing this are the last of them.

Full Changelog: v2.1.0...v2.2.0

v2.1.0

Choose a tag to compare

@stamat stamat released this 05 Aug 23:52

The config file now tells your editor what belongs in it — and tells Poops,
which no longer lets a key misspelt inside a block pass in silence. It also
stops calling a companion package's block a mistake. Separately, llms was the one index
feature that could not turn itself on, and the corpus it writes carried the
machinery of the pages it was made from.

Added

  • A JSON Schema for poops.json, so the editor catches a typo you would
    otherwise find in the output.
    A top-level "stlyes" was warned about and
    ignored, and a mistyped script option reaches esbuild, which rejects it — but
    "minfiy" in a style's options is read by nothing and warned about by nobody.
    The build stays green and the .min.css is never written.

    Poops now ships schema/poops.schema.json, covering every key, with the
    documentation for each one inline. Point $schema at it and the editor
    completes and validates as you type:

    {
      "$schema": "./node_modules/poops/schema/poops.schema.json"
    }

    The same file is published at
    https://stamat.info/poops/poops.schema.json
    for a project that has not installed Poops yet, and can be attached by
    filename from VS Code's json.schemas setting instead of by editing the
    config. The $schema key is inert to Poops — it is recognised and otherwise
    ignored, and nothing was added to what Poops installs into your project.

    The schema is hand-written, so Poops' own test suite holds it to the code: it
    is validated against the draft-07 meta-schema, and poops.json plus every
    complete config example in the README and on the documentation site is
    validated against it, so an example that stops being valid config now fails
    the build.

  • A key misspelt inside a block is now named too. The unknown-key warning
    stopped at the top level: "stlyes" was caught, "inn" in a styles entry was
    not. That entry compiled nothing, poops -b exited 0, and the file that never
    appeared was the only sign. Poops now checks every block it owns against the
    schema it ships, at startup:

    [info][warn] Unknown key "inn" in styles[0] — ignored. Valid: in, out, options
    

    Key names only, and only where Poops owns them: images belongs to
    poops-images, site is yours to name, and a companion's top-level block is
    left alone — an unrecognised key in any of those passes without comment.
    Types are not checked, so "minify": "yes" still reaches the compiler and
    fails there. exec keeps its own warning, which says the more useful thing —
    that the stage never runs. The schema read is the copy inside
    node_modules/poops, so where $schema points changes nothing.

    The walk is unknown-keys, a new
    dependency and the only one this release adds — zero dependencies of its own,
    written for this and published separately because poops-images and septic read
    the same file and want the same warning.

Changed

  • A top-level key naming a package you depend on is no longer called
    unknown.
    poops.json is shared — septic
    reads a septic block out of the same file — but every build printed
    Unknown config key "septic" — ignored, a warning about something working
    exactly as designed. The name is now checked against your dependencies,
    devDependencies, peerDependencies and optionalDependencies first, and a
    match passes in silence. Declaring the package is enough; Poops still never
    loads it or reads its block. Nothing by that name declared, and the warning
    is unchanged — which is what still catches "stlyes".

    Your editor cannot see node_modules, so the schema cannot make that call: it
    allows an object under any name it does not know and rejects everything else,
    meaning "stlyes": [ … ] is flagged there but "srve": { … } is not. The CLI
    catches what the editor lets through.

Fixed

  • llms alone now generates its files. Page entries were collected only
    when searchIndex, sitemap, nav or feed was configured, so a markup
    config whose only index feature was llms compiled the site and wrote
    nothing — no warning, no output. Pairing it with a sitemap was the
    workaround; there is nothing to pair it with now.

  • llms-full.txt no longer carries a page's machinery. The corpus is built
    from the Markdown source, which is read before the template engine runs, so it
    held whatever the source held: template comments, tags and output expressions,
    and every inline <style> and <script> on the page. A page that wraps its
    body in a {% set body %}…{% endset %} capture and styles itself in a
    <style> block could hand an LLM more plumbing than prose. All of it is
    stripped now, and the prose a capture wrapped stays. Fenced blocks, inline
    code spans and {% raw %} bodies are left alone — a sample documenting
    template syntax is content. A feed's article HTML is built from the same
    stripped source.

What's Changed

  • Bump the actions group across 1 directory with 4 updates by @dependabot[bot] in #52

Full Changelog: v2.0.0...v2.1.0

v2.0.0

Choose a tag to compare

@stamat stamat released this 31 Jul 19:43

Poops 2.0 has no new features. It is the release where the core loop — watch,
build, reload — stops carrying old dependencies and old spellings, and where the
compat shims that survived the whole 1.x line finally go.

Changed

  • Poops now requires Node.js 22 or newer. Node 20 reached end of life in
    April 2026, and the dependencies the dev loop is built on — the file watcher,
    esbuild — assume a modern runtime anyway. CI tests on Node 22 and 24, on both
    Ubuntu and Windows.

  • Live reload is now served by Poops itself, on the server port. The
    livereload package is gone, and with it a second port, an old websocket
    stack, and the snippet you had to paste into your templates.

    "livereload": true alongside "serve" is all it takes. Poops answers
    /__poops_reload as a server-sent events stream and appends a small client
    script to each HTML page it serves — appends it to the response, so nothing
    lands in your build output. Behaviour is unchanged otherwise: one reload per
    save once the build settles, CSS-only builds swap stylesheets in place
    instead of reloading, and the browser reconnects by itself after a restart.

    Migrating: delete the livereload.js snippet from your templates (a leftover
    one is harmless — it will 404 quietly), and drop livereload.port,
    livereload.exclude, livereload.extraExts and livereload.exts from your
    config. The last three had already stopped doing anything when the reload
    server stopped watching files in 1.5.1.

  • The file watcher moved from chokidar 3 to chokidar 5. Two majors of
    watcher fixes, and one fewer legacy dependency tree under node_modules.
    Nothing in your config changes: watch has always been a list of directories,
    never globs, so chokidar 4 dropping glob support costs nothing here. Watch
    mode behaves as before — one rebuild per save, CSS still hot-swaps, deletions
    still remove their output.

  • esbuild moved from 0.25 to 0.28, and the default target from es2019 to
    es2020.
    esbuild is pre-1.0, so its minor bumps shift output by design;
    absorbing that is what a major of ours is for. Expect small differences in
    your bundles — the CommonJS interop helper is more careful around a throwing
    module, and Symbol.for calls are annotated as side-effect free, which makes
    minified output slightly smaller.

    The new default target is the visible half: optional chaining (?.) and
    nullish coalescing (??) are ES2020, and compiling them down for browsers
    that have shipped them since 2020 only made bundles bigger. Entries that set
    their own target are untouched.

  • timeDateFormat is now dateFormat. It sets the default format for the
    date filter; the old name said the same thing twice. Deprecated and still
    read through 2.x, gone in 3.0.

    Engines see the rename too: registerFilters({ timeDateFormat, markupOut })
    is now registerFilters({ dateFormat, markupOut }). This is a hard rename —
    it lands before the engine interface becomes public API in this same release,
    so a custom engine must update its parameter name for 2.0.

  • output is now out in the markup sub-features. llms, nav, feed,
    searchIndex, sitemap and robots name their output file with out, the
    same word every entry in the config already uses:

    { "nav": { "out": "nav.json", "root": "docs" } }

    output is deprecated but still honoured through 2.x, warning once per
    feature, and stops working in 3.0. The string shorthand ("nav": "nav.json")
    is unaffected.

  • Markup settings belong under markup.options. markup now has the shape
    every other entry has — in, out, and everything else in options:

    {
      "markup": {
        "in": "src/markup",
        "out": "dist",
        "options": {
          "engine": "nunjucks",
          "site": { "title": "My Site" },
          "searchIndex": "search-index.json"
        }
      }
    }

    1.x read engine, site, data, includePaths, timeDateFormat,
    collections, searchIndex, sitemap, llms, robots, feed, nav,
    baseURL and autoescape at the markup. level as well. That placement is
    deprecated but still honoured through 2.x — each stray key logs a warning
    naming its new home — and stops working in 3.0. Where both are set, options
    wins.

Added

  • The markup engine interface is public API as of 2.0. markup.options.engine
    has always accepted any importable module, and
    poops-shopify ships a production
    engine against it — but nothing said the interface was stable, so a rename in
    a patch release could have broken it silently. It is documented in
    the engine API reference, semver applies to it from here on,
    and a contract test asserts both builtin engines still expose the shape — a
    failing test is a breaking change caught before it ships.

  • serve.base defaults to the markup out directory. Nearly every config
    set it to the path it had just built into. Set it only when the server should
    serve somewhere else; an explicit value still wins, and a project with no
    markup still serves the working directory.

Removed

  • The ssg config key. It has been an alias for reactor since the rename,
    and it was the last compatibility shim in the codebase. A config still using
    it now gets told what to call it instead:

    [info] Config key "ssg" is renamed to "reactor" in 2.0 — ignored.
    

Internal

  • Dependency bumps. book-of-spells 1.3 → 1.4 and a postcss patch, both
    routine. Dev-only: neostandard 0.12 → 0.13, and sulphuris 2 → 4 for the
    example site's styles. ESLint stays on 9 — neostandard 0.13 still declares a
    peer of eslint@^9, so 10 has to wait for it.

Fixed

  • "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 the link for a post came out as
    …/edit/main/src/posts\hello.md. Both are posix now.

  • The image cache is read correctly across platforms. Its lookups all key on
    posix paths, so a cache written on Windows — committed next to the images it
    describes, or built in CI — matched nothing and galleries came up empty. Keys
    and variant paths are normalized on read. The output-directory containment
    check also no longer accepts a sibling directory whose name merely starts with
    the output dir's (dist-old for dist).

  • 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 — no stylesheet matched, so every style edit reloaded the whole
    page instead of swapping the CSS. Both spellings are now reported (and with
    justMinified, only the minified one, since the other is deleted).

Full Changelog: v1.9.8...v2.0.0

v1.9.8

Choose a tag to compare

@stamat stamat released this 31 Jul 19:43

A fence could say more than its language, but only the language survived to the HTML — anything marking a fence for a later stage had to be an HTML comment next to it in the Markdown. The rest of the info string now lands on the code element as classes and data attributes, and all six places that render a code block finally agree on what one looks like.

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.

    ```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>

    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 =:

    ```html preview expanded=
    <my-element></my-element>
    ```
    <pre><code class="hljs language-html preview" data-expanded=""></code></pre>

    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
    {% highlight %} 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.

Full Changelog: v1.9.7...v1.9.8

v1.9.7

Choose a tag to compare

@stamat stamat released this 29 Jul 15:27

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:

    {% 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:

    {% raw %}"jsonld": {
      "@type": "TechArticle",
      "license": "https://opensource.org/licenses/MIT",
      "isAccessibleForFree": true
    }{% endraw %}

    Precedence is defaults → site.jsonldpage.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:

    ---
    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.

Full Changelog: v1.9.6...v1.9.7

v1.9.6

Choose a tag to compare

@stamat stamat released this 29 Jul 14:56

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:

    {% 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:

    {% 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:

    {% 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.

Full Changelog: v1.9.5...v1.9.6