@thomasfosterau/effect-sitemap 0.0.0 → 0.1.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.
@@ -0,0 +1,426 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Stream from "effect/Stream";
3
+ import * as HttpServerResponse from "effect/http/HttpServerResponse";
4
+ //#region src/index.d.ts
5
+ /**
6
+ * One sitemap entry: a site-root-relative path and an optional
7
+ * `YYYY-MM-DD` lastmod.
8
+ *
9
+ * @since 0.1.0
10
+ * @category models
11
+ */
12
+ export interface SitemapUrl {
13
+ readonly path: string;
14
+ readonly lastmod?: string | undefined;
15
+ }
16
+ /**
17
+ * The sitemap protocol's cap on one file: 50,000 URLs (and 50 MB). Split
18
+ * into an index past it ({@link chunkSitemap}, {@link renderSitemapIndex}).
19
+ *
20
+ * @since 0.1.0
21
+ * @category constants
22
+ */
23
+ export declare const SITEMAP_MAX_URLS = 50000;
24
+ /**
25
+ * The `Cache-Control` a low-churn generated document (`sitemap.xml`,
26
+ * `feed.json`, `robots.txt`) is served with: the edge caches the whole
27
+ * document, bounding any request-time enumeration behind it to at most one
28
+ * per hour per edge (PERF-CACHE-002). A mutation that could change the
29
+ * document purges its `Cache-Tag` ({@link purgeCacheTags}) rather than
30
+ * relying on the TTL alone.
31
+ *
32
+ * @since 0.1.0
33
+ * @category constants
34
+ */
35
+ export declare const LOW_CHURN_CACHE_CONTROL = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
36
+ /**
37
+ * What a served discovery document is cached under.
38
+ *
39
+ * @since 0.1.0
40
+ * @category models
41
+ */
42
+ export interface DocumentCacheOptions {
43
+ /** The `Cache-Tag` a mutation purges ({@link purgeCacheTags}); absent means untagged. */
44
+ readonly cacheTag?: string | undefined;
45
+ /** Defaults to {@link LOW_CHURN_CACHE_CONTROL}. */
46
+ readonly cacheControl?: string | undefined;
47
+ }
48
+ /**
49
+ * The five XML-reserved characters escaped for element text.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * import { escapeXml } from "@thomasfosterau/effect-sitemap"
54
+ *
55
+ * escapeXml(`a & b < "c"`) // "a &amp; b &lt; &quot;c&quot;"
56
+ * ```
57
+ *
58
+ * @since 0.1.0
59
+ * @category rendering
60
+ */
61
+ export declare const escapeXml: (value: string) => string;
62
+ /**
63
+ * The `YYYY-MM-DD` day of a `Date` or an ISO timestamp string, or
64
+ * `undefined` when unparsable — the value `renderUrl` reads as "omit
65
+ * `<lastmod>`". A parse of an existing value, never a clock read.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * import { lastmodOf } from "@thomasfosterau/effect-sitemap"
70
+ *
71
+ * lastmodOf("2026-03-04T05:06:07.000Z") // "2026-03-04"
72
+ * lastmodOf(new Date("2026-03-04T23:59:59Z")) // "2026-03-04"
73
+ * lastmodOf("not a date") // undefined
74
+ * ```
75
+ *
76
+ * @since 0.1.0
77
+ * @category rendering
78
+ */
79
+ export declare const lastmodOf: (value: unknown) => string | undefined;
80
+ /**
81
+ * One `<url>` element on `base` (the canonical origin, never the request
82
+ * origin).
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * import { renderUrl } from "@thomasfosterau/effect-sitemap"
87
+ *
88
+ * renderUrl("https://example.com", { path: "/posts/1", lastmod: "2026-03-04" })
89
+ * // "<url><loc>https://example.com/posts/1</loc><lastmod>2026-03-04</lastmod></url>"
90
+ * renderUrl("https://example.com", { path: "/" }) // "<url><loc>https://example.com/</loc></url>"
91
+ * ```
92
+ *
93
+ * @since 0.1.0
94
+ * @category rendering
95
+ */
96
+ export declare const renderUrl: (base: string, entry: SitemapUrl) => string;
97
+ /**
98
+ * The XML declaration and `<urlset>` opening tag that begin every sitemap
99
+ * ({@link renderSitemap}, and the first chunk of {@link sitemapStream}).
100
+ *
101
+ * @since 0.1.0
102
+ * @category constants
103
+ */
104
+ export declare const URLSET_OPEN = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">";
105
+ /**
106
+ * The `<urlset>` closing tag that ends every sitemap ({@link renderSitemap},
107
+ * and the last chunk of {@link sitemapStream}).
108
+ *
109
+ * @since 0.1.0
110
+ * @category constants
111
+ */
112
+ export declare const URLSET_CLOSE = "</urlset>";
113
+ /**
114
+ * The whole `sitemap.xml` on `base`, materialised: every `<loc>` is absolute
115
+ * on that canonical origin, never the request origin (CANON-001). For a
116
+ * bounded set (static shells plus a `LIMIT`ed enumeration);
117
+ * {@link sitemapStream} for a large one.
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * import { renderSitemap } from "@thomasfosterau/effect-sitemap"
122
+ *
123
+ * renderSitemap("https://example.com", [{ path: "/" }, { path: "/posts/1", lastmod: "2026-03-04" }])
124
+ * // '<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://example.com/</loc></url><url><loc>https://example.com/posts/1</loc><lastmod>2026-03-04</lastmod></url></urlset>'
125
+ * ```
126
+ *
127
+ * @since 0.1.0
128
+ * @category rendering
129
+ */
130
+ export declare const renderSitemap: (base: string, entries: Iterable<SitemapUrl>) => string;
131
+ /**
132
+ * The `sitemap.xml` as a `Stream` of text chunks — the opening, one chunk
133
+ * per entry as the source yields it, the closing — so a large enumeration
134
+ * is never materialised in the isolate before the first byte goes out. Every
135
+ * `<loc>` is absolute on `base`, the canonical origin (CANON-001). Run
136
+ * the enumeration before the response is constructed when it needs a
137
+ * database: the body stream must not touch a per-request runtime that is
138
+ * tearing down.
139
+ *
140
+ * @example
141
+ * ```ts
142
+ * import * as Effect from "effect/Effect"
143
+ * import * as Stream from "effect/Stream"
144
+ * import { sitemapStream } from "@thomasfosterau/effect-sitemap"
145
+ *
146
+ * const chunks = await Effect.runPromise(
147
+ * Stream.runCollect(sitemapStream("https://example.com", Stream.fromIterable([{ path: "/a" }, { path: "/b" }]))),
148
+ * )
149
+ * chunks.length // 4 — open, two entries, close
150
+ * ```
151
+ *
152
+ * @since 0.1.0
153
+ * @category streaming
154
+ */
155
+ export declare const sitemapStream: <E, R>(base: string, entries: Stream.Stream<SitemapUrl, E, R>) => Stream.Stream<string, E, R>;
156
+ /**
157
+ * The streamed `sitemap.xml` as an `HttpServerResponse` with the
158
+ * `application/xml` type, {@link LOW_CHURN_CACHE_CONTROL} and, when given,
159
+ * the `Cache-Tag` a mutation purges.
160
+ *
161
+ * @example
162
+ * ```ts
163
+ * import * as Effect from "effect/Effect"
164
+ * import * as Stream from "effect/Stream"
165
+ * import * as HttpServerResponse from "effect/http/HttpServerResponse"
166
+ * import { SITEMAP_CACHE_TAG, sitemapResponse } from "@thomasfosterau/effect-sitemap"
167
+ *
168
+ * const response = sitemapResponse("https://example.com", Stream.fromIterable([{ path: "/" }]), { cacheTag: SITEMAP_CACHE_TAG })
169
+ * response.headers["cache-tag"] // "sitemap"
170
+ * await HttpServerResponse.toWeb(response).text() // the XML document
171
+ * ```
172
+ *
173
+ * @since 0.1.0
174
+ * @category streaming
175
+ */
176
+ export declare const sitemapResponse: <E>(base: string, entries: Stream.Stream<SitemapUrl, E>, options?: DocumentCacheOptions) => HttpServerResponse.HttpServerResponse;
177
+ /**
178
+ * Splits an enumeration into sitemap-sized files (at most `size` URLs each,
179
+ * default {@link SITEMAP_MAX_URLS}) for a sitemap index.
180
+ *
181
+ * @example
182
+ * ```ts
183
+ * import { chunkSitemap } from "@thomasfosterau/effect-sitemap"
184
+ *
185
+ * chunkSitemap([{ path: "/a" }, { path: "/b" }, { path: "/c" }], 2).map((chunk) => chunk.length) // [2, 1]
186
+ * ```
187
+ *
188
+ * @since 0.1.0
189
+ * @category rendering
190
+ */
191
+ export declare const chunkSitemap: (entries: ReadonlyArray<SitemapUrl>, size?: number) => ReadonlyArray<ReadonlyArray<SitemapUrl>>;
192
+ /**
193
+ * A `sitemap.xml` index over several sitemap files, each a site-root-relative
194
+ * path (`/sitemaps/posts-1.xml`) with an optional lastmod.
195
+ *
196
+ * @example
197
+ * ```ts
198
+ * import { renderSitemapIndex } from "@thomasfosterau/effect-sitemap"
199
+ *
200
+ * renderSitemapIndex("https://example.com", [{ path: "/sitemap-1.xml", lastmod: "2026-03-04" }, { path: "/sitemap-2.xml" }])
201
+ * // '<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://example.com/sitemap-1.xml</loc><lastmod>2026-03-04</lastmod></sitemap><sitemap><loc>https://example.com/sitemap-2.xml</loc></sitemap></sitemapindex>'
202
+ * ```
203
+ *
204
+ * @since 0.1.0
205
+ * @category rendering
206
+ */
207
+ export declare const renderSitemapIndex: (base: string, sitemaps: Iterable<SitemapUrl>) => string;
208
+ /**
209
+ * A JSON Feed 1.1 item (the subset emitted).
210
+ *
211
+ * @since 0.1.0
212
+ * @category models
213
+ */
214
+ export interface JsonFeedItem {
215
+ readonly id: string;
216
+ readonly url: string;
217
+ readonly title?: string;
218
+ readonly summary?: string;
219
+ readonly date_published: string;
220
+ readonly date_modified: string;
221
+ }
222
+ /**
223
+ * A JSON Feed 1.1 document (the subset emitted).
224
+ *
225
+ * @since 0.1.0
226
+ * @category models
227
+ */
228
+ export interface JsonFeed {
229
+ readonly version: "https://jsonfeed.org/version/1.1";
230
+ readonly title: string;
231
+ readonly home_page_url: string;
232
+ readonly feed_url: string;
233
+ readonly description: string;
234
+ readonly items: ReadonlyArray<JsonFeedItem>;
235
+ }
236
+ /**
237
+ * One feed entry: a site-root-relative path, its timestamps and its text.
238
+ * `title` / `summary` are `null` when the row has none (the SQL wire shape
239
+ * an enumeration produces) and omitted from the item.
240
+ *
241
+ * @since 0.1.0
242
+ * @category models
243
+ */
244
+ export interface FeedEntry {
245
+ readonly path: string;
246
+ /** ISO 8601 datetime of the most recent version — the item's `date_modified`. */
247
+ readonly updatedAt: string;
248
+ /** ISO 8601 datetime of the earliest version — the item's `date_published`. */
249
+ readonly publishedAt: string;
250
+ readonly title?: string | null | undefined;
251
+ readonly summary?: string | null | undefined;
252
+ }
253
+ /**
254
+ * What names a feed.
255
+ *
256
+ * @since 0.1.0
257
+ * @category models
258
+ */
259
+ export interface JsonFeedOptions {
260
+ /** The canonical origin, never the request origin. */
261
+ readonly base: string;
262
+ readonly title: string;
263
+ readonly description: string;
264
+ /** The feed's own path. Defaults to `/feed.json`. */
265
+ readonly feedPath?: string | undefined;
266
+ /** The home page's path. Defaults to `/`. */
267
+ readonly homePath?: string | undefined;
268
+ }
269
+ /**
270
+ * The item bound for a change-discovery feed: "latest changes", not a
271
+ * complete listing (DISC-010).
272
+ *
273
+ * @since 0.1.0
274
+ * @category constants
275
+ */
276
+ export declare const FEED_ITEM_LIMIT = 50;
277
+ /**
278
+ * Serialises feed entries into a JSON Feed 1.1 document. Pure. Item `id`
279
+ * and `url` are the canonical absolute URL on `options.base`, never the
280
+ * request origin (CANON-001); `title` / `summary` are omitted, never
281
+ * `null`-valued keys, when absent; entry order is preserved (the caller is
282
+ * responsible for newest-first).
283
+ *
284
+ * @example
285
+ * ```ts
286
+ * import { buildJsonFeed } from "@thomasfosterau/effect-sitemap"
287
+ *
288
+ * const feed = buildJsonFeed(
289
+ * { base: "https://example.com", title: "Example — Latest changes", description: "The 50 most recent changes." },
290
+ * [{ path: "/posts/1", publishedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-02-01T00:00:00.000Z", title: "Hello", summary: null }],
291
+ * )
292
+ * feed.items[0]?.url // "https://example.com/posts/1"
293
+ * "summary" in (feed.items[0] ?? {}) // false
294
+ * ```
295
+ *
296
+ * @since 0.1.0
297
+ * @category feed
298
+ */
299
+ export declare const buildJsonFeed: (options: JsonFeedOptions, entries: ReadonlyArray<FeedEntry>) => JsonFeed;
300
+ /**
301
+ * The `Content-Type` a JSON Feed is served with.
302
+ *
303
+ * @since 0.1.0
304
+ * @category constants
305
+ */
306
+ export declare const JSON_FEED_CONTENT_TYPE = "application/feed+json";
307
+ /**
308
+ * A JSON Feed as an `HttpServerResponse`: 2-space pretty-printed (a
309
+ * hand-readable discovery document; changing the indent changes the bytes on
310
+ * the wire and in every cached copy), `application/feed+json`,
311
+ * {@link LOW_CHURN_CACHE_CONTROL} and, when given, the `Cache-Tag`.
312
+ *
313
+ * @example
314
+ * ```ts
315
+ * import * as HttpServerResponse from "effect/http/HttpServerResponse"
316
+ * import { buildJsonFeed, FEED_CACHE_TAG, jsonFeedResponse } from "@thomasfosterau/effect-sitemap"
317
+ *
318
+ * const feed = buildJsonFeed({ base: "https://example.com", title: "t", description: "d" }, [])
319
+ * const response = jsonFeedResponse(feed, { cacheTag: FEED_CACHE_TAG })
320
+ * response.headers["content-type"] // "application/feed+json"
321
+ * const text = await HttpServerResponse.toWeb(response).text()
322
+ * text.startsWith("{\n \"version\"") // true
323
+ * ```
324
+ *
325
+ * @since 0.1.0
326
+ * @category feed
327
+ */
328
+ export declare const jsonFeedResponse: (feed: JsonFeed, options?: DocumentCacheOptions) => HttpServerResponse.HttpServerResponse;
329
+ /**
330
+ * The `Cache-Tag` on every `feed.json` response.
331
+ *
332
+ * @since 0.1.0
333
+ * @category constants
334
+ */
335
+ export declare const FEED_CACHE_TAG = "feed";
336
+ /**
337
+ * The `Cache-Tag` on every `sitemap.xml` response.
338
+ *
339
+ * @since 0.1.0
340
+ * @category constants
341
+ */
342
+ export declare const SITEMAP_CACHE_TAG = "sitemap";
343
+ /**
344
+ * Builds a `purgeTagsForResourceType`: the tags a mutation to one resource
345
+ * of `type` may have staled — both aggregate documents when the type can
346
+ * appear in them, nothing otherwise. Deliberately decided from the type
347
+ * alone, with no per-request visibility check: a write to an always-private
348
+ * resource of an eligible type still purges — a wasted purge, not a missed
349
+ * one. `eligibleTypes` are the resource-type segments the documents
350
+ * enumerate against (the values of the application's route-segment map).
351
+ *
352
+ * @example
353
+ * ```ts
354
+ * import { FEED_CACHE_TAG, makePurgeTagsForResourceType, SITEMAP_CACHE_TAG } from "@thomasfosterau/effect-sitemap"
355
+ *
356
+ * const purgeTagsForResourceType = makePurgeTagsForResourceType(["posts", "people"])
357
+ * purgeTagsForResourceType("posts") // [SITEMAP_CACHE_TAG, FEED_CACHE_TAG]
358
+ * purgeTagsForResourceType("comments") // []
359
+ * ```
360
+ *
361
+ * @since 0.1.0
362
+ * @category purging
363
+ */
364
+ export declare const makePurgeTagsForResourceType: (eligibleTypes: Iterable<string>, tags?: ReadonlyArray<string>) => ((type: string) => ReadonlyArray<string>);
365
+ /**
366
+ * The subset of a Cloudflare `ExecutionContext` the purge needs — narrowed
367
+ * so a test can pass a bare `{ cache: { purge } }` rather than a platform
368
+ * `ExecutionContext`. `cache` is absent in local dev, where Workers Cache is
369
+ * not emulated.
370
+ *
371
+ * @since 0.1.0
372
+ * @category models
373
+ */
374
+ export interface CachePurgeContext {
375
+ readonly cache?: {
376
+ purge(options: {
377
+ readonly tags: Array<string>;
378
+ }): Promise<{
379
+ readonly success: boolean;
380
+ readonly errors: ReadonlyArray<unknown>;
381
+ }>;
382
+ } | undefined;
383
+ }
384
+ /**
385
+ * Purges the given `Cache-Tag`s from THIS Worker's Workers Cache (a
386
+ * `purge({ tags })` purges only the calling Worker's own cache). A no-op
387
+ * for an empty tag list or an absent `ctx.cache`; a logged, never failing,
388
+ * outcome otherwise — the purge call rejecting, or *resolving* with
389
+ * `success: false` (a Cloudflare-side failure resolves rather than rejects).
390
+ * This is the purge half of PERF-CACHE-002: a mutation purges by
391
+ * `Cache-Tag` rather than waiting out the TTL. A dropped purge must never
392
+ * surface on the mutation that triggered it: the surface serves stale
393
+ * content until its own `s-maxage` elapses, the accepted failure mode.
394
+ *
395
+ * @example
396
+ * ```ts
397
+ * import * as Effect from "effect/Effect"
398
+ * import { purgeCacheTags } from "@thomasfosterau/effect-sitemap"
399
+ *
400
+ * const purged: Array<ReadonlyArray<string>> = []
401
+ * const ctx = { cache: { purge: async ({ tags }: { tags: Array<string> }) => { purged.push(tags); return { success: true, errors: [] } } } }
402
+ * await Effect.runPromise(purgeCacheTags(ctx, ["sitemap", "feed"]))
403
+ * await Effect.runPromise(purgeCacheTags(undefined, ["sitemap"])) // no cache: no-op
404
+ * purged // [["sitemap", "feed"]]
405
+ * ```
406
+ *
407
+ * @since 0.1.0
408
+ * @category purging
409
+ */
410
+ export declare const purgeCacheTags: (ctx: CachePurgeContext | undefined, tags: ReadonlyArray<string>) => Effect.Effect<void>;
411
+ /**
412
+ * {@link purgeCacheTags} as a `Promise`, for the plain-JS seam a front
413
+ * door hands to `ctx.waitUntil` after the response has gone out.
414
+ *
415
+ * @example
416
+ * ```ts
417
+ * import { purgeCacheTagsPromise } from "@thomasfosterau/effect-sitemap"
418
+ *
419
+ * await purgeCacheTagsPromise(undefined, ["sitemap"]) // resolves; nothing to purge against
420
+ * ```
421
+ *
422
+ * @since 0.1.0
423
+ * @category purging
424
+ */
425
+ export declare const purgeCacheTagsPromise: (ctx: CachePurgeContext | undefined, tags: ReadonlyArray<string>) => Promise<void>;
426
+ //#endregion
package/dist/index.js ADDED
@@ -0,0 +1,410 @@
1
+ import * as Arr from "effect/Array";
2
+ import * as Effect from "effect/Effect";
3
+ import * as HashSet from "effect/HashSet";
4
+ import * as Predicate from "effect/Predicate";
5
+ import * as Stream from "effect/Stream";
6
+ import * as HttpServerResponse from "effect/http/HttpServerResponse";
7
+ import { describeError, logErrorEvent } from "@thomasfosterau/effect-web/observability";
8
+ //#region src/index.ts
9
+ /**
10
+ * `@thomasfosterau/effect-sitemap` — the discovery documents an
11
+ * application serves and the cache they live behind: `sitemap.xml`
12
+ * (rendered whole, or streamed), a sitemap index, `feed.json` (JSON Feed
13
+ * 1.1), and the `Cache-Tag` purge that keeps the edge copies fresh.
14
+ *
15
+ * Everything here takes a site-root-relative `path`; mapping a resource to
16
+ * its route stays in the application.
17
+ *
18
+ * No XML escaping is applied to `<loc>`: every location is the canonical
19
+ * origin plus a path an application derives from validated ids or a
20
+ * hand-maintained list. If authored text (a title) is ever emitted it MUST
21
+ * be escaped first; {@link escapeXml} is here for that.
22
+ *
23
+ * @since 0.1.0
24
+ */
25
+ /**
26
+ * The sitemap protocol's cap on one file: 50,000 URLs (and 50 MB). Split
27
+ * into an index past it ({@link chunkSitemap}, {@link renderSitemapIndex}).
28
+ *
29
+ * @since 0.1.0
30
+ * @category constants
31
+ */
32
+ const SITEMAP_MAX_URLS = 5e4;
33
+ /**
34
+ * The `Cache-Control` a low-churn generated document (`sitemap.xml`,
35
+ * `feed.json`, `robots.txt`) is served with: the edge caches the whole
36
+ * document, bounding any request-time enumeration behind it to at most one
37
+ * per hour per edge (PERF-CACHE-002). A mutation that could change the
38
+ * document purges its `Cache-Tag` ({@link purgeCacheTags}) rather than
39
+ * relying on the TTL alone.
40
+ *
41
+ * @since 0.1.0
42
+ * @category constants
43
+ */
44
+ const LOW_CHURN_CACHE_CONTROL = "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
45
+ /** The cache headers of a served document; the tag only when given. */
46
+ const documentHeaders = (options) => {
47
+ const control = options.cacheControl ?? "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400";
48
+ return options.cacheTag === void 0 ? { "cache-control": control } : {
49
+ "cache-control": control,
50
+ "cache-tag": options.cacheTag
51
+ };
52
+ };
53
+ /**
54
+ * The five XML-reserved characters escaped for element text.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * import { escapeXml } from "@thomasfosterau/effect-sitemap"
59
+ *
60
+ * escapeXml(`a & b < "c"`) // "a &amp; b &lt; &quot;c&quot;"
61
+ * ```
62
+ *
63
+ * @since 0.1.0
64
+ * @category rendering
65
+ */
66
+ const escapeXml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
67
+ /**
68
+ * The `YYYY-MM-DD` day of a `Date` or an ISO timestamp string, or
69
+ * `undefined` when unparsable — the value `renderUrl` reads as "omit
70
+ * `<lastmod>`". A parse of an existing value, never a clock read.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * import { lastmodOf } from "@thomasfosterau/effect-sitemap"
75
+ *
76
+ * lastmodOf("2026-03-04T05:06:07.000Z") // "2026-03-04"
77
+ * lastmodOf(new Date("2026-03-04T23:59:59Z")) // "2026-03-04"
78
+ * lastmodOf("not a date") // undefined
79
+ * ```
80
+ *
81
+ * @since 0.1.0
82
+ * @category rendering
83
+ */
84
+ const lastmodOf = (value) => {
85
+ if (Predicate.isDate(value)) return Number.isNaN(value.getTime()) ? void 0 : value.toISOString().slice(0, 10);
86
+ if (Predicate.isString(value) && /^\d{4}-\d{2}-\d{2}/.test(value)) return value.slice(0, 10);
87
+ };
88
+ /**
89
+ * One `<url>` element on `base` (the canonical origin, never the request
90
+ * origin).
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * import { renderUrl } from "@thomasfosterau/effect-sitemap"
95
+ *
96
+ * renderUrl("https://example.com", { path: "/posts/1", lastmod: "2026-03-04" })
97
+ * // "<url><loc>https://example.com/posts/1</loc><lastmod>2026-03-04</lastmod></url>"
98
+ * renderUrl("https://example.com", { path: "/" }) // "<url><loc>https://example.com/</loc></url>"
99
+ * ```
100
+ *
101
+ * @since 0.1.0
102
+ * @category rendering
103
+ */
104
+ const renderUrl = (base, entry) => {
105
+ return `<url><loc>${new URL(entry.path, base).toString()}</loc>${entry.lastmod === void 0 ? "" : `<lastmod>${entry.lastmod}</lastmod>`}</url>`;
106
+ };
107
+ /**
108
+ * The XML declaration and `<urlset>` opening tag that begin every sitemap
109
+ * ({@link renderSitemap}, and the first chunk of {@link sitemapStream}).
110
+ *
111
+ * @since 0.1.0
112
+ * @category constants
113
+ */
114
+ const URLSET_OPEN = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`;
115
+ /**
116
+ * The `<urlset>` closing tag that ends every sitemap ({@link renderSitemap},
117
+ * and the last chunk of {@link sitemapStream}).
118
+ *
119
+ * @since 0.1.0
120
+ * @category constants
121
+ */
122
+ const URLSET_CLOSE = `</urlset>`;
123
+ /**
124
+ * The whole `sitemap.xml` on `base`, materialised: every `<loc>` is absolute
125
+ * on that canonical origin, never the request origin (CANON-001). For a
126
+ * bounded set (static shells plus a `LIMIT`ed enumeration);
127
+ * {@link sitemapStream} for a large one.
128
+ *
129
+ * @example
130
+ * ```ts
131
+ * import { renderSitemap } from "@thomasfosterau/effect-sitemap"
132
+ *
133
+ * renderSitemap("https://example.com", [{ path: "/" }, { path: "/posts/1", lastmod: "2026-03-04" }])
134
+ * // '<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://example.com/</loc></url><url><loc>https://example.com/posts/1</loc><lastmod>2026-03-04</lastmod></url></urlset>'
135
+ * ```
136
+ *
137
+ * @since 0.1.0
138
+ * @category rendering
139
+ */
140
+ const renderSitemap = (base, entries) => [
141
+ URLSET_OPEN,
142
+ ...Arr.map(Arr.fromIterable(entries), (entry) => renderUrl(base, entry)),
143
+ URLSET_CLOSE
144
+ ].join("");
145
+ /**
146
+ * The `sitemap.xml` as a `Stream` of text chunks — the opening, one chunk
147
+ * per entry as the source yields it, the closing — so a large enumeration
148
+ * is never materialised in the isolate before the first byte goes out. Every
149
+ * `<loc>` is absolute on `base`, the canonical origin (CANON-001). Run
150
+ * the enumeration before the response is constructed when it needs a
151
+ * database: the body stream must not touch a per-request runtime that is
152
+ * tearing down.
153
+ *
154
+ * @example
155
+ * ```ts
156
+ * import * as Effect from "effect/Effect"
157
+ * import * as Stream from "effect/Stream"
158
+ * import { sitemapStream } from "@thomasfosterau/effect-sitemap"
159
+ *
160
+ * const chunks = await Effect.runPromise(
161
+ * Stream.runCollect(sitemapStream("https://example.com", Stream.fromIterable([{ path: "/a" }, { path: "/b" }]))),
162
+ * )
163
+ * chunks.length // 4 — open, two entries, close
164
+ * ```
165
+ *
166
+ * @since 0.1.0
167
+ * @category streaming
168
+ */
169
+ const sitemapStream = (base, entries) => Stream.concat(Stream.concat(Stream.succeed(URLSET_OPEN), Stream.map(entries, (entry) => renderUrl(base, entry))), Stream.succeed(URLSET_CLOSE));
170
+ /**
171
+ * The streamed `sitemap.xml` as an `HttpServerResponse` with the
172
+ * `application/xml` type, {@link LOW_CHURN_CACHE_CONTROL} and, when given,
173
+ * the `Cache-Tag` a mutation purges.
174
+ *
175
+ * @example
176
+ * ```ts
177
+ * import * as Effect from "effect/Effect"
178
+ * import * as Stream from "effect/Stream"
179
+ * import * as HttpServerResponse from "effect/http/HttpServerResponse"
180
+ * import { SITEMAP_CACHE_TAG, sitemapResponse } from "@thomasfosterau/effect-sitemap"
181
+ *
182
+ * const response = sitemapResponse("https://example.com", Stream.fromIterable([{ path: "/" }]), { cacheTag: SITEMAP_CACHE_TAG })
183
+ * response.headers["cache-tag"] // "sitemap"
184
+ * await HttpServerResponse.toWeb(response).text() // the XML document
185
+ * ```
186
+ *
187
+ * @since 0.1.0
188
+ * @category streaming
189
+ */
190
+ const sitemapResponse = (base, entries, options = {}) => HttpServerResponse.stream(Stream.encodeText(sitemapStream(base, entries)), {
191
+ contentType: "application/xml",
192
+ headers: documentHeaders(options)
193
+ });
194
+ /**
195
+ * Splits an enumeration into sitemap-sized files (at most `size` URLs each,
196
+ * default {@link SITEMAP_MAX_URLS}) for a sitemap index.
197
+ *
198
+ * @example
199
+ * ```ts
200
+ * import { chunkSitemap } from "@thomasfosterau/effect-sitemap"
201
+ *
202
+ * chunkSitemap([{ path: "/a" }, { path: "/b" }, { path: "/c" }], 2).map((chunk) => chunk.length) // [2, 1]
203
+ * ```
204
+ *
205
+ * @since 0.1.0
206
+ * @category rendering
207
+ */
208
+ const chunkSitemap = (entries, size = SITEMAP_MAX_URLS) => Arr.isReadonlyArrayEmpty(entries) ? [] : Arr.chunksOf(entries, size);
209
+ /**
210
+ * A `sitemap.xml` index over several sitemap files, each a site-root-relative
211
+ * path (`/sitemaps/posts-1.xml`) with an optional lastmod.
212
+ *
213
+ * @example
214
+ * ```ts
215
+ * import { renderSitemapIndex } from "@thomasfosterau/effect-sitemap"
216
+ *
217
+ * renderSitemapIndex("https://example.com", [{ path: "/sitemap-1.xml", lastmod: "2026-03-04" }, { path: "/sitemap-2.xml" }])
218
+ * // '<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://example.com/sitemap-1.xml</loc><lastmod>2026-03-04</lastmod></sitemap><sitemap><loc>https://example.com/sitemap-2.xml</loc></sitemap></sitemapindex>'
219
+ * ```
220
+ *
221
+ * @since 0.1.0
222
+ * @category rendering
223
+ */
224
+ const renderSitemapIndex = (base, sitemaps) => [
225
+ `<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`,
226
+ ...Arr.map(Arr.fromIterable(sitemaps), (sitemap) => {
227
+ return `<sitemap><loc>${new URL(sitemap.path, base).toString()}</loc>${sitemap.lastmod === void 0 ? "" : `<lastmod>${sitemap.lastmod}</lastmod>`}</sitemap>`;
228
+ }),
229
+ `</sitemapindex>`
230
+ ].join("");
231
+ /**
232
+ * The item bound for a change-discovery feed: "latest changes", not a
233
+ * complete listing (DISC-010).
234
+ *
235
+ * @since 0.1.0
236
+ * @category constants
237
+ */
238
+ const FEED_ITEM_LIMIT = 50;
239
+ /**
240
+ * Serialises feed entries into a JSON Feed 1.1 document. Pure. Item `id`
241
+ * and `url` are the canonical absolute URL on `options.base`, never the
242
+ * request origin (CANON-001); `title` / `summary` are omitted, never
243
+ * `null`-valued keys, when absent; entry order is preserved (the caller is
244
+ * responsible for newest-first).
245
+ *
246
+ * @example
247
+ * ```ts
248
+ * import { buildJsonFeed } from "@thomasfosterau/effect-sitemap"
249
+ *
250
+ * const feed = buildJsonFeed(
251
+ * { base: "https://example.com", title: "Example — Latest changes", description: "The 50 most recent changes." },
252
+ * [{ path: "/posts/1", publishedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-02-01T00:00:00.000Z", title: "Hello", summary: null }],
253
+ * )
254
+ * feed.items[0]?.url // "https://example.com/posts/1"
255
+ * "summary" in (feed.items[0] ?? {}) // false
256
+ * ```
257
+ *
258
+ * @since 0.1.0
259
+ * @category feed
260
+ */
261
+ const buildJsonFeed = (options, entries) => ({
262
+ version: "https://jsonfeed.org/version/1.1",
263
+ title: options.title,
264
+ home_page_url: new URL(options.homePath ?? "/", options.base).toString(),
265
+ feed_url: new URL(options.feedPath ?? "/feed.json", options.base).toString(),
266
+ description: options.description,
267
+ items: Arr.map(entries, (entry) => feedItem(options.base, entry))
268
+ });
269
+ /** One item; `title` / `summary` are added only when the entry has them. */
270
+ const feedItem = (base, entry) => {
271
+ const url = new URL(entry.path, base).toString();
272
+ const item = {
273
+ id: url,
274
+ url,
275
+ date_published: entry.publishedAt,
276
+ date_modified: entry.updatedAt
277
+ };
278
+ if (entry.title !== null && entry.title !== void 0) item.title = entry.title;
279
+ if (entry.summary !== null && entry.summary !== void 0) item.summary = entry.summary;
280
+ return item;
281
+ };
282
+ /**
283
+ * The `Content-Type` a JSON Feed is served with.
284
+ *
285
+ * @since 0.1.0
286
+ * @category constants
287
+ */
288
+ const JSON_FEED_CONTENT_TYPE = "application/feed+json";
289
+ /**
290
+ * A JSON Feed as an `HttpServerResponse`: 2-space pretty-printed (a
291
+ * hand-readable discovery document; changing the indent changes the bytes on
292
+ * the wire and in every cached copy), `application/feed+json`,
293
+ * {@link LOW_CHURN_CACHE_CONTROL} and, when given, the `Cache-Tag`.
294
+ *
295
+ * @example
296
+ * ```ts
297
+ * import * as HttpServerResponse from "effect/http/HttpServerResponse"
298
+ * import { buildJsonFeed, FEED_CACHE_TAG, jsonFeedResponse } from "@thomasfosterau/effect-sitemap"
299
+ *
300
+ * const feed = buildJsonFeed({ base: "https://example.com", title: "t", description: "d" }, [])
301
+ * const response = jsonFeedResponse(feed, { cacheTag: FEED_CACHE_TAG })
302
+ * response.headers["content-type"] // "application/feed+json"
303
+ * const text = await HttpServerResponse.toWeb(response).text()
304
+ * text.startsWith("{\n \"version\"") // true
305
+ * ```
306
+ *
307
+ * @since 0.1.0
308
+ * @category feed
309
+ */
310
+ const jsonFeedResponse = (feed, options = {}) => HttpServerResponse.text(JSON.stringify(feed, null, 2), {
311
+ contentType: JSON_FEED_CONTENT_TYPE,
312
+ headers: documentHeaders(options)
313
+ });
314
+ /**
315
+ * The `Cache-Tag` on every `feed.json` response.
316
+ *
317
+ * @since 0.1.0
318
+ * @category constants
319
+ */
320
+ const FEED_CACHE_TAG = "feed";
321
+ /**
322
+ * The `Cache-Tag` on every `sitemap.xml` response.
323
+ *
324
+ * @since 0.1.0
325
+ * @category constants
326
+ */
327
+ const SITEMAP_CACHE_TAG = "sitemap";
328
+ /**
329
+ * Builds a `purgeTagsForResourceType`: the tags a mutation to one resource
330
+ * of `type` may have staled — both aggregate documents when the type can
331
+ * appear in them, nothing otherwise. Deliberately decided from the type
332
+ * alone, with no per-request visibility check: a write to an always-private
333
+ * resource of an eligible type still purges — a wasted purge, not a missed
334
+ * one. `eligibleTypes` are the resource-type segments the documents
335
+ * enumerate against (the values of the application's route-segment map).
336
+ *
337
+ * @example
338
+ * ```ts
339
+ * import { FEED_CACHE_TAG, makePurgeTagsForResourceType, SITEMAP_CACHE_TAG } from "@thomasfosterau/effect-sitemap"
340
+ *
341
+ * const purgeTagsForResourceType = makePurgeTagsForResourceType(["posts", "people"])
342
+ * purgeTagsForResourceType("posts") // [SITEMAP_CACHE_TAG, FEED_CACHE_TAG]
343
+ * purgeTagsForResourceType("comments") // []
344
+ * ```
345
+ *
346
+ * @since 0.1.0
347
+ * @category purging
348
+ */
349
+ const makePurgeTagsForResourceType = (eligibleTypes, tags = [SITEMAP_CACHE_TAG, FEED_CACHE_TAG]) => {
350
+ const eligible = HashSet.fromIterable(eligibleTypes);
351
+ return (type) => HashSet.has(eligible, type) ? tags : [];
352
+ };
353
+ /**
354
+ * Purges the given `Cache-Tag`s from THIS Worker's Workers Cache (a
355
+ * `purge({ tags })` purges only the calling Worker's own cache). A no-op
356
+ * for an empty tag list or an absent `ctx.cache`; a logged, never failing,
357
+ * outcome otherwise — the purge call rejecting, or *resolving* with
358
+ * `success: false` (a Cloudflare-side failure resolves rather than rejects).
359
+ * This is the purge half of PERF-CACHE-002: a mutation purges by
360
+ * `Cache-Tag` rather than waiting out the TTL. A dropped purge must never
361
+ * surface on the mutation that triggered it: the surface serves stale
362
+ * content until its own `s-maxage` elapses, the accepted failure mode.
363
+ *
364
+ * @example
365
+ * ```ts
366
+ * import * as Effect from "effect/Effect"
367
+ * import { purgeCacheTags } from "@thomasfosterau/effect-sitemap"
368
+ *
369
+ * const purged: Array<ReadonlyArray<string>> = []
370
+ * const ctx = { cache: { purge: async ({ tags }: { tags: Array<string> }) => { purged.push(tags); return { success: true, errors: [] } } } }
371
+ * await Effect.runPromise(purgeCacheTags(ctx, ["sitemap", "feed"]))
372
+ * await Effect.runPromise(purgeCacheTags(undefined, ["sitemap"])) // no cache: no-op
373
+ * purged // [["sitemap", "feed"]]
374
+ * ```
375
+ *
376
+ * @since 0.1.0
377
+ * @category purging
378
+ */
379
+ const purgeCacheTags = (ctx, tags) => {
380
+ if (Arr.isReadonlyArrayEmpty(tags)) return Effect.void;
381
+ const cache = ctx?.cache;
382
+ if (cache === void 0) return Effect.void;
383
+ return Effect.tryPromise({
384
+ try: () => cache.purge({ tags: [...tags] }),
385
+ catch: (cause) => describeError(cause)
386
+ }).pipe(Effect.flatMap((result) => result.success ? Effect.void : logErrorEvent("cache-purge.rejected", {
387
+ tags,
388
+ errors: result.errors
389
+ })), Effect.catch((cause) => logErrorEvent("cache-purge.failed", {
390
+ tags,
391
+ cause
392
+ })));
393
+ };
394
+ /**
395
+ * {@link purgeCacheTags} as a `Promise`, for the plain-JS seam a front
396
+ * door hands to `ctx.waitUntil` after the response has gone out.
397
+ *
398
+ * @example
399
+ * ```ts
400
+ * import { purgeCacheTagsPromise } from "@thomasfosterau/effect-sitemap"
401
+ *
402
+ * await purgeCacheTagsPromise(undefined, ["sitemap"]) // resolves; nothing to purge against
403
+ * ```
404
+ *
405
+ * @since 0.1.0
406
+ * @category purging
407
+ */
408
+ const purgeCacheTagsPromise = (ctx, tags) => Effect.runPromise(purgeCacheTags(ctx, tags));
409
+ //#endregion
410
+ export { FEED_CACHE_TAG, FEED_ITEM_LIMIT, JSON_FEED_CONTENT_TYPE, LOW_CHURN_CACHE_CONTROL, SITEMAP_CACHE_TAG, SITEMAP_MAX_URLS, URLSET_CLOSE, URLSET_OPEN, buildJsonFeed, chunkSitemap, escapeXml, jsonFeedResponse, lastmodOf, makePurgeTagsForResourceType, purgeCacheTags, purgeCacheTagsPromise, renderSitemap, renderSitemapIndex, renderUrl, sitemapResponse, sitemapStream };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package",
3
3
  "name": "@thomasfosterau/effect-sitemap",
4
- "version": "0.0.0",
4
+ "version": "0.1.0",
5
5
  "description": "Sitemap, sitemap index and JSON Feed 1.1 builders for Effect web applications (rendered or streamed), the low-churn edge cache policy they share, and the Cache-Tag purge-on-mutation seam.",
6
6
  "keywords": [
7
7
  "effect",
@@ -37,11 +37,11 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@thomasfosterau/effect-web": "^0.0.0"
40
+ "@thomasfosterau/effect-web": "^0.1.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@effect/language-service": "^0.87.4",
44
- "@types/node": "^26.6.4",
44
+ "@types/node": "^26.6.5",
45
45
  "@vitest/coverage-istanbul": "4.1.11",
46
46
  "effect": "4.0.3"
47
47
  },