@thomasfosterau/effect-sitemap 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/dist/index.d.ts +426 -0
- package/dist/index.js +410 -0
- package/package.json +51 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Foster
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,119 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @thomasfosterau/effect-sitemap
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
Invariant ids in doc comments and below (`CANON-001`, `PERF-CACHE-002`, …) name the rule an export enforces; [`SECURITY.md`](https://github.com/thomasfosterau/effect-web/blob/main/SECURITY.md#invariant-ids) states each rule and the exports that enforce it.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add @thomasfosterau/effect-sitemap @thomasfosterau/effect-web effect
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Peer: `effect` (>= 4.0.0-rc.118). `@thomasfosterau/effect-web` is a dependency (the purge logs through its event logger). One entry, `sideEffects: false`, no top-level statements in `dist/`.
|
|
14
|
+
|
|
15
|
+
Everything takes a **site-root-relative path** and the **canonical origin** (the configured production origin, never the request origin). `renderSitemap`, `sitemapStream` and `buildJsonFeed` resolve every URL they emit against it (CANON-001). The map from an application's resource types to its route segments (`sitemapPath`, `feedPath`) is application vocabulary and stays in the application.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Sitemaps
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import * as Stream from "effect/Stream";
|
|
23
|
+
import {
|
|
24
|
+
lastmodOf,
|
|
25
|
+
renderSitemap,
|
|
26
|
+
SITEMAP_CACHE_TAG,
|
|
27
|
+
sitemapResponse,
|
|
28
|
+
type SitemapUrl,
|
|
29
|
+
} from "@thomasfosterau/effect-sitemap";
|
|
30
|
+
|
|
31
|
+
// `base` is the canonical origin (the configured production origin); the rows are the application's.
|
|
32
|
+
const STATIC_PAGES: ReadonlyArray<SitemapUrl> = [{ path: "/" }, { path: "/about" }];
|
|
33
|
+
|
|
34
|
+
// Whole, for a bounded set:
|
|
35
|
+
const wholeSitemap = (base: string, rows: ReadonlyArray<{ id: string; updatedAt: Date }>) =>
|
|
36
|
+
renderSitemap(base, [
|
|
37
|
+
...STATIC_PAGES,
|
|
38
|
+
...rows.map((row) => ({ path: `/people/${row.id}`, lastmod: lastmodOf(row.updatedAt) })),
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
// Streamed, one chunk per entry, for a large one (run the enumeration before building the response):
|
|
42
|
+
const streamedSitemap = (base: string, entries: ReadonlyArray<SitemapUrl>) =>
|
|
43
|
+
sitemapResponse(base, Stream.fromIterable(entries), { cacheTag: SITEMAP_CACHE_TAG });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| Export | What it is |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `SitemapUrl` | `{ path, lastmod?: "YYYY-MM-DD" }`. |
|
|
49
|
+
| `lastmodOf(value)` | The day of a `Date` or an ISO string; `undefined` (omit `<lastmod>`) otherwise. A parse, never a clock read. |
|
|
50
|
+
| `renderUrl(base, entry)` | One `<url>`, its `<loc>` absolute on `base`. |
|
|
51
|
+
| `renderSitemap(base, entries)` | The whole `<urlset>` with the XML declaration, every `<loc>` absolute on `base` (CANON-001). |
|
|
52
|
+
| `sitemapStream(base, Stream<SitemapUrl>)` | The document as a `Stream<string>`: opening, one chunk per entry, closing — never materialised in the isolate. Every `<loc>` is absolute on `base` (CANON-001). |
|
|
53
|
+
| `sitemapResponse(base, stream, { cacheTag?, cacheControl? })` | The stream as an `HttpServerResponse`: `application/xml`, `LOW_CHURN_CACHE_CONTROL`, the `Cache-Tag`. |
|
|
54
|
+
| `chunkSitemap(entries, size = SITEMAP_MAX_URLS)`, `renderSitemapIndex(base, sitemaps)` | Past 50,000 URLs: split into files and index them. |
|
|
55
|
+
| `URLSET_OPEN`, `URLSET_CLOSE`, `escapeXml` | The pieces, and the escaper for any authored text a consumer decides to emit. Nothing here emits any: `<loc>` is not escaped, because every location is the canonical origin plus a path built from validated ids or a hand-written list. |
|
|
56
|
+
|
|
57
|
+
### JSON Feed 1.1
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import {
|
|
61
|
+
buildJsonFeed,
|
|
62
|
+
FEED_CACHE_TAG,
|
|
63
|
+
FEED_ITEM_LIMIT,
|
|
64
|
+
jsonFeedResponse,
|
|
65
|
+
type FeedEntry,
|
|
66
|
+
} from "@thomasfosterau/effect-sitemap";
|
|
67
|
+
|
|
68
|
+
// `rows` are the newest FEED_ITEM_LIMIT changes, newest first.
|
|
69
|
+
const feedResponse = (base: string, rows: ReadonlyArray<FeedEntry>) =>
|
|
70
|
+
jsonFeedResponse(
|
|
71
|
+
buildJsonFeed(
|
|
72
|
+
{
|
|
73
|
+
base,
|
|
74
|
+
title: "Example — Latest changes",
|
|
75
|
+
description: `The ${FEED_ITEM_LIMIT} most recent changes.`,
|
|
76
|
+
},
|
|
77
|
+
rows,
|
|
78
|
+
),
|
|
79
|
+
{ cacheTag: FEED_CACHE_TAG },
|
|
80
|
+
);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| Export | What it is |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `FeedEntry` | `{ path, publishedAt, updatedAt, title?: string \| null, summary?: string \| null }` — the SQL wire shape, `null` for an absent text. |
|
|
86
|
+
| `buildJsonFeed({ base, title, description, feedPath? = "/feed.json", homePath? = "/" }, entries)` | The document. Item `id` and `url` are the canonical URL on `base`, never the request origin (CANON-001); `title` / `summary` are omitted, never `null`-valued; order is the caller's (newest-first). |
|
|
87
|
+
| `FEED_ITEM_LIMIT` (50) | The item bound: a change-discovery feed, not a corpus listing (DISC-010). |
|
|
88
|
+
| `jsonFeedResponse(feed, { cacheTag?, cacheControl? })` | 2-space pretty-printed `application/feed+json` with the cache headers. The indent is part of the bytes on the wire and in every cached copy. |
|
|
89
|
+
| `JsonFeed`, `JsonFeedItem`, `JSON_FEED_CONTENT_TYPE` | The shapes and the media type. |
|
|
90
|
+
|
|
91
|
+
### Cache-Tag purge on mutation
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import {
|
|
95
|
+
makePurgeTagsForResourceType,
|
|
96
|
+
purgeCacheTags,
|
|
97
|
+
type CachePurgeContext,
|
|
98
|
+
} from "@thomasfosterau/effect-sitemap";
|
|
99
|
+
|
|
100
|
+
// The resource types the sitemap and feed enumerate.
|
|
101
|
+
const purgeTagsForResourceType = makePurgeTagsForResourceType(["posts", "people"]);
|
|
102
|
+
|
|
103
|
+
// After a mutation to a resource of `type` (and after the response has gone out):
|
|
104
|
+
const purgeAfterMutation = (ctx: CachePurgeContext, type: string) =>
|
|
105
|
+
purgeCacheTags(ctx, purgeTagsForResourceType(type));
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
| Export | What it is |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| `FEED_CACHE_TAG` (`feed`), `SITEMAP_CACHE_TAG` (`sitemap`) | The tags the responses carry and the purge names. |
|
|
111
|
+
| `makePurgeTagsForResourceType(eligibleTypes, tags?)` | `(type) => tags`: both tags for a type the documents enumerate, none otherwise — decided from the type alone, no visibility check (a wasted purge over a missed one). |
|
|
112
|
+
| `CachePurgeContext` | The `{ cache?: { purge({ tags }) } }` subset of a Workers `ExecutionContext`; `cache` is absent in local dev. |
|
|
113
|
+
| `purgeCacheTags(ctx, tags)` | `Effect<void>`: a no-op for no tags or no `cache`; a logged `cache-purge.rejected` (`success: false`) or `cache-purge.failed` (rejection), never a failure — the mutation never sees a dropped purge. The purge half of PERF-CACHE-002. |
|
|
114
|
+
| `purgeCacheTagsPromise(ctx, tags)` | The same as a `Promise`, for `ctx.waitUntil`. |
|
|
115
|
+
| `LOW_CHURN_CACHE_CONTROL` | The document `Cache-Control` (an hour at the edge, a day stale-while-revalidate): the explicit edge TTL of PERF-CACHE-002. |
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT — see [LICENSE](LICENSE).
|
package/dist/index.d.ts
ADDED
|
@@ -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 & b < "c""
|
|
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 & b < "c""
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* @since 0.1.0
|
|
64
|
+
* @category rendering
|
|
65
|
+
*/
|
|
66
|
+
const escapeXml = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
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,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://www.schemastore.org/package",
|
|
2
3
|
"name": "@thomasfosterau/effect-sitemap",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
4
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
7
|
+
"effect",
|
|
8
|
+
"sitemap",
|
|
9
|
+
"sitemap-index"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/thomasfosterau/effect-web#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/thomasfosterau/effect-web/issues"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Thomas Foster <me@thomasfoster.au>",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/thomasfosterau/effect-web.git",
|
|
20
|
+
"directory": "packages/sitemap"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@thomasfosterau/effect-web": "^0.1.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@effect/language-service": "^0.87.4",
|
|
44
|
+
"@types/node": "^26.6.5",
|
|
45
|
+
"@vitest/coverage-istanbul": "4.1.11",
|
|
46
|
+
"effect": "4.0.3"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"effect": ">=4.0.0-rc.118"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=22"
|
|
53
|
+
}
|
|
6
54
|
}
|