unify docs

Two small generators: cache-busting URLs and an RSS feed

Role: Two recipes for the --generate seam, each the answer to a thing people ask the core to do — put a content hash in an asset's URL, and publish a feed in a format other than Atom. unify does neither, and does not need to: a generator you own does each in a screenful, and whatever it writes is composed, rewritten and checked like a file you typed. Read the --generate section of the CLI reference first. Every literal below is tested (tests/conformance/generator-recipes.test.js); both assume the layout unify init scaffolds: content in site/, the generator in scripts/ beside it, and generate: scripts/<name>.mjs in unify.yaml.

Cache-busting URLs

A browser that cached style.css last week keeps using it after you change the file, because the address did not change. The cure is a version in the address, and the generator is the place to compute it: it runs on every build, it can read the files, and a fragment it writes reaches every page through the layout.

scripts/cache-bust.mjs:

import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";

const [, , sourceRoot, overlay] = process.argv;
const v = (path) =>
  createHash("sha256").update(readFileSync(join(sourceRoot, path))).digest("hex").slice(0, 8);

mkdirSync(join(overlay, "_includes"), { recursive: true });
writeFileSync(join(overlay, "_includes/assets.html"),
  `<link rel="stylesheet" href="/assets/style.css?v=${v("assets/style.css")}">
<script src="/assets/app.js?v=${v("assets/app.js")}" defer></script>
`);

Keep the line for each file you have. The layout asks for the fragment in its <head>; being under _includes/, it is never published:

<include src="/_includes/assets.html"></include>

With an assets/style.css holding body{color:#333} and an assets/app.js holding console.log("hello");, a build under --base-url https://example.com/sub/ puts this in every page:

<link rel="stylesheet" href="/sub/assets/style.css?v=0039f158">
<script src="/sub/assets/app.js?v=f9444510" defer></script>

An RSS feed

unify's own feed.xml is Atom, and it leaves out a post whose date is a day alone (advisory A17) rather than invent a time of day: the built-in feed section of the CLI reference and conformance spec §29 give the reasons. If you want RSS, or a feed at an endpoint that already exists, or day-dated posts in it, write the feed yourself. The generator reads every page's authored title, description and date from the source inventory (context.inputs.sourcePages, which is on whenever a generator is named; the blog template's generator reads it the same way), and each decision unify declined to make is a line you can read.

scripts/rss.mjs:

import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";

const [, , , overlay, contextPath] = process.argv;
const context = JSON.parse(readFileSync(contextPath, "utf8"));
const { baseUrl, prettyUrls } = context.site;
if (!baseUrl) throw new Error("rss.mjs: an RSS link must be absolute, so build with --base-url");
const { pages } = JSON.parse(readFileSync(context.inputs.sourcePages, "utf8"));

const SITE = { title: "My Notes", description: "Notes from the workshop." }; // yours to word
const OUT = "rss.xml"; // or an endpoint you already have, "news/rss.xml"; "feed.xml" replaces unify's Atom feed

const esc = (s) =>
  String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");

// Nothing rewrites a link inside this file, so apply --pretty-urls and --base-url here.
const address = (href) => {
  const dir = href.replace(/(^|\/)index\.html$/, "$1"); // an index page answers at its directory
  return new URL((prettyUrls ? dir.replace(/\.html$/, "/") : dir).slice(1), baseUrl).href;
};

// Dates are your decision. A full timestamp becomes the same instant in RFC 822;
// a bare day names no instant, so that item gets no <pubDate>. Never the clock.
const rfc822 = (d) =>
  /T.*(Z|[+-]\d\d:\d\d)$/.test(d) && !Number.isNaN(Date.parse(d)) ? new Date(d).toUTCString() : null;

const item = (p) => {
  const url = esc(address(p.href)), when = rfc822(p.date);
  return [
    "    <item>",
    `      <title>${esc(p.title)}</title>`,
    `      <link>${url}</link>`,
    `      <guid>${url}</guid>`,
    p.description && `      <description>${esc(p.description)}</description>`,
    when && `      <pubDate>${when}</pubDate>`,
    "    </item>",
  ].filter(Boolean).join("\n");
};

const items = pages
  .filter((p) => p.source.startsWith("posts/") && p.title && p.date) // yours: which pages are posts
  .sort((a, b) => b.date.localeCompare(a.date) || a.source.localeCompare(b.source))
  .map(item);

mkdirSync(dirname(join(overlay, OUT)), { recursive: true }); // the overlay starts empty
writeFileSync(join(overlay, OUT), `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>${esc(SITE.title)}</title>
    <link>${esc(baseUrl)}</link>
    <description>${esc(SITE.description)}</description>
${items.join("\n")}
  </channel>
</rss>
`);

Point readers at it from the layout's <head>:

<link rel="alternate" type="application/rss+xml" title="My Notes" href="/rss.xml">

With posts/one.md titled Fish & Chips <tasty> and dated 2026-03-04, posts/two.md titled Two Weeks Of Rain and dated 2026-03-05T21:30:00+02:00, built with --base-url https://example.com/sub/ --pretty-urls, the file is:

<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>My Notes</title>
    <link>https://example.com/sub/</link>
    <description>Notes from the workshop.</description>
    <item>
      <title>Two Weeks Of Rain</title>
      <link>https://example.com/sub/posts/two/</link>
      <guid>https://example.com/sub/posts/two/</guid>
      <description>What the gauge said.</description>
      <pubDate>Thu, 05 Mar 2026 19:30:00 GMT</pubDate>
    </item>
    <item>
      <title>Fish &amp; Chips &lt;tasty&gt;</title>
      <link>https://example.com/sub/posts/one/</link>
      <guid>https://example.com/sub/posts/one/</guid>
      <description>Friday supper, reviewed.</description>
    </item>
  </channel>
</rss>