@thomasfosterau/effect-sitemap 0.0.0-stage → 0.0.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/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/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.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
4
|
+
"version": "0.0.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.0.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@effect/language-service": "^0.87.4",
|
|
44
|
+
"@types/node": "^26.6.4",
|
|
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
|
}
|