@warlock.js/sitemap 5.15.0 → 5.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +179 -137
  3. package/cjs/index.cjs +677 -201
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/atomic-publish.mjs +85 -0
  6. package/esm/atomic-publish.mjs.map +1 -0
  7. package/esm/atomic-write-file.mjs +50 -0
  8. package/esm/atomic-write-file.mjs.map +1 -0
  9. package/esm/duplicate-path-tracker.mjs +36 -0
  10. package/esm/duplicate-path-tracker.mjs.map +1 -0
  11. package/esm/errors.d.mts +39 -0
  12. package/esm/errors.mjs +52 -0
  13. package/esm/errors.mjs.map +1 -0
  14. package/esm/index.d.mts +8 -8
  15. package/esm/index.mjs +6 -6
  16. package/esm/lastmod.mjs +23 -0
  17. package/esm/lastmod.mjs.map +1 -0
  18. package/esm/normalize-entry.mjs +59 -0
  19. package/esm/normalize-entry.mjs.map +1 -0
  20. package/esm/route-counter.mjs +19 -0
  21. package/esm/route-counter.mjs.map +1 -0
  22. package/esm/shard-name.mjs +28 -0
  23. package/esm/shard-name.mjs.map +1 -0
  24. package/esm/sitemap-index-options.mjs +31 -0
  25. package/esm/sitemap-index-options.mjs.map +1 -0
  26. package/esm/sitemap-index-types.d.mts +39 -0
  27. package/esm/sitemap-index-xml.mjs +20 -0
  28. package/esm/sitemap-index-xml.mjs.map +1 -0
  29. package/esm/sitemap-index.d.mts +31 -0
  30. package/esm/sitemap-index.mjs +96 -0
  31. package/esm/sitemap-index.mjs.map +1 -0
  32. package/esm/sitemap-shard-writer.mjs +89 -0
  33. package/esm/sitemap-shard-writer.mjs.map +1 -0
  34. package/esm/sitemap.d.mts +63 -0
  35. package/esm/sitemap.mjs +129 -0
  36. package/esm/sitemap.mjs.map +1 -0
  37. package/esm/types.d.mts +51 -20
  38. package/esm/url.d.mts +1 -20
  39. package/esm/url.mjs +26 -19
  40. package/esm/url.mjs.map +1 -1
  41. package/esm/xml.d.mts +15 -4
  42. package/esm/xml.mjs +30 -8
  43. package/esm/xml.mjs.map +1 -1
  44. package/llms-full.txt +139 -159
  45. package/llms.txt +2 -2
  46. package/package.json +2 -14
  47. package/skills/sitemap-overview/SKILL.md +139 -159
  48. package/esm/collect-entries.d.mts +0 -44
  49. package/esm/collect-entries.mjs +0 -73
  50. package/esm/collect-entries.mjs.map +0 -1
  51. package/esm/diagnostic.d.mts +0 -13
  52. package/esm/diagnostic.mjs +0 -19
  53. package/esm/diagnostic.mjs.map +0 -1
  54. package/esm/routable-page.d.mts +0 -27
  55. package/esm/sitemap-connector.d.mts +0 -60
  56. package/esm/sitemap-connector.mjs +0 -117
  57. package/esm/sitemap-connector.mjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to `@warlock.js/sitemap` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 5.16.0 - 2026-09-18
8
+
9
+ ### Upgrading
10
+
11
+ - The 5.15 connector API is gone. In a Warlock app, remove `sitemapConnector()` from `warlock.config.ts` and `src/config/sitemap.ts`, then configure `web.sitemap` in `src/config/web.ts` (`warlock add sitemap` writes this section). Outside Warlock, build the sitemap with `new Sitemap({ baseUrl })`.
12
+
13
+ ### Added
14
+
15
+ - `SitemapIndex`: streams entries into size-capped shards plus a master index, and optionally writes `.xml.gz` files. It publishes the whole output directory atomically and marks it with `.sitemap-set.json`.
16
+ - `Sitemap.publishTo(outDir, fileName?)`, and `saveTo()` now writes atomically (temp file, then rename, with retries on Windows `EPERM`/`EBUSY`).
17
+ - `UnownedOutputDirectoryError`: publishing refuses to replace a non-empty directory that lacks the ownership marker.
18
+
19
+ ### Changed
20
+
21
+ - **BREAKING:** the package no longer depends on any framework. The connector API is removed: `sitemapConnector()`, `collectSitemapEntries()`, the `SitemapConfig` config module, `MissingPublicUrlError`, `NoPageRegistryError` and `RoutablePage`. Use the `Sitemap` builder class instead (`new Sitemap({ baseUrl })`, `add` / `addMany` / `declareRoute`, `toXML` / `saveTo`). Warlock apps configure `web.sitemap` in `@warlock.js/web` instead.
22
+ - `baseUrl` is validated in the constructor (`InvalidBaseUrlError`). Entries are keyed by path, and `duplicates()` reports every collision.
23
+ - Dropped the `@warlock.js/core` and `@warlock.js/web` dependencies.
24
+
7
25
  ## 5.15.0 - 2026-09-18
