npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@warlock.js/sitemap

v5.23.2

Published

A framework-blind sitemap builder: entries in, a valid sitemaps.org document out. Zero runtime dependencies.

Readme

@warlock.js/sitemap

A sitemap builder that knows nothing about any framework. Entries in, a valid sitemaps.org document out.

Zero runtime dependencies. Generation only — emitting XML needs no dependency; parsing one does, and nothing here needs it yet.

Install

npm install @warlock.js/sitemap

Use it

import { Sitemap } from "@warlock.js/sitemap";

const sitemap = new Sitemap({
  baseUrl: "https://example.com",
  changefreq: "weekly",
  priority: 0.5,
});

sitemap.add({ path: "/" });

for (const post of await Post.all()) {
  sitemap.add({
    name: "post-details",
    route: "/posts/:id",
    path: `/posts/${post.slug}`,
    lastmod: post.updatedAt,
    priority: 0.8,
  });
}

const xml = sitemap.toXML();

await sitemap.saveTo("public/sitemap.xml");

That is the whole common case. It is the same three lines from an Express handler, a cron script, a Warlock app, or a node build-sitemap.mjs you wrote in five minutes.

The API

new Sitemap(options)

| Option | Meaning | | --- | --- | | baseUrl | Required. Absolute origin, e.g. https://example.com or https://example.com/docs. | | changefreq | Applied to any entry that does not set its own. | | priority | Same. | | lastmod | Same. |

baseUrl is validated in the constructor. A Sitemap that cannot produce a valid URL should not exist, and you should learn about a typo at the line that wrote it rather than at the first request. Anything that is not an absolute http(s) URL throws InvalidBaseUrlError.

add(entry) / addMany(entries)

Only path is required, and it must be a concrete path — /posts/123, not /posts/:id. A pattern is not a URL.

| Field | Meaning | | --- | --- | | path | Required. Resolved against baseUrl. An absolute URL is used as given. | | name | Optional label for the route, for your own diagnostics. | | route | Optional pattern this URL came from, e.g. /posts/:id. | | lastmod | Date (serialised as W3C datetime) or a string (passed through untouched). | | changefreq | One of the seven protocol values. | | priority | 0.0–1.0. Anything else throws. | | alternates | Language versions of this page — see below. |

Both return this, so they chain.

Adding the same path twice keeps the later entry, silently. Entries are stored keyed by path, because a duplicate <loc> makes the document invalid and two loops legitimately covering an overlapping set is the normal cause. It is silent, not hidden — see duplicates().

entries() / size

What will actually be emitted, with defaults already folded in. entries() hands back a copy.

routes() — the diagnostic

sitemap.routes();
// [ { route: "/posts/:id", count: 400 }, { route: "/products/:slug", count: 0 } ]

The interesting row is the zero. It means a route you expected to contribute URLs contributed none, and a whole section of your site is missing from a document that otherwise looks perfect. Use declareRoute() to name a pattern you expect to produce URLs:

sitemap.declareRoute("/products/:slug");

const empty = sitemap.routes().filter((route) => route.count === 0);

if (empty.length > 0) {
  throw new Error(`empty sitemap routes: ${empty.map((route) => route.route).join(", ")}`);
}

This package reports; it never prints. Whether a zero is a warning or a build failure is your decision, not ours.

duplicates()

sitemap.duplicates();
// [ { path: "/posts/1", count: 2, routes: ["/posts/:id", "/:slug"] } ]

Every path that was added more than once, and the route of each add — so a collision between two sources is nameable. Never throws.

toXML()

Pure and repeatable: calling it twice returns the same string, and it mutates nothing. It is synchronous and stays synchronous — there is no I/O in it.

Sitemap structurally satisfies @warlock.js/core's XMLable contract (toXML(): string), so a Warlock controller can return response.xml(sitemap) directly — this package still depends on nothing from core to make that true. That path is for a single Sitemap under the sitemaps.org 50,000-URL / 50MB ceiling. SitemapIndex has no toXML() and is never passed to response.xml(); see Very large sites below for how its output is served instead.

