@whatworks/payload-sitemap 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.md +21 -0
- package/README.md +201 -0
- package/dist/exports/next.d.ts +78 -0
- package/dist/exports/next.js +118 -0
- package/dist/exports/next.js.map +1 -0
- package/dist/index.d.ts +147 -0
- package/dist/index.js +281 -0
- package/dist/index.js.map +1 -0
- package/dist/types-j8T8euN5.d.ts +271 -0
- package/dist/xml-DSOM2moN.js +487 -0
- package/dist/xml-DSOM2moN.js.map +1 -0
- package/package.json +90 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 What Works Global
|
|
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
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# @whatworks/payload-sitemap
|
|
2
|
+
|
|
3
|
+
Chunked, lazily cached XML sitemaps for [Payload](https://payloadcms.com) with hook-driven invalidation and robots.txt helpers.
|
|
4
|
+
|
|
5
|
+
Next.js + Vercel work with near-zero config; every layer — delivery, caching, invalidation, robots output — is overridable for other stacks (decoupled frontends, self-hosted servers, SSG builds).
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
- **Lazy generation.** Nothing regenerates on save. Collection hooks only mark a group's cache dirty (deduped per request, run after the response so the transaction has committed). The next sitemap request regenerates that group from the database — slug changes, breadcrumb cascades (e.g. `plugin-nested-docs` re-saving children), publishes, unpublishes, and deletes all come out correct without any change detection.
|
|
10
|
+
- **One sitemap file per collection, chunked.** `/sitemap.xml` is a `<sitemapindex>` referencing `pages-1.xml`, `posts-1.xml`, … split at `chunkSize` (default 25,000; protocol limit 50,000).
|
|
11
|
+
- **Lean queries.** Docs are fetched in pages of 1,000 with `select`, `depth: 0`, and a `_status` filter applied only when the collection actually has drafts enabled.
|
|
12
|
+
- **Caching:** with `'auto'` (default), entries are cached in the Next.js Data Cache with tags (`revalidateTag` invalidation — works across serverless instances on Vercel) and fall back to an in-memory cache outside Next. Responses also carry `Cache-Control: s-maxage` so a CDN absorbs crawler traffic.
|
|
13
|
+
|
|
14
|
+
## Quick start (Payload inside Next.js)
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// payload.config.ts
|
|
18
|
+
import { sitemapPlugin } from '@whatworks/payload-sitemap'
|
|
19
|
+
|
|
20
|
+
export default buildConfig({
|
|
21
|
+
plugins: [
|
|
22
|
+
sitemapPlugin({
|
|
23
|
+
collections: {
|
|
24
|
+
pages: {
|
|
25
|
+
path: ({ doc }) => (doc.slug === 'home' ? '/' : `/${doc.slug}`),
|
|
26
|
+
select: { slug: true },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
],
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// app/sitemap.xml/route.ts
|
|
36
|
+
import config from '@payload-config'
|
|
37
|
+
import { createSitemapIndexRoute } from '@whatworks/payload-sitemap/next'
|
|
38
|
+
|
|
39
|
+
export const dynamic = 'force-dynamic'
|
|
40
|
+
export const { GET } = createSitemapIndexRoute({ config })
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// app/sitemaps/[sitemap]/route.ts
|
|
45
|
+
import config from '@payload-config'
|
|
46
|
+
import { createSitemapChunkRoute } from '@whatworks/payload-sitemap/next'
|
|
47
|
+
|
|
48
|
+
export const dynamic = 'force-dynamic'
|
|
49
|
+
export const { GET } = createSitemapChunkRoute({ config })
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// app/robots.ts
|
|
54
|
+
import config from '@payload-config'
|
|
55
|
+
import { createRobots } from '@whatworks/payload-sitemap/next'
|
|
56
|
+
|
|
57
|
+
export default createRobots({ config })
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This is the entire setup — no env vars required. `robots.txt` disallows everything outside production (`VERCEL_ENV`/`NODE_ENV`), and disallows the admin + API routes in production.
|
|
61
|
+
|
|
62
|
+
### How `siteUrl` is resolved
|
|
63
|
+
|
|
64
|
+
Every URL in the sitemap is joined onto the first available of:
|
|
65
|
+
|
|
66
|
+
1. the `siteUrl` plugin option — a string, or a function `({ request }) => string` with full control
|
|
67
|
+
2. `SITE_URL` → `NEXT_PUBLIC_SERVER_URL` → `https://$VERCEL_PROJECT_PRODUCTION_URL`
|
|
68
|
+
3. **the incoming request** — `x-forwarded-proto`/`x-forwarded-host`, then `Host`, then the request URL's origin
|
|
69
|
+
|
|
70
|
+
Env vars deliberately outrank the request: a deployment is often reachable via
|
|
71
|
+
non-canonical aliases (`*.vercel.app`, internal hostnames), and an SEO artifact should
|
|
72
|
+
emit the canonical domain when one is declared. Set `SITE_URL` (or the option) in
|
|
73
|
+
production if your app answers on more than one host; skip it entirely and the sitemap
|
|
74
|
+
simply mirrors whatever host it was requested on.
|
|
75
|
+
|
|
76
|
+
Host headers are client-controlled, so request-derived origins are never written to the
|
|
77
|
+
shared cache — entries are cached as site-relative paths and joined onto the resolved
|
|
78
|
+
origin per request. A forged `Host` can only distort the forger's own response.
|
|
79
|
+
|
|
80
|
+
## Plugin options
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
sitemapPlugin({
|
|
84
|
+
// Required. Per-collection config, typed against your generated types.
|
|
85
|
+
collections: {
|
|
86
|
+
pages: {
|
|
87
|
+
// Return a path (joined to siteUrl) or an absolute URL (used verbatim).
|
|
88
|
+
// Return null/undefined to omit the doc.
|
|
89
|
+
path: ({ doc }) => `/${doc.slug}`,
|
|
90
|
+
// Fields your path()/lastMod() need. id/updatedAt are always included.
|
|
91
|
+
select: { slug: true, breadcrumbs: true },
|
|
92
|
+
lastMod: 'publishedAt', // field name, (doc) => Date, or false. Default 'updatedAt'
|
|
93
|
+
where: {}, // extra query constraints
|
|
94
|
+
chunkSize: 10_000, // per-collection override
|
|
95
|
+
changeFreq: 'weekly', // opt-in; Google ignores it
|
|
96
|
+
priority: 0.5, // opt-in; Google ignores it
|
|
97
|
+
// Override the default invalidation heuristic (invalidate on any change
|
|
98
|
+
// except a draft save with no published transition):
|
|
99
|
+
shouldInvalidate: ({ doc, previousDoc, operation }) => true,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
// String, or a function with full control (multi-tenant, per-request logic).
|
|
104
|
+
// Default: env chain → incoming request (see “How siteUrl is resolved”).
|
|
105
|
+
siteUrl: ({ request }) => `https://${request.headers.get('host')}`,
|
|
106
|
+
trailingSlash: false,
|
|
107
|
+
chunkSize: 25_000,
|
|
108
|
+
|
|
109
|
+
// Extra routes: static array, or an async function (e.g. read a global).
|
|
110
|
+
routes: async ({ payload }) => [{ path: '/search' }],
|
|
111
|
+
|
|
112
|
+
// Injected admin fields. Default: an `excludeFromSitemap` sidebar checkbox.
|
|
113
|
+
// `group` nests them inside the group field (or named tab) with that name —
|
|
114
|
+
// e.g. an existing `metadata` group. Dot notation reaches nested containers
|
|
115
|
+
// (`'seo.metadata'`); missing segments are created as groups on collections
|
|
116
|
+
// that lack them. The exclude flag then lives at `metadata.excludeFromSitemap`.
|
|
117
|
+
adminFields: { exclude: true, group: 'metadata' },
|
|
118
|
+
|
|
119
|
+
// 'auto' (default) | 'memory' | 'none' | custom { wrap, invalidate } adapter.
|
|
120
|
+
cache: 'auto',
|
|
121
|
+
|
|
122
|
+
// REST endpoints under the API route. DISABLED by default — the Next route
|
|
123
|
+
// handlers above are the primary delivery.
|
|
124
|
+
endpoints: {
|
|
125
|
+
path: '/sitemap', // → /api/sitemap/index.xml, /api/sitemap/pages-1.xml
|
|
126
|
+
access: ({ req }) => true, // XML access; default public once enabled
|
|
127
|
+
json: true, // /api/sitemap/entries.json; default access: req.user
|
|
128
|
+
cacheControl: 'public, max-age=0, s-maxage=600, stale-while-revalidate=86400',
|
|
129
|
+
origin: 'https://cms.example.com', // index chunk-URL origin; default: request origin
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
// Defaults for robots output (createRobots / generateRobotsTxt).
|
|
133
|
+
robots: {
|
|
134
|
+
isProduction: undefined, // default VERCEL_ENV/NODE_ENV
|
|
135
|
+
disallow: ['/drafts/'], // appended to the default rule
|
|
136
|
+
rules: undefined, // replace default rules entirely
|
|
137
|
+
sitemaps: undefined, // default `${siteUrl}/sitemap.xml`
|
|
138
|
+
transform: (robots) => robots, // final say over the computed output
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
disabled: false, // keeps injected fields for schema/migration consistency
|
|
142
|
+
})
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Decoupled frontends & other stacks
|
|
146
|
+
|
|
147
|
+
Payload standalone as a headless CMS, frontend elsewhere (Astro, SvelteKit, …):
|
|
148
|
+
|
|
149
|
+
1. **Enable the REST endpoints** (`endpoints: true`). The sitemap serves from
|
|
150
|
+
`https://cms.example.com/api/sitemap/index.xml`.
|
|
151
|
+
2. Either **proxy** `/sitemap.xml` → the CMS endpoint from your frontend, or declare it
|
|
152
|
+
cross-host in the frontend's robots.txt (sanctioned by sitemaps.org):
|
|
153
|
+
`Sitemap: https://cms.example.com/api/sitemap/index.xml`
|
|
154
|
+
3. For **build-time SSG**, fetch `/api/sitemap/entries.json` (authenticated by default)
|
|
155
|
+
or call `getSitemapEntries(payload)` in a build script and render however you like.
|
|
156
|
+
4. `generateRobotsTxt(config, overrides?)` returns a robots.txt string for any server.
|
|
157
|
+
|
|
158
|
+
On a long-running self-hosted server, set `cache: 'memory'` — hooks invalidate it
|
|
159
|
+
in-process. For anything else, implement the two-method `SitemapCache` interface
|
|
160
|
+
(`wrap`, `invalidate`) — e.g. backed by Redis — and pass it as `cache`.
|
|
161
|
+
|
|
162
|
+
## Public API
|
|
163
|
+
|
|
164
|
+
| Export (`.`) | Purpose |
|
|
165
|
+
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
166
|
+
| `sitemapPlugin(config)` | The Payload plugin |
|
|
167
|
+
| `getSitemapEntries(payload, opts?)` | All cached entries keyed by group |
|
|
168
|
+
| `invalidateSitemap(payload, groups?)` | Manual invalidation (e.g. from a global's hook that feeds `routes`) |
|
|
169
|
+
| `generateRobotsTxt(config, overrides?)` | robots.txt string for any delivery |
|
|
170
|
+
| `getIndexItems` / `getChunkEntries` / `buildUrlsetXml` / `buildSitemapIndexXml` | Building blocks for custom delivery |
|
|
171
|
+
| `SITEMAP_CACHE_TAG` / `sitemapCacheTag(group)` | Next cache tags, for custom `revalidateTag` calls |
|
|
172
|
+
|
|
173
|
+
| Export (`./next`) | Purpose |
|
|
174
|
+
| ----------------------------------------------------------------- | --------------------------------------------------- |
|
|
175
|
+
| `createSitemapIndexRoute({ config, chunksPath?, cacheControl? })` | `GET` handler for `app/sitemap.xml/route.ts` |
|
|
176
|
+
| `createSitemapChunkRoute({ config, param?, cacheControl? })` | `GET` handler for `app/sitemaps/[sitemap]/route.ts` |
|
|
177
|
+
| `createRobots({ config, ...robotsOverrides })` | Default export for `app/robots.ts` |
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
`pnpm dev` (from `packages/sitemap`) boots the sandbox in `dev/` — a Next app with the
|
|
182
|
+
admin at [/admin](http://localhost:3000/admin) (login form comes prefilled) and a demo
|
|
183
|
+
page at [/](http://localhost:3000) listing every delivery surface plus the live cached
|
|
184
|
+
entries. It seeds a drafts-enabled `pages` collection, a draftless `legal` collection,
|
|
185
|
+
a draft-only doc and an `excludeFromSitemap` doc into a throwaway SQLite database
|
|
186
|
+
(`dev/.dbs/`, gitignored — delete it to reseed). Publish/delete docs in the admin and
|
|
187
|
+
reload to watch hook-driven invalidation land.
|
|
188
|
+
|
|
189
|
+
## Notes
|
|
190
|
+
|
|
191
|
+
- **`changefreq`/`priority` are opt-in** because Google ignores both; `lastmod` is the
|
|
192
|
+
only freshness signal it uses. Skipping them keeps the admin sidebar and XML lean.
|
|
193
|
+
- **Invalidation timing:** hooks schedule invalidation via Next's `after()` (post-response,
|
|
194
|
+
post-commit; uses `waitUntil` on Vercel) with a plain macrotask fallback outside Next.
|
|
195
|
+
`revalidateTag` is called with `{ expire: 0 }` for instant expiry on Next 16; Next 15
|
|
196
|
+
ignores the extra argument.
|
|
197
|
+
- **CDN staleness trade-off:** the default `s-maxage=600` means a CDN may serve a
|
|
198
|
+
sitemap up to 10 minutes stale after a publish — regeneration is cheap behind it, so
|
|
199
|
+
tune `cacheControl` if you need tighter freshness.
|
|
200
|
+
- Drafts are never included: the `_status` filter applies exactly when the collection
|
|
201
|
+
has drafts enabled, so collections without drafts also work (no `_status` query error).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { o as RobotsOptions } from "../types-j8T8euN5.js";
|
|
2
|
+
import { SanitizedConfig } from "payload";
|
|
3
|
+
import { MetadataRoute } from "next";
|
|
4
|
+
|
|
5
|
+
//#region src/exports/next.d.ts
|
|
6
|
+
type PayloadConfigInput = Promise<SanitizedConfig> | SanitizedConfig;
|
|
7
|
+
type RouteFactoryArgs = {
|
|
8
|
+
/** `Cache-Control` header for the response. */
|
|
9
|
+
cacheControl?: string;
|
|
10
|
+
config: PayloadConfigInput;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Route handler for the sitemap index.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* // app/sitemap.xml/route.ts
|
|
17
|
+
* import config from '@payload-config'
|
|
18
|
+
* import { createSitemapIndexRoute } from '@whatworks/payload-sitemap/next'
|
|
19
|
+
*
|
|
20
|
+
* export const dynamic = 'force-dynamic'
|
|
21
|
+
* export const { GET } = createSitemapIndexRoute({ config })
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
declare const createSitemapIndexRoute: ({
|
|
25
|
+
cacheControl,
|
|
26
|
+
chunksPath,
|
|
27
|
+
config
|
|
28
|
+
}: {
|
|
29
|
+
/** Public path prefix where the chunk route is mounted. @default '/sitemaps' */
|
|
30
|
+
chunksPath?: string;
|
|
31
|
+
} & RouteFactoryArgs) => {
|
|
32
|
+
GET: (request: Request) => Promise<Response>;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Route handler for individual sitemap chunk files.
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* // app/sitemaps/[sitemap]/route.ts
|
|
39
|
+
* import config from '@payload-config'
|
|
40
|
+
* import { createSitemapChunkRoute } from '@whatworks/payload-sitemap/next'
|
|
41
|
+
*
|
|
42
|
+
* export const dynamic = 'force-dynamic'
|
|
43
|
+
* export const { GET } = createSitemapChunkRoute({ config })
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
declare const createSitemapChunkRoute: ({
|
|
47
|
+
cacheControl,
|
|
48
|
+
config,
|
|
49
|
+
param
|
|
50
|
+
}: {
|
|
51
|
+
/** Name of the dynamic segment the route is mounted under. @default 'sitemap' */
|
|
52
|
+
param?: string;
|
|
53
|
+
} & RouteFactoryArgs) => {
|
|
54
|
+
GET: (request: Request, ctx: {
|
|
55
|
+
params: Promise<Record<string, string | string[] | undefined>>;
|
|
56
|
+
}) => Promise<Response>;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Default export for `app/robots.ts`. Plugin-level `robots` options apply first,
|
|
60
|
+
* factory overrides win, and `transform` gets the final say.
|
|
61
|
+
*
|
|
62
|
+
* ```ts
|
|
63
|
+
* // app/robots.ts
|
|
64
|
+
* import config from '@payload-config'
|
|
65
|
+
* import { createRobots } from '@whatworks/payload-sitemap/next'
|
|
66
|
+
*
|
|
67
|
+
* export default createRobots({ config })
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
declare const createRobots: ({
|
|
71
|
+
config,
|
|
72
|
+
...overrides
|
|
73
|
+
}: {
|
|
74
|
+
config: PayloadConfigInput;
|
|
75
|
+
} & RobotsOptions) => (() => Promise<MetadataRoute.Robots>);
|
|
76
|
+
//#endregion
|
|
77
|
+
export { createRobots, createSitemapChunkRoute, createSitemapIndexRoute };
|
|
78
|
+
//# sourceMappingURL=next.d.ts.map
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { _ as getSitemapConfig, c as getChunkEntries, d as finalizeEntries, h as DEFAULT_CACHE_CONTROL, i as buildRobotsData, l as getIndexItems, n as buildUrlsetXml, t as buildSitemapIndexXml } from "../xml-DSOM2moN.js";
|
|
2
|
+
import { getPayload } from "payload";
|
|
3
|
+
|
|
4
|
+
//#region src/exports/next.ts
|
|
5
|
+
const xmlResponse = (xml, cacheControl) => new Response(xml, { headers: {
|
|
6
|
+
"Cache-Control": cacheControl,
|
|
7
|
+
"Content-Type": "application/xml; charset=utf-8"
|
|
8
|
+
} });
|
|
9
|
+
/**
|
|
10
|
+
* Metadata routes receive no request object, so when the site origin isn't
|
|
11
|
+
* statically configured fall back to the request headers via `next/headers`
|
|
12
|
+
* (which makes the route dynamic).
|
|
13
|
+
*/
|
|
14
|
+
const resolveRobotsSiteUrl = async (sitemapConfig) => {
|
|
15
|
+
try {
|
|
16
|
+
return sitemapConfig.siteUrl();
|
|
17
|
+
} catch (staticError) {
|
|
18
|
+
try {
|
|
19
|
+
const { headers } = await import("next/headers");
|
|
20
|
+
return sitemapConfig.siteUrl({ request: { headers: await headers() } });
|
|
21
|
+
} catch {
|
|
22
|
+
throw staticError;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Route handler for the sitemap index.
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* // app/sitemap.xml/route.ts
|
|
31
|
+
* import config from '@payload-config'
|
|
32
|
+
* import { createSitemapIndexRoute } from '@whatworks/payload-sitemap/next'
|
|
33
|
+
*
|
|
34
|
+
* export const dynamic = 'force-dynamic'
|
|
35
|
+
* export const { GET } = createSitemapIndexRoute({ config })
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
const createSitemapIndexRoute = ({ cacheControl = DEFAULT_CACHE_CONTROL, chunksPath = "/sitemaps", config }) => ({ GET: async (request) => {
|
|
39
|
+
const payload = await getPayload({ config: await config });
|
|
40
|
+
const sitemapConfig = getSitemapConfig(payload.config);
|
|
41
|
+
const base = `${sitemapConfig.siteUrl({ request })}${chunksPath}`;
|
|
42
|
+
return xmlResponse(buildSitemapIndexXml(await getIndexItems({
|
|
43
|
+
chunkUrl: (file) => `${base}/${file}`,
|
|
44
|
+
config: sitemapConfig,
|
|
45
|
+
payload
|
|
46
|
+
})), cacheControl);
|
|
47
|
+
} });
|
|
48
|
+
/**
|
|
49
|
+
* Route handler for individual sitemap chunk files.
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* // app/sitemaps/[sitemap]/route.ts
|
|
53
|
+
* import config from '@payload-config'
|
|
54
|
+
* import { createSitemapChunkRoute } from '@whatworks/payload-sitemap/next'
|
|
55
|
+
*
|
|
56
|
+
* export const dynamic = 'force-dynamic'
|
|
57
|
+
* export const { GET } = createSitemapChunkRoute({ config })
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
const createSitemapChunkRoute = ({ cacheControl = DEFAULT_CACHE_CONTROL, config, param = "sitemap" }) => ({ GET: async (request, ctx) => {
|
|
61
|
+
const rawFile = (await ctx.params)[param];
|
|
62
|
+
const file = Array.isArray(rawFile) ? rawFile[0] : rawFile;
|
|
63
|
+
if (!file) return new Response("Not found", { status: 404 });
|
|
64
|
+
const payload = await getPayload({ config: await config });
|
|
65
|
+
const sitemapConfig = getSitemapConfig(payload.config);
|
|
66
|
+
const chunk = await getChunkEntries({
|
|
67
|
+
config: sitemapConfig,
|
|
68
|
+
file,
|
|
69
|
+
payload
|
|
70
|
+
});
|
|
71
|
+
if (!chunk) return new Response("Not found", { status: 404 });
|
|
72
|
+
const collConfig = sitemapConfig.collections[chunk.group];
|
|
73
|
+
return xmlResponse(buildUrlsetXml(finalizeEntries(chunk.entries, {
|
|
74
|
+
siteUrl: sitemapConfig.siteUrl({ request }),
|
|
75
|
+
trailingSlash: sitemapConfig.trailingSlash
|
|
76
|
+
}), {
|
|
77
|
+
changeFreq: collConfig?.changeFreq,
|
|
78
|
+
priority: collConfig?.priority
|
|
79
|
+
}), cacheControl);
|
|
80
|
+
} });
|
|
81
|
+
/**
|
|
82
|
+
* Default export for `app/robots.ts`. Plugin-level `robots` options apply first,
|
|
83
|
+
* factory overrides win, and `transform` gets the final say.
|
|
84
|
+
*
|
|
85
|
+
* ```ts
|
|
86
|
+
* // app/robots.ts
|
|
87
|
+
* import config from '@payload-config'
|
|
88
|
+
* import { createRobots } from '@whatworks/payload-sitemap/next'
|
|
89
|
+
*
|
|
90
|
+
* export default createRobots({ config })
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
const createRobots = ({ config, ...overrides }) => {
|
|
94
|
+
return async () => {
|
|
95
|
+
const awaited = await config;
|
|
96
|
+
const sitemapConfig = getSitemapConfig(awaited);
|
|
97
|
+
const options = {
|
|
98
|
+
...sitemapConfig.robots,
|
|
99
|
+
...overrides
|
|
100
|
+
};
|
|
101
|
+
const sitemaps = options.sitemaps ?? [`${await resolveRobotsSiteUrl(sitemapConfig)}/sitemap.xml`];
|
|
102
|
+
const data = buildRobotsData({
|
|
103
|
+
adminRoute: awaited.routes.admin,
|
|
104
|
+
apiRoute: awaited.routes.api,
|
|
105
|
+
options,
|
|
106
|
+
sitemaps
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
rules: data.rules,
|
|
110
|
+
...data.sitemaps.length ? { sitemap: data.sitemaps.length === 1 ? data.sitemaps[0] : data.sitemaps } : {},
|
|
111
|
+
...data.host ? { host: data.host } : {}
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
export { createRobots, createSitemapChunkRoute, createSitemapIndexRoute };
|
|
118
|
+
//# sourceMappingURL=next.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"next.js","names":[],"sources":["../../src/exports/next.ts"],"sourcesContent":["import type { MetadataRoute } from 'next'\nimport type { SanitizedConfig } from 'payload'\n\nimport { getPayload } from 'payload'\n\nimport type { RobotsOptions } from '../types.js'\n\nimport { getChunkEntries, getIndexItems } from '../core/chunks.js'\nimport { finalizeEntries } from '../core/entries.js'\nimport { DEFAULT_CACHE_CONTROL, getSitemapConfig } from '../core/resolved.js'\nimport { buildRobotsData } from '../core/robots.js'\nimport { buildSitemapIndexXml, buildUrlsetXml } from '../core/xml.js'\n\ntype PayloadConfigInput = Promise<SanitizedConfig> | SanitizedConfig\n\ntype RouteFactoryArgs = {\n /** `Cache-Control` header for the response. */\n cacheControl?: string\n config: PayloadConfigInput\n}\n\nconst xmlResponse = (xml: string, cacheControl: string): Response =>\n new Response(xml, {\n headers: {\n 'Cache-Control': cacheControl,\n 'Content-Type': 'application/xml; charset=utf-8',\n },\n })\n\n/**\n * Metadata routes receive no request object, so when the site origin isn't\n * statically configured fall back to the request headers via `next/headers`\n * (which makes the route dynamic).\n */\nconst resolveRobotsSiteUrl = async (\n sitemapConfig: ReturnType<typeof getSitemapConfig>,\n): Promise<string> => {\n try {\n return sitemapConfig.siteUrl()\n } catch (staticError) {\n try {\n const { headers } = await import('next/headers')\n return sitemapConfig.siteUrl({ request: { headers: await headers() } })\n } catch {\n throw staticError\n }\n }\n}\n\n/**\n * Route handler for the sitemap index.\n *\n * ```ts\n * // app/sitemap.xml/route.ts\n * import config from '@payload-config'\n * import { createSitemapIndexRoute } from '@whatworks/payload-sitemap/next'\n *\n * export const dynamic = 'force-dynamic'\n * export const { GET } = createSitemapIndexRoute({ config })\n * ```\n */\nexport const createSitemapIndexRoute = ({\n cacheControl = DEFAULT_CACHE_CONTROL,\n chunksPath = '/sitemaps',\n config,\n}: {\n /** Public path prefix where the chunk route is mounted. @default '/sitemaps' */\n chunksPath?: string\n} & RouteFactoryArgs): { GET: (request: Request) => Promise<Response> } => ({\n GET: async (request) => {\n const payload = await getPayload({ config: await config })\n const sitemapConfig = getSitemapConfig(payload.config)\n const base = `${sitemapConfig.siteUrl({ request })}${chunksPath}`\n const items = await getIndexItems({\n chunkUrl: (file) => `${base}/${file}`,\n config: sitemapConfig,\n payload,\n })\n return xmlResponse(buildSitemapIndexXml(items), cacheControl)\n },\n})\n\n/**\n * Route handler for individual sitemap chunk files.\n *\n * ```ts\n * // app/sitemaps/[sitemap]/route.ts\n * import config from '@payload-config'\n * import { createSitemapChunkRoute } from '@whatworks/payload-sitemap/next'\n *\n * export const dynamic = 'force-dynamic'\n * export const { GET } = createSitemapChunkRoute({ config })\n * ```\n */\nexport const createSitemapChunkRoute = ({\n cacheControl = DEFAULT_CACHE_CONTROL,\n config,\n param = 'sitemap',\n}: {\n /** Name of the dynamic segment the route is mounted under. @default 'sitemap' */\n param?: string\n} & RouteFactoryArgs): {\n GET: (\n request: Request,\n ctx: { params: Promise<Record<string, string | string[] | undefined>> },\n ) => Promise<Response>\n} => ({\n GET: async (request, ctx) => {\n const params = await ctx.params\n const rawFile = params[param]\n const file = Array.isArray(rawFile) ? rawFile[0] : rawFile\n if (!file) {\n return new Response('Not found', { status: 404 })\n }\n\n const payload = await getPayload({ config: await config })\n const sitemapConfig = getSitemapConfig(payload.config)\n const chunk = await getChunkEntries({ config: sitemapConfig, file, payload })\n if (!chunk) {\n return new Response('Not found', { status: 404 })\n }\n\n const collConfig = sitemapConfig.collections[chunk.group]\n const entries = finalizeEntries(chunk.entries, {\n siteUrl: sitemapConfig.siteUrl({ request }),\n trailingSlash: sitemapConfig.trailingSlash,\n })\n const xml = buildUrlsetXml(entries, {\n changeFreq: collConfig?.changeFreq,\n priority: collConfig?.priority,\n })\n return xmlResponse(xml, cacheControl)\n },\n})\n\n/**\n * Default export for `app/robots.ts`. Plugin-level `robots` options apply first,\n * factory overrides win, and `transform` gets the final say.\n *\n * ```ts\n * // app/robots.ts\n * import config from '@payload-config'\n * import { createRobots } from '@whatworks/payload-sitemap/next'\n *\n * export default createRobots({ config })\n * ```\n */\nexport const createRobots = ({\n config,\n ...overrides\n}: { config: PayloadConfigInput } & RobotsOptions): (() => Promise<MetadataRoute.Robots>) => {\n return async () => {\n const awaited = await config\n const sitemapConfig = getSitemapConfig(awaited)\n const options = { ...sitemapConfig.robots, ...overrides }\n const sitemaps = options.sitemaps ?? [\n `${await resolveRobotsSiteUrl(sitemapConfig)}/sitemap.xml`,\n ]\n\n const data = buildRobotsData({\n adminRoute: awaited.routes.admin,\n apiRoute: awaited.routes.api,\n options,\n sitemaps,\n })\n\n return {\n rules: data.rules,\n ...(data.sitemaps.length\n ? { sitemap: data.sitemaps.length === 1 ? data.sitemaps[0] : data.sitemaps }\n : {}),\n ...(data.host ? { host: data.host } : {}),\n }\n }\n}\n"],"mappings":";;;;AAqBA,MAAM,eAAe,KAAa,iBAChC,IAAI,SAAS,KAAK,EAChB,SAAS;CACP,iBAAiB;CACjB,gBAAgB;CACjB,EACF,CAAC;;;;;;AAOJ,MAAM,uBAAuB,OAC3B,kBACoB;AACpB,KAAI;AACF,SAAO,cAAc,SAAS;UACvB,aAAa;AACpB,MAAI;GACF,MAAM,EAAE,YAAY,MAAM,OAAO;AACjC,UAAO,cAAc,QAAQ,EAAE,SAAS,EAAE,SAAS,MAAM,SAAS,EAAE,EAAE,CAAC;UACjE;AACN,SAAM;;;;;;;;;;;;;;;;AAiBZ,MAAa,2BAA2B,EACtC,eAAe,uBACf,aAAa,aACb,cAI0E,EAC1E,KAAK,OAAO,YAAY;CACtB,MAAM,UAAU,MAAM,WAAW,EAAE,QAAQ,MAAM,QAAQ,CAAC;CAC1D,MAAM,gBAAgB,iBAAiB,QAAQ,OAAO;CACtD,MAAM,OAAO,GAAG,cAAc,QAAQ,EAAE,SAAS,CAAC,GAAG;AAMrD,QAAO,YAAY,qBALL,MAAM,cAAc;EAChC,WAAW,SAAS,GAAG,KAAK,GAAG;EAC/B,QAAQ;EACR;EACD,CAAC,CAC4C,EAAE,aAAa;GAEhE;;;;;;;;;;;;;AAcD,MAAa,2BAA2B,EACtC,eAAe,uBACf,QACA,QAAQ,iBASJ,EACJ,KAAK,OAAO,SAAS,QAAQ;CAE3B,MAAM,WADS,MAAM,IAAI,QACF;CACvB,MAAM,OAAO,MAAM,QAAQ,QAAQ,GAAG,QAAQ,KAAK;AACnD,KAAI,CAAC,KACH,QAAO,IAAI,SAAS,aAAa,EAAE,QAAQ,KAAK,CAAC;CAGnD,MAAM,UAAU,MAAM,WAAW,EAAE,QAAQ,MAAM,QAAQ,CAAC;CAC1D,MAAM,gBAAgB,iBAAiB,QAAQ,OAAO;CACtD,MAAM,QAAQ,MAAM,gBAAgB;EAAE,QAAQ;EAAe;EAAM;EAAS,CAAC;AAC7E,KAAI,CAAC,MACH,QAAO,IAAI,SAAS,aAAa,EAAE,QAAQ,KAAK,CAAC;CAGnD,MAAM,aAAa,cAAc,YAAY,MAAM;AASnD,QAAO,YAJK,eAJI,gBAAgB,MAAM,SAAS;EAC7C,SAAS,cAAc,QAAQ,EAAE,SAAS,CAAC;EAC3C,eAAe,cAAc;EAC9B,CAAC,EACkC;EAClC,YAAY,YAAY;EACxB,UAAU,YAAY;EACvB,CAAC,EACsB,aAAa;GAExC;;;;;;;;;;;;;AAcD,MAAa,gBAAgB,EAC3B,QACA,GAAG,gBACwF;AAC3F,QAAO,YAAY;EACjB,MAAM,UAAU,MAAM;EACtB,MAAM,gBAAgB,iBAAiB,QAAQ;EAC/C,MAAM,UAAU;GAAE,GAAG,cAAc;GAAQ,GAAG;GAAW;EACzD,MAAM,WAAW,QAAQ,YAAY,CACnC,GAAG,MAAM,qBAAqB,cAAc,CAAC,cAC9C;EAED,MAAM,OAAO,gBAAgB;GAC3B,YAAY,QAAQ,OAAO;GAC3B,UAAU,QAAQ,OAAO;GACzB;GACA;GACD,CAAC;AAEF,SAAO;GACL,OAAO,KAAK;GACZ,GAAI,KAAK,SAAS,SACd,EAAE,SAAS,KAAK,SAAS,WAAW,IAAI,KAAK,SAAS,KAAK,KAAK,UAAU,GAC1E,EAAE;GACN,GAAI,KAAK,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,EAAE;GACzC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { _ as SitemapRoute, a as RobotsData, b as resolveSiteUrl, c as SitemapCache, d as SitemapEndpointAccess, f as SitemapEndpointsConfig, g as SitemapPluginConfig, h as SitemapPathArgs, i as ResolvedSitemapEndpoints, l as SitemapCollectionConfig, m as SitemapInvalidateArgs, n as InternalSitemapCollectionConfig, o as RobotsOptions, p as SitemapEntry, r as ResolvedSitemapConfig, s as RobotsRule, t as ChangeFrequency, u as SitemapCollections, v as SitemapRoutesFn, x as siteUrlFromRequest, y as SiteUrlContext } from "./types-j8T8euN5.js";
|
|
2
|
+
import { Payload, PayloadRequest, Plugin, SanitizedConfig } from "payload";
|
|
3
|
+
|
|
4
|
+
//#region src/core/cache.d.ts
|
|
5
|
+
declare const SITEMAP_CACHE_TAG = "payload-sitemap";
|
|
6
|
+
declare const sitemapCacheTag: (group: string) => string;
|
|
7
|
+
declare const createMemoryCache: () => SitemapCache;
|
|
8
|
+
declare const noopCache: SitemapCache;
|
|
9
|
+
/**
|
|
10
|
+
* Caches entries in the Next.js Data Cache tagged `payload-sitemap:<group>`, so
|
|
11
|
+
* invalidation is `revalidateTag` — shared across serverless instances on Vercel.
|
|
12
|
+
* Degrades to uncached execution when `next/cache` is unavailable or errors
|
|
13
|
+
* (standalone Payload, seed scripts, non-request contexts).
|
|
14
|
+
*/
|
|
15
|
+
declare const createNextTagsCache: () => SitemapCache;
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/core/chunks.d.ts
|
|
18
|
+
declare const chunkFileName: (group: string, index: number) => string;
|
|
19
|
+
/**
|
|
20
|
+
* Matches a chunk filename against the known groups by prefix, so group slugs
|
|
21
|
+
* containing hyphens or digits parse unambiguously.
|
|
22
|
+
*/
|
|
23
|
+
declare const matchChunkFile: (file: string, groups: string[]) => {
|
|
24
|
+
group: string;
|
|
25
|
+
index: number;
|
|
26
|
+
} | null;
|
|
27
|
+
type BaseArgs = {
|
|
28
|
+
config: ResolvedSitemapConfig;
|
|
29
|
+
payload: Payload;
|
|
30
|
+
req?: PayloadRequest;
|
|
31
|
+
};
|
|
32
|
+
/** Items for the `<sitemapindex>`: one per chunk of each non-empty group. */
|
|
33
|
+
declare const getIndexItems: (args: {
|
|
34
|
+
chunkUrl: (file: string) => string;
|
|
35
|
+
} & BaseArgs) => Promise<Array<{
|
|
36
|
+
lastmod?: string;
|
|
37
|
+
loc: string;
|
|
38
|
+
}>>;
|
|
39
|
+
/**
|
|
40
|
+
* Entries for one chunk file, or `null` when the filename matches no group/range.
|
|
41
|
+
* `loc` values are site-relative — pass through `finalizeEntries` before rendering.
|
|
42
|
+
*/
|
|
43
|
+
declare const getChunkEntries: (args: {
|
|
44
|
+
file: string;
|
|
45
|
+
} & BaseArgs) => Promise<{
|
|
46
|
+
entries: SitemapEntry[];
|
|
47
|
+
group: string;
|
|
48
|
+
} | null>;
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/core/entries.d.ts
|
|
51
|
+
declare const formatLoc: (path: string, siteUrl: string, trailingSlash: boolean) => string;
|
|
52
|
+
/**
|
|
53
|
+
* Joins cached site-relative entries onto the resolved site origin. Entries are
|
|
54
|
+
* cached with relative `loc` paths so cached data is host-independent — a
|
|
55
|
+
* request-derived siteUrl can never leak into the shared cache.
|
|
56
|
+
*/
|
|
57
|
+
declare const finalizeEntries: (entries: SitemapEntry[], {
|
|
58
|
+
siteUrl,
|
|
59
|
+
trailingSlash
|
|
60
|
+
}: {
|
|
61
|
+
siteUrl: string;
|
|
62
|
+
trailingSlash: boolean;
|
|
63
|
+
}) => SitemapEntry[];
|
|
64
|
+
type FetchArgs = {
|
|
65
|
+
config: ResolvedSitemapConfig;
|
|
66
|
+
payload: Payload;
|
|
67
|
+
req?: PayloadRequest;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Cached entries for one group (a collection slug or `ROUTES_GROUP`).
|
|
71
|
+
* `loc` values are site-relative — pass through `finalizeEntries` before rendering.
|
|
72
|
+
*/
|
|
73
|
+
declare const getGroupEntries: (args: {
|
|
74
|
+
group: string;
|
|
75
|
+
} & FetchArgs) => Promise<SitemapEntry[]>;
|
|
76
|
+
/**
|
|
77
|
+
* All sitemap entries with absolute URLs, keyed by group. Public API for SSG
|
|
78
|
+
* frontends and custom delivery — reads the plugin config from the Payload
|
|
79
|
+
* instance. Pass `req` (or `request` — a Fetch Request, or
|
|
80
|
+
* `{ headers: await headers() }` in an RSC) so the site origin can be derived
|
|
81
|
+
* from the request when it isn't configured statically.
|
|
82
|
+
*/
|
|
83
|
+
declare const getSitemapEntries: (payload: Payload, options?: {
|
|
84
|
+
groups?: string[];
|
|
85
|
+
req?: PayloadRequest;
|
|
86
|
+
request?: {
|
|
87
|
+
headers?: Headers;
|
|
88
|
+
url?: null | string;
|
|
89
|
+
};
|
|
90
|
+
}) => Promise<Record<string, SitemapEntry[]>>;
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/core/invalidate.d.ts
|
|
93
|
+
/**
|
|
94
|
+
* Manually invalidate cached sitemap groups — e.g. from a hook on a global that
|
|
95
|
+
* feeds the `routes` option. Invalidates every group when none are given.
|
|
96
|
+
*/
|
|
97
|
+
declare const invalidateSitemap: (payload: Payload, groups?: string[]) => Promise<void>;
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/core/resolved.d.ts
|
|
100
|
+
/** Reserved group name for entries from the `routes` option. */
|
|
101
|
+
declare const ROUTES_GROUP = "_routes";
|
|
102
|
+
/** Reads the resolved plugin config stashed on the Payload config by `sitemapPlugin`. */
|
|
103
|
+
declare const getSitemapConfig: (config: SanitizedConfig) => ResolvedSitemapConfig;
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/core/robots.d.ts
|
|
106
|
+
type BuildRobotsArgs = {
|
|
107
|
+
/** Payload admin route, used for the default disallow list. @default '/admin' */
|
|
108
|
+
adminRoute?: string;
|
|
109
|
+
/** Payload API route, used for the default disallow list. @default '/api' */
|
|
110
|
+
apiRoute?: string;
|
|
111
|
+
options?: RobotsOptions;
|
|
112
|
+
/** Absolute sitemap URL(s) advertised in the output. */
|
|
113
|
+
sitemaps: string[];
|
|
114
|
+
};
|
|
115
|
+
declare const buildRobotsData: ({
|
|
116
|
+
adminRoute,
|
|
117
|
+
apiRoute,
|
|
118
|
+
options,
|
|
119
|
+
sitemaps
|
|
120
|
+
}: BuildRobotsArgs) => RobotsData;
|
|
121
|
+
declare const renderRobotsTxt: (data: RobotsData) => string;
|
|
122
|
+
/**
|
|
123
|
+
* robots.txt for any delivery mechanism. Defaults come from the plugin config;
|
|
124
|
+
* `overrides` win field-by-field, and `transform` gets the final say. Pass
|
|
125
|
+
* `request` so the default sitemap URL can derive its origin from the incoming
|
|
126
|
+
* request when `siteUrl` isn't configured statically.
|
|
127
|
+
*/
|
|
128
|
+
declare const generateRobotsTxt: (configInput: Promise<SanitizedConfig> | SanitizedConfig, overrides?: {
|
|
129
|
+
request?: SiteUrlContext["request"];
|
|
130
|
+
} & RobotsOptions) => Promise<string>;
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/core/xml.d.ts
|
|
133
|
+
declare const escapeXml: (value: string) => string;
|
|
134
|
+
declare const buildUrlsetXml: (entries: SitemapEntry[], defaults?: {
|
|
135
|
+
changeFreq?: ChangeFrequency;
|
|
136
|
+
priority?: number;
|
|
137
|
+
}) => string;
|
|
138
|
+
declare const buildSitemapIndexXml: (items: Array<{
|
|
139
|
+
lastmod?: string;
|
|
140
|
+
loc: string;
|
|
141
|
+
}>) => string;
|
|
142
|
+
//#endregion
|
|
143
|
+
//#region src/plugin.d.ts
|
|
144
|
+
declare const sitemapPlugin: (pluginConfig: SitemapPluginConfig) => Plugin;
|
|
145
|
+
//#endregion
|
|
146
|
+
export { ChangeFrequency, InternalSitemapCollectionConfig, ROUTES_GROUP, ResolvedSitemapConfig, ResolvedSitemapEndpoints, RobotsData, RobotsOptions, RobotsRule, SITEMAP_CACHE_TAG, type SiteUrlContext, SitemapCache, SitemapCollectionConfig, SitemapCollections, SitemapEndpointAccess, SitemapEndpointsConfig, SitemapEntry, SitemapInvalidateArgs, SitemapPathArgs, SitemapPluginConfig, SitemapRoute, SitemapRoutesFn, buildRobotsData, buildSitemapIndexXml, buildUrlsetXml, chunkFileName, createMemoryCache, createNextTagsCache, escapeXml, finalizeEntries, formatLoc, generateRobotsTxt, getChunkEntries, getGroupEntries, getIndexItems, getSitemapConfig, getSitemapEntries, invalidateSitemap, matchChunkFile, noopCache, renderRobotsTxt, resolveSiteUrl, siteUrlFromRequest, sitemapCacheTag, sitemapPlugin };
|
|
147
|
+
//# sourceMappingURL=index.d.ts.map
|