8
26
 
9
27
  ### Added
package/README.md CHANGED
@@ -1,14 +1,10 @@
1
1
  # @warlock.js/sitemap
2
2
 
3
- A `sitemap.xml` generator: exclusion rules (`noindex`, `sitemap: false`),
4
- per-entry `changefreq`/`priority` defaults, and a loud diagnostic when a
5
- dynamic route can't be enumerated. `@warlock.js/core` and `@warlock.js/web`
6
- are **optional peers** — everything except `sitemapConnector()` imports
7
- nothing from either, and `sitemapConnector()` itself only reaches them
8
- through a lazy `import()` at boot. Three ways to use it, below.
3
+ A sitemap builder that knows nothing about any framework. Entries in, a valid
4
+ [sitemaps.org](https://www.sitemaps.org/protocol.html) document out.
9
5
 
10
- Generation only in this release. No remote sitemap parser — emitting XML
11
- needs no dependency; parsing one does, and nothing needs it yet.
6
+ **Zero runtime dependencies.** Generation only — emitting XML needs no
7
+ dependency; parsing one does, and nothing here needs it yet.
12
8
 
13
9
  ## Install
14
10
 
@@ -16,170 +12,216 @@ needs no dependency; parsing one does, and nothing needs it yet.
16
12
  npm install @warlock.js/sitemap
17
13
  ```
18
14
 
19
- ## Mode 1 — Standalone, any Node app
20
-
21
- No Warlock at all. Build the `RoutablePage[]` array yourself — from a route
22
- table, a database, wherever your app already knows its own URLs — and call
23
- `collectSitemapEntries` + `buildSitemapXml` directly. Nothing on this path
24
- resolves `@warlock.js/core` or `@warlock.js/web`.
15
+ ## Use it
25
16
 
26
17
  ```ts
27
- import express from "express";
28
- import { buildSitemapXml, collectSitemapEntries, type RoutablePage } from "@warlock.js/sitemap";
29
-
30
- const app = express();
31
-
32
- app.get("/sitemap.xml", async (_req, res) => {
33
- const pages: RoutablePage[] = [
34
- { routeName: "home", routePath: "/" },
35
- { routeName: "about", routePath: "/about" },
36
- {
37
- routeName: "post-details",
38
- routePath: "/posts/:id",
39
- sitemap: async () => (await db.posts.find()).map((post) => ({ path: `/posts/${post.slug}` })),
40
- },
41
- ];
42
-
43
- const { entries } = await collectSitemapEntries(pages, {
44
- defaults: { changefreq: "weekly", priority: 0.5 },
18
+ import { Sitemap } from "@warlock.js/sitemap";
19
+
20
+ const sitemap = new Sitemap({
21
+ baseUrl: "https://example.com",
22
+ changefreq: "weekly",
23
+ priority: 0.5,
24
+ });
25
+
26
+ sitemap.add({ path: "/" });
27
+
28
+ for (const post of await Post.all()) {
29
+ sitemap.add({
30
+ name: "post-details",
31
+ route: "/posts/:id",
32
+ path: `/posts/${post.slug}`,
33
+ lastmod: post.updatedAt,
34
+ priority: 0.8,
45
35
  });
36
+ }
46
37
 
47
- const xml = buildSitemapXml(entries, "https://example.com");
38
+ const xml = sitemap.toXML();
48
39
 
49
- res.type("application/xml").send(xml);
50
- });
40
+ await sitemap.saveTo("public/sitemap.xml");
51
41
  ```
52
42
 
53
- ## Mode 2 Warlock, API-only (no `@warlock.js/web`)
43
+ That is the whole common case. It is the same three lines from an Express
44
+ handler, a cron script, a Warlock app, or a `node build-sitemap.mjs` you wrote
45
+ in five minutes.
54
46
 
55
- An API-only Warlock app has no page registry — `listRoutablePages()` would
56
- have nothing to list. Pass `entries` and `sitemapConnector()` uses it instead;
57
- `@warlock.js/web` is not required on this path.
47
+ ## The API
58
48
 
59
- ```ts
60
- // warlock.config.ts
61
- import { sitemapConnector } from "@warlock.js/sitemap";
62
-
63
- export default defineConfig({
64
- connectors: [
65
- sitemapConnector({
66
- entries: async () => {
67
- const products = await db.products.find();
68
-
69
- return products.map((product) => ({ path: `/products/${product.slug}` }));
70
- },
71
- }),
72
- ],
73
- });
74
- ```
49
+ ### `new Sitemap(options)`
75
50
 
76
- If neither `entries` nor `@warlock.js/web` is available, the connector
77
- refuses to boot rather than serving an empty `<urlset>` — see
78
- [`NoPageRegistryError`](#no-page-registry-no-entries) below.
51
+ | Option | Meaning |
52
+ | --- | --- |
53
+ | `baseUrl` | **Required.** Absolute origin, e.g. `https://example.com` or `https://example.com/docs`. |
54
+ | `changefreq` | Applied to any entry that does not set its own. |
55
+ | `priority` | Same. |
56
+ | `lastmod` | Same. |
79
57
 
80
- ## Mode 3 Warlock web
58
+ `baseUrl` is validated **in the constructor**. A `Sitemap` that cannot produce
59
+ a valid URL should not exist, and you should learn about a typo at the line
60
+ that wrote it rather than at the first request. Anything that is not an
61
+ absolute `http(s)` URL throws `InvalidBaseUrlError`.
81
62
 
82
- ```bash
83
- warlock add sitemap
84
- ```
63
+ ### `add(entry)` / `addMany(entries)`
64
+
65
+ Only `path` is required, and it must be a **concrete path** — `/posts/123`, not
66
+ `/posts/:id`. A pattern is not a URL.
67
+
68
+ | Field | Meaning |
69
+ | --- | --- |
70
+ | `path` | **Required.** Resolved against `baseUrl`. An absolute URL is used as given. |
71
+ | `name` | Optional label for the route, for your own diagnostics. |
72
+ | `route` | Optional pattern this URL came from, e.g. `/posts/:id`. |
73
+ | `lastmod` | `Date` (serialised as W3C datetime) or a string (passed through untouched). |
74
+ | `changefreq` | One of the seven protocol values. |
75
+ | `priority` | `0.0`–`1.0`. Anything else throws. |
76
+ | `alternates` | Language versions of this page — see below. |
77
+
78
+ Both return `this`, so they chain.
79
+
80
+ **Adding the same path twice keeps the later entry, silently.** Entries are
81
+ stored keyed by path, because a duplicate `<loc>` makes the document invalid
82
+ and two loops legitimately covering an overlapping set is the normal cause. It
83
+ is silent, not hidden — see `duplicates()`.
85
84
 
86
- writes `src/config/sitemap.ts`:
85
+ ### `entries()` / `size`
86
+
87
+ What will actually be emitted, with defaults already folded in. `entries()`
88
+ hands back a copy.
89
+
90
+ ### `routes()` — the diagnostic
87
91
 
88
92
  ```ts
89
- export const sitemapConfig: SitemapConfig = {
90
- enabled: true,
91
- path: "/sitemap.xml",
92
- defaults: { changefreq: "weekly", priority: 0.5 },
93
- };
93
+ sitemap.routes();
94
+ // [ { route: "/posts/:id", count: 400 }, { route: "/products/:slug", count: 0 } ]
94
95
  ```
95
96
 
96
- `sitemapConnector()` reads the page registry from `@warlock.js/web`'s
97
- `listRoutablePages()` on every request not once at boot so it reflects
98
- the app's current shape under `warlock dev` too.
99
-
100
- Only a dynamic route needs a page-level `sitemap` export; a static route is
101
- included automatically at its own path.
97
+ **The interesting row is the zero.** It means a route you expected to
98
+ contribute URLs contributed none, and a whole section of your site is missing
99
+ from a document that otherwise looks perfect. Use `declareRoute()` to name a
100
+ pattern you expect to produce URLs:
102
101
 
103
102
  ```ts
104
- // any *.page.tsx
105
- export const sitemap: SitemapEntries = async () => [
106
- { path: "/posts/hello-world", lastmod: "2026-09-17", priority: 0.8 },
107
- ];
103
+ sitemap.declareRoute("/products/:slug");
104
+
105
+ const empty = sitemap.routes().filter((route) => route.count === 0);
108
106
 
109
- // or, to keep a page out of the sitemap deliberately
110
- export const sitemap = false;
107
+ if (empty.length > 0) {
108
+ throw new Error(`empty sitemap routes: ${empty.map((route) => route.route).join(", ")}`);
109
+ }
111
110
  ```
112
111
 
113
- `changefreq` and `priority` are per-entry and optional they fall back to
114
- the config's `defaults`. They are not part of `PageMetadata`; they mean
115
- nothing outside a sitemap.
112
+ **This package reports; it never prints.** Whether a zero is a warning or a
113
+ build failure is your decision, not ours.
116
114
 
117
- **A dynamic route with no `sitemap` export is silently omitted from the
118
- generated XML if nobody is watching.** In development this package reports it
119
- instead: a dynamic route cannot be enumerated without application data, and
120
- the framework's whole job here is to make sure you find out, rather than
121
- shipping a sitemap that looks complete while it quietly drops every product
122
- page on the site.
115
+ ### `duplicates()`
123
116
 
124
- If a project has both a page registry **and** `entries`, they are combined —
125
- `entries` are added to the page-derived entries, not a replacement for them.
126
- Where the same `path` appears in both, the `entries` version wins.
117
+ ```ts
118
+ sitemap.duplicates();
119
+ // [ { path: "/posts/1", count: 2, routes: ["/posts/:id", "/:slug"] } ]
120
+ ```
127
121
 
128
- ## Config reference (`src/config/sitemap.ts`)
122
+ Every path that was added more than once, and the `route` of each add — so a
123
+ collision between two sources is nameable. Never throws.
129
124
 
130
- | key | meaning |
131
- | --- | --- |
132
- | `enabled` | no-op when not `true` — the connector registers no route |
133
- | `path` | defaults to `/sitemap.xml` |
134
- | `defaults.changefreq` / `defaults.priority` | applied to any entry that omits them, page-derived or app-supplied |
125
+ ### `toXML()`
135
126
 
136
- The public origin the sitemap is served from is **not** configured here — it
137
- lives in `app.publicUrl` (or the `PUBLIC_APP_URL` environment variable), one
138
- level up in `@warlock.js/core`, because canonical links, OG tags and absolute
139
- mail URLs need the same value. If the sitemap is `enabled` and no origin is
140
- configured, the app refuses to boot rather than guess — a sitemap served with
141
- the wrong host is worse than one that never started.
127
+ Pure and repeatable: calling it twice returns the same string, and it mutates
128
+ nothing. It is **synchronous and stays synchronous** there is no I/O in it.
142
129
 
143
- ## What goes in the sitemap
130
+ `Sitemap` structurally satisfies `@warlock.js/core`'s `XMLable` contract
131
+ (`toXML(): string`), so a Warlock controller can `return response.xml(sitemap)`
132
+ directly — this package still depends on nothing from core to make that true.
133
+ That path is for a single `Sitemap` under the sitemaps.org 50,000-URL / 50MB
134
+ ceiling. `SitemapIndex` has no `toXML()` and is never passed to `response.xml()`;
135
+ see [Very large sites](#very-large-sites) below for how its output is served instead.
144
136
 
145
- | case | behaviour |
146
- | --- | --- |
147
- | static route | included |
148
- | not-found route | excluded |
149
- | error page | excluded — it isn't a routable page at all |
150
- | page whose `metadata.robots` says `noindex` | excluded |
151
- | page exporting `sitemap: false` | excluded |
152
- | dynamic route (`[id]`, `[...slug]`) **with** a `sitemap` export | the entries that export returns |
153
- | dynamic route **without** a `sitemap` export | **omitted, and named in a dev-mode diagnostic** |
154
- | `sitemapConnector({ entries })` result | added to the above, `entries` wins on a `path` collision |
155
-
156
- ## No page registry, no `entries`
157
-
158
- If `@warlock.js/web` is not installed and no `entries` option is supplied,
159
- `sitemapConnector()` throws `NoPageRegistryError` at `boot()` — before the
160
- route is even registered — rather than serving an empty sitemap that looks
161
- correct. Fix it either way:
137
+ ### `saveTo(filePath)`
162
138
 
163
- ```
164
- Sitemap is enabled but has no source of entries: `@warlock.js/web` is not installed,
165
- so there is no page registry to read, and no `entries` option was supplied either.
166
- Fix this by installing `@warlock.js/web`, or by passing
167
- `sitemapConnector({ entries: async () => [...] })` with your own supplier.
168
- ```
139
+ Writes the document, creating parent directories. Works on a clean checkout
140
+ with no `dist/`.
169
141
 
170
- ## Full documentation
142
+ ### `publishTo(outDir, fileName = "sitemap.xml")`
171
143
 
172
- The complete guide lives at
173
- **[warlock.js.org](https://warlock.js.org/v/latest/sitemap/)**.
144
+ Publishes the document as the whole content of `outDir`, the same way
145
+ `SitemapIndex.saveTo(outDir)` publishes a set: swapped in atomically and marked
146
+ as owned (see below). Use it when the sitemap may later grow into an index: an
147
+ index can then be published into the same directory, and publishing a single
148
+ file again clears out old shards. Returns the published file's path.
174
149
 
175
- ## Tests
150
+ ## Language alternates
176
151
 
177
- This package uses Vitest:
152
+ `alternates` emits `<xhtml:link rel="alternate" hreflang="…">` inside each
153
+ `<url>` — the thing search engines actually consume to learn that two URLs are
154
+ one page in two languages.
178
155
 
179
- ```bash
180
- yarn test
156
+ ```ts
157
+ const alternates = [
158
+ { hreflang: "en", path: "/en/about" },
159
+ { hreflang: "ar", path: "/ar/about-us" },
160
+ { hreflang: "x-default", path: "/en/about" },
161
+ ];
162
+
163
+ sitemap.add({ path: "/en/about", alternates });
164
+ sitemap.add({ path: "/ar/about-us", alternates });
181
165
  ```
182
166
 
183
- ## License
167
+ Two rules worth knowing:
168
+
169
+ 1. **Every language version is also its own `<url>`, carrying the complete
170
+ alternate set including itself.** Listing alternates on only one of them is
171
+ the usual way this gets shipped broken.
172
+ 2. **This package does not know what a locale is.** `hreflang` is any string —
173
+ `en`, `en-GB`, `x-default` — and it never derives `/{locale}/…` for you. You
174
+ supply each path explicitly, which is the only thing that works when slugs
175
+ diverge between languages.
176
+
177
+ The `xhtml` namespace is declared only when something actually uses it.
178
+
179
+ ## Very large sites
180
+
181
+ The protocol caps one file at **50,000 URLs or 50MB uncompressed**. `Sitemap`
182
+ retains every entry — that is what makes `entries()` and a repeatable
183
+ `toXML()` possible — so it is the right tool up to that ceiling and the wrong
184
+ one above it. `SitemapIndex` is the streaming writer for sites past it: it
185
+ emits shards plus a flat master index and retains nothing but the current
186
+ shard's buffer.
187
+
188
+ `alternates` works the same way across a shard boundary — each `<xhtml:link>`
189
+ resolves to an absolute URL, so it is invisible to where one shard ends and
190
+ the next begins. Each shard declares `xmlns:xhtml` only when its own entries
191
+ carry alternates, and an alternate's bytes count toward that shard's byte
192
+ ceiling exactly like the rest of its `<url>` block.
193
+
194
+ `maxBytesPerFile` is enforced at shard boundaries, not within an entry: a
195
+ single entry (with its alternates) larger than the ceiling is written alone
196
+ in its own shard rather than split, since an entry can't be split.
197
+
198
+ ### `saveTo(outDir)` owns the directory it is given
199
+
200
+ `SitemapIndex.saveTo(outDir)` publishes atomically: it writes the full set
201
+ into a sibling temp directory, then swaps it into `outDir` in one `rename` —
202
+ a crawler never sees a partial set, and a failed run leaves the previous set
203
+ untouched.
204
+
205
+ That swap replaces the ENTIRE contents of `outDir`, so `outDir` must be a
206
+ directory **dedicated to this sitemap set** — never an app's `public/` or
207
+ any other directory something else writes to. Every successful publish marks
208
+ `outDir` with `.sitemap-set.json`; a later `saveTo()` call only swaps a
209
+ directory that either does not exist yet, is empty, or already carries that
210
+ marker. If `outDir` exists, is non-empty, and has no marker,
211
+ `saveTo()` throws `UnownedOutputDirectoryError` and leaves the directory
212
+ completely untouched. Point `saveTo()` at a directory this package alone
213
+ writes to. `Sitemap.publishTo(outDir)` follows the same rule.
214
+
215
+ ## Errors
216
+
217
+ | Error | When |
218
+ | --- | --- |
219
+ | `InvalidBaseUrlError` | `baseUrl` is missing, relative, or not `http(s)`. Thrown from the constructor. |
220
+ | `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`. |
221
+ | `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. |
222
+
223
+ ## Also exported
184
224
 
185
- MIT
225
+ `buildSitemapXml(entries, baseUrl)`, `escapeXml(value)` and
226
+ `joinOrigin(origin, path)` — the pieces `Sitemap` is built from, for when you
227
+ want the serialiser without the builder.