saveTo(filePath)

Writes the document, creating parent directories. Works on a clean checkout with no dist/.

publishTo(outDir, fileName = "sitemap.xml")

Publishes the document as the whole content of outDir, the same way SitemapIndex.saveTo(outDir) publishes a set: swapped in atomically and marked as owned (see below). Use it when the sitemap may later grow into an index: an index can then be published into the same directory, and publishing a single file again clears out old shards. Returns the published file's path.

Language alternates

alternates emits <xhtml:link rel="alternate" hreflang="…"> inside each <url> — the thing search engines actually consume to learn that two URLs are one page in two languages.

const alternates = [
  { hreflang: "en", path: "/en/about" },
  { hreflang: "ar", path: "/ar/about-us" },
  { hreflang: "x-default", path: "/en/about" },
];

sitemap.add({ path: "/en/about", alternates });
sitemap.add({ path: "/ar/about-us", alternates });

Two rules worth knowing:

  1. Every language version is also its own <url>, carrying the complete alternate set including itself. Listing alternates on only one of them is the usual way this gets shipped broken.
  2. This package does not know what a locale is. hreflang is any string — en, en-GB, x-default — and it never derives /{locale}/… for you. You supply each path explicitly, which is the only thing that works when slugs diverge between languages.

The xhtml namespace is declared only when something actually uses it.

Very large sites

The protocol caps one file at 50,000 URLs or 50MB uncompressed. Sitemap retains every entry — that is what makes entries() and a repeatable toXML() possible — so it is the right tool up to that ceiling and the wrong one above it. SitemapIndex is the streaming writer for sites past it: it emits shards plus a flat master index and retains nothing but the current shard's buffer.

alternates works the same way across a shard boundary — each <xhtml:link> resolves to an absolute URL, so it is invisible to where one shard ends and the next begins. Each shard declares xmlns:xhtml only when its own entries carry alternates, and an alternate's bytes count toward that shard's byte ceiling exactly like the rest of its <url> block.

maxBytesPerFile is enforced at shard boundaries, not within an entry: a single entry (with its alternates) larger than the ceiling is written alone in its own shard rather than split, since an entry can't be split.

saveTo(outDir) owns the directory it is given

SitemapIndex.saveTo(outDir) publishes atomically: it writes the full set into a sibling temp directory, then swaps it into outDir in one rename — a crawler never sees a partial set, and a failed run leaves the previous set untouched.

That swap replaces the ENTIRE contents of outDir, so outDir must be a directory dedicated to this sitemap set — never an app's public/ or any other directory something else writes to. Every successful publish marks outDir with .sitemap-set.json; a later saveTo() call only swaps a directory that either does not exist yet, is empty, or already carries that marker. If outDir exists, is non-empty, and has no marker, saveTo() throws UnownedOutputDirectoryError and leaves the directory completely untouched. Point saveTo() at a directory this package alone writes to. Sitemap.publishTo(outDir) follows the same rule.

Errors

| Error | When | | --- | --- | | InvalidBaseUrlError | baseUrl is missing, relative, or not http(s). Thrown from the constructor. | | InvalidSitemapEntryError | An entry the protocol cannot represent: no path, a priority outside 0.0–1.0, an unknown changefreq, an invalid Date, an alternate with no hreflang. | | UnownedOutputDirectoryError | SitemapIndex.saveTo(outDir) or Sitemap.publishTo(outDir): outDir is a non-empty directory with no .sitemap-set.json marker from a previous publish, so it is refused rather than swapped or deleted. |

Also exported

buildSitemapXml(entries, baseUrl), escapeXml(value) and joinOrigin(origin, path) — the pieces Sitemap is built from, for when you want the serialiser without the builder.