@warlock.js/sitemap 5.15.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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/cjs/index.cjs +279 -0
- package/cjs/index.cjs.map +1 -0
- package/esm/collect-entries.d.mts +44 -0
- package/esm/collect-entries.mjs +73 -0
- package/esm/collect-entries.mjs.map +1 -0
- package/esm/diagnostic.d.mts +13 -0
- package/esm/diagnostic.mjs +19 -0
- package/esm/diagnostic.mjs.map +1 -0
- package/esm/index.d.mts +8 -0
- package/esm/index.mjs +7 -0
- package/esm/routable-page.d.mts +27 -0
- package/esm/sitemap-connector.d.mts +60 -0
- package/esm/sitemap-connector.mjs +117 -0
- package/esm/sitemap-connector.mjs.map +1 -0
- package/esm/types.d.mts +33 -0
- package/esm/url.d.mts +29 -0
- package/esm/url.mjs +35 -0
- package/esm/url.mjs.map +1 -0
- package/esm/xml.d.mts +14 -0
- package/esm/xml.mjs +35 -0
- package/esm/xml.mjs.map +1 -0
- package/llms-full.txt +204 -0
- package/llms.txt +9 -0
- package/package.json +39 -0
- package/skills/sitemap-overview/SKILL.md +194 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog — @warlock.js/sitemap
|
|
2
|
+
|
|
3
|
+
All notable changes to `@warlock.js/sitemap` are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
|
|
6
|
+
|
|
7
|
+
## 5.15.0 - 2026-09-18
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- New package: runtime `sitemap.xml` generation. Walks the page registry at runtime, applies the framework's exclusion rules (not-found route, error page, `metadata.robots: noindex`, `sitemap: false`), collects the entries a page's `sitemap` export returns, and reports — in development — any dynamic route left with no `sitemap` export so it is never silently dropped from the generated XML.
|
|
12
|
+
- Zero runtime dependencies, in the same spirit as `@warlock.js/fs`: XML serialization is string-building plus escaping, and needs no library.
|
|
13
|
+
- Usable in three ways: standalone in any Node app (`collectSitemapEntries` + `buildSitemapXml`, no Warlock at all), in an API-only Warlock app via `sitemapConnector({ entries })`, and in a Warlock web app from the page registry. `@warlock.js/core` and `@warlock.js/web` are **optional** peers — everything except `sitemapConnector()` imports nothing from either, and the connector reaches them only through a lazy `import()`.
|
|
14
|
+
- `sitemapConnector({ entries })` merges app-supplied entries with page-derived ones, deduplicated by path with the app-supplied entry winning. With neither source available the connector refuses to boot (`NoPageRegistryError`) instead of serving an empty `<urlset>` that looks correct.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Hassan Zohdy
|
|
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,185 @@
|
|
|
1
|
+
# @warlock.js/sitemap
|
|
2
|
+
|
|
3
|
+
A `sitemap.xml` generator: exclusion rules (`noindex`, `sitemap: false`),
|
|
4
|
+
per-entry `changefreq`/`priority` defaults, and a loud diagnostic when a
|
|
5
|
+
dynamic route can't be enumerated. `@warlock.js/core` and `@warlock.js/web`
|
|
6
|
+
are **optional peers** — everything except `sitemapConnector()` imports
|
|
7
|
+
nothing from either, and `sitemapConnector()` itself only reaches them
|
|
8
|
+
through a lazy `import()` at boot. Three ways to use it, below.
|
|
9
|
+
|
|
10
|
+
Generation only in this release. No remote sitemap parser — emitting XML
|
|
11
|
+
needs no dependency; parsing one does, and nothing needs it yet.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @warlock.js/sitemap
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Mode 1 — Standalone, any Node app
|
|
20
|
+
|
|
21
|
+
No Warlock at all. Build the `RoutablePage[]` array yourself — from a route
|
|
22
|
+
table, a database, wherever your app already knows its own URLs — and call
|
|
23
|
+
`collectSitemapEntries` + `buildSitemapXml` directly. Nothing on this path
|
|
24
|
+
resolves `@warlock.js/core` or `@warlock.js/web`.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import express from "express";
|
|
28
|
+
import { buildSitemapXml, collectSitemapEntries, type RoutablePage } from "@warlock.js/sitemap";
|
|
29
|
+
|
|
30
|
+
const app = express();
|
|
31
|
+
|
|
32
|
+
app.get("/sitemap.xml", async (_req, res) => {
|
|
33
|
+
const pages: RoutablePage[] = [
|
|
34
|
+
{ routeName: "home", routePath: "/" },
|
|
35
|
+
{ routeName: "about", routePath: "/about" },
|
|
36
|
+
{
|
|
37
|
+
routeName: "post-details",
|
|
38
|
+
routePath: "/posts/:id",
|
|
39
|
+
sitemap: async () => (await db.posts.find()).map((post) => ({ path: `/posts/${post.slug}` })),
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const { entries } = await collectSitemapEntries(pages, {
|
|
44
|
+
defaults: { changefreq: "weekly", priority: 0.5 },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const xml = buildSitemapXml(entries, "https://example.com");
|
|
48
|
+
|
|
49
|
+
res.type("application/xml").send(xml);
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Mode 2 — Warlock, API-only (no `@warlock.js/web`)
|
|
54
|
+
|
|
55
|
+
An API-only Warlock app has no page registry — `listRoutablePages()` would
|
|
56
|
+
have nothing to list. Pass `entries` and `sitemapConnector()` uses it instead;
|
|
57
|
+
`@warlock.js/web` is not required on this path.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// warlock.config.ts
|
|
61
|
+
import { sitemapConnector } from "@warlock.js/sitemap";
|
|
62
|
+
|
|
63
|
+
export default defineConfig({
|
|
64
|
+
connectors: [
|
|
65
|
+
sitemapConnector({
|
|
66
|
+
entries: async () => {
|
|
67
|
+
const products = await db.products.find();
|
|
68
|
+
|
|
69
|
+
return products.map((product) => ({ path: `/products/${product.slug}` }));
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
If neither `entries` nor `@warlock.js/web` is available, the connector
|
|
77
|
+
refuses to boot rather than serving an empty `<urlset>` — see
|
|
78
|
+
[`NoPageRegistryError`](#no-page-registry-no-entries) below.
|
|
79
|
+
|
|
80
|
+
## Mode 3 — Warlock web
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
warlock add sitemap
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
writes `src/config/sitemap.ts`:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
export const sitemapConfig: SitemapConfig = {
|
|
90
|
+
enabled: true,
|
|
91
|
+
path: "/sitemap.xml",
|
|
92
|
+
defaults: { changefreq: "weekly", priority: 0.5 },
|
|
93
|
+
};
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`sitemapConnector()` reads the page registry from `@warlock.js/web`'s
|
|
97
|
+
`listRoutablePages()` on every request — not once at boot — so it reflects
|
|
98
|
+
the app's current shape under `warlock dev` too.
|
|
99
|
+
|
|
100
|
+
Only a dynamic route needs a page-level `sitemap` export; a static route is
|
|
101
|
+
included automatically at its own path.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// any *.page.tsx
|
|
105
|
+
export const sitemap: SitemapEntries = async () => [
|
|
106
|
+
{ path: "/posts/hello-world", lastmod: "2026-09-17", priority: 0.8 },
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
// or, to keep a page out of the sitemap deliberately
|
|
110
|
+
export const sitemap = false;
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`changefreq` and `priority` are per-entry and optional — they fall back to
|
|
114
|
+
the config's `defaults`. They are not part of `PageMetadata`; they mean
|
|
115
|
+
nothing outside a sitemap.
|
|
116
|
+
|
|
117
|
+
**A dynamic route with no `sitemap` export is silently omitted from the
|
|
118
|
+
generated XML if nobody is watching.** In development this package reports it
|
|
119
|
+
instead: a dynamic route cannot be enumerated without application data, and
|
|
120
|
+
the framework's whole job here is to make sure you find out, rather than
|
|
121
|
+
shipping a sitemap that looks complete while it quietly drops every product
|
|
122
|
+
page on the site.
|
|
123
|
+
|
|
124
|
+
If a project has both a page registry **and** `entries`, they are combined —
|
|
125
|
+
`entries` are added to the page-derived entries, not a replacement for them.
|
|
126
|
+
Where the same `path` appears in both, the `entries` version wins.
|
|
127
|
+
|
|
128
|
+
## Config reference (`src/config/sitemap.ts`)
|
|
129
|
+
|
|
130
|
+
| key | meaning |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| `enabled` | no-op when not `true` — the connector registers no route |
|
|
133
|
+
| `path` | defaults to `/sitemap.xml` |
|
|
134
|
+
| `defaults.changefreq` / `defaults.priority` | applied to any entry that omits them, page-derived or app-supplied |
|
|
135
|
+
|
|
136
|
+
The public origin the sitemap is served from is **not** configured here — it
|
|
137
|
+
lives in `app.publicUrl` (or the `PUBLIC_APP_URL` environment variable), one
|
|
138
|
+
level up in `@warlock.js/core`, because canonical links, OG tags and absolute
|
|
139
|
+
mail URLs need the same value. If the sitemap is `enabled` and no origin is
|
|
140
|
+
configured, the app refuses to boot rather than guess — a sitemap served with
|
|
141
|
+
the wrong host is worse than one that never started.
|
|
142
|
+
|
|
143
|
+
## What goes in the sitemap
|
|
144
|
+
|
|
145
|
+
| case | behaviour |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| static route | included |
|
|
148
|
+
| not-found route | excluded |
|
|
149
|
+
| error page | excluded — it isn't a routable page at all |
|
|
150
|
+
| page whose `metadata.robots` says `noindex` | excluded |
|
|
151
|
+
| page exporting `sitemap: false` | excluded |
|
|
152
|
+
| dynamic route (`[id]`, `[...slug]`) **with** a `sitemap` export | the entries that export returns |
|
|
153
|
+
| dynamic route **without** a `sitemap` export | **omitted, and named in a dev-mode diagnostic** |
|
|
154
|
+
| `sitemapConnector({ entries })` result | added to the above, `entries` wins on a `path` collision |
|
|
155
|
+
|
|
156
|
+
## No page registry, no `entries`
|
|
157
|
+
|
|
158
|
+
If `@warlock.js/web` is not installed and no `entries` option is supplied,
|
|
159
|
+
`sitemapConnector()` throws `NoPageRegistryError` at `boot()` — before the
|
|
160
|
+
route is even registered — rather than serving an empty sitemap that looks
|
|
161
|
+
correct. Fix it either way:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
Sitemap is enabled but has no source of entries: `@warlock.js/web` is not installed,
|
|
165
|
+
so there is no page registry to read, and no `entries` option was supplied either.
|
|
166
|
+
Fix this by installing `@warlock.js/web`, or by passing
|
|
167
|
+
`sitemapConnector({ entries: async () => [...] })` with your own supplier.
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Full documentation
|
|
171
|
+
|
|
172
|
+
The complete guide lives at
|
|
173
|
+
**[warlock.js.org](https://warlock.js.org/v/latest/sitemap/)**.
|
|
174
|
+
|
|
175
|
+
## Tests
|
|
176
|
+
|
|
177
|
+
This package uses Vitest:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
yarn test
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT
|
package/cjs/index.cjs
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
|
|
3
|
+
//#region ../sitemap/src/collect-entries.ts
|
|
4
|
+
const DYNAMIC_SEGMENT = /(^|\/):[A-Za-z_][A-Za-z0-9_]*/;
|
|
5
|
+
/** A route path carries a dynamic segment (`[id]` -> `:id`) it cannot enumerate on its own. */
|
|
6
|
+
function isDynamicRoutePath(routePath) {
|
|
7
|
+
return DYNAMIC_SEGMENT.test(routePath);
|
|
8
|
+
}
|
|
9
|
+
function isNoindex(robots) {
|
|
10
|
+
return robots !== void 0 && /noindex/i.test(robots);
|
|
11
|
+
}
|
|
12
|
+
/** Applies the config's `defaults` to any entry that omits `changefreq`/`priority` — shared by page-derived and app-supplied entries alike. */
|
|
13
|
+
function withDefaults(entry, defaults) {
|
|
14
|
+
return {
|
|
15
|
+
...entry,
|
|
16
|
+
changefreq: entry.changefreq ?? defaults?.changefreq,
|
|
17
|
+
priority: entry.priority ?? defaults?.priority
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Combines page-derived entries with app-supplied ones (`SitemapConnectorOptions.entries`),
|
|
22
|
+
* deduplicating by `path`. App-supplied entries are ADDED, not substituted — an
|
|
23
|
+
* app with both a page graph and extra URLs (e.g. rows the page graph can't
|
|
24
|
+
* see) wants both — but where the same path appears in both, the app-supplied
|
|
25
|
+
* entry wins, since it was written for that exact path on purpose.
|
|
26
|
+
*/
|
|
27
|
+
function mergeSitemapEntries(pageEntries, appEntries) {
|
|
28
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
29
|
+
for (const entry of pageEntries) byPath.set(entry.path, entry);
|
|
30
|
+
for (const entry of appEntries) byPath.set(entry.path, entry);
|
|
31
|
+
return Array.from(byPath.values());
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Walks the routable pages and produces the entries + the unresolved-dynamic
|
|
35
|
+
* diagnostic input, applying every exclusion rule:
|
|
36
|
+
*
|
|
37
|
+
* - `metadata.robots` says `noindex` -> excluded.
|
|
38
|
+
* - `sitemap: false` -> excluded.
|
|
39
|
+
* - a `sitemap` export (any route) -> its returned entries, in place of the
|
|
40
|
+
* page's own route path.
|
|
41
|
+
* - a dynamic route with no `sitemap` export -> omitted, name collected.
|
|
42
|
+
* - everything else (static routes) -> one entry at the page's own route path.
|
|
43
|
+
*
|
|
44
|
+
* Not-found and error pages are excluded by construction: the caller is
|
|
45
|
+
* expected to hand this only `DiscoveredRoutablePage`-derived entries, and
|
|
46
|
+
* the not-found route is never one of those (`@warlock.js/web`'s discovery
|
|
47
|
+
* reports it as a routable page for the client matcher, but the runtime
|
|
48
|
+
* wiring filters it out before calling here — see the sitemap README).
|
|
49
|
+
*/
|
|
50
|
+
async function collectSitemapEntries(pages, options = {}) {
|
|
51
|
+
const entries = [];
|
|
52
|
+
const unresolvedDynamicRoutes = [];
|
|
53
|
+
for (const page of pages) {
|
|
54
|
+
if (isNoindex(page.robots)) continue;
|
|
55
|
+
if (page.sitemap === false) continue;
|
|
56
|
+
if (typeof page.sitemap === "function") {
|
|
57
|
+
const produced = await page.sitemap();
|
|
58
|
+
for (const entry of produced) entries.push(withDefaults(entry, options.defaults));
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (isDynamicRoutePath(page.routePath)) {
|
|
62
|
+
unresolvedDynamicRoutes.push(page.routeName);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
entries.push(withDefaults({ path: page.routePath }, options.defaults));
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
entries,
|
|
69
|
+
unresolvedDynamicRoutes
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region ../sitemap/src/diagnostic.ts
|
|
75
|
+
/**
|
|
76
|
+
* The dev-mode diagnostic for dynamic routes {@link collectSitemapEntries}
|
|
77
|
+
* (`collect-entries.ts`) could not enumerate — the whole reason this package
|
|
78
|
+
* is written carefully. A dynamic route cannot be enumerated without
|
|
79
|
+
* application data; what the framework controls is whether the developer
|
|
80
|
+
* finds out. Returns `undefined` when there is nothing to report, so a caller
|
|
81
|
+
* can `if (message) console.warn(message)` without an extra length check.
|
|
82
|
+
*/
|
|
83
|
+
function describeUnresolvedDynamicRoutes(routeNames) {
|
|
84
|
+
if (routeNames.length === 0) return void 0;
|
|
85
|
+
const plural = routeNames.length === 1 ? "" : "s";
|
|
86
|
+
const named = routeNames.map((name) => ` - ${name}`).join("\n");
|
|
87
|
+
return `[warlock:sitemap] ${routeNames.length} dynamic route${plural} ${routeNames.length === 1 ? "has" : "have"} no \`sitemap\` export and ${routeNames.length === 1 ? "is" : "are"} OMITTED from sitemap.xml:\n${named}\nA dynamic route cannot be enumerated without application data. Add \`export const sitemap: SitemapEntries = async () => [...]\` to each page above, or \`export const sitemap = false\` to keep it out of the sitemap deliberately.`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region ../sitemap/src/url.ts
|
|
92
|
+
/**
|
|
93
|
+
* Joins a configured origin and an app-relative route path into one absolute
|
|
94
|
+
* URL, with exactly one slash at the seam regardless of whether either side
|
|
95
|
+
* already carries one.
|
|
96
|
+
*/
|
|
97
|
+
function joinOrigin(origin, routePath) {
|
|
98
|
+
return `${origin.endsWith("/") ? origin.slice(0, -1) : origin}${routePath.startsWith("/") ? routePath : `/${routePath}`}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Raised when the sitemap is enabled but no public origin is configured.
|
|
102
|
+
* Refuses to boot rather than falling back to a request-derived origin: a
|
|
103
|
+
* sitemap served with the wrong host is worse than one that refuses to
|
|
104
|
+
* start, because nothing downstream ever tells you it was wrong.
|
|
105
|
+
*/
|
|
106
|
+
var MissingPublicUrlError = class extends Error {
|
|
107
|
+
constructor() {
|
|
108
|
+
super("Sitemap is enabled but no public origin is configured. Set `app.publicUrl` in warlock.config.ts, or the PUBLIC_APP_URL environment variable.");
|
|
109
|
+
this.name = "MissingPublicUrlError";
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* The origin the sitemap is served from: `app.publicUrl` first, then the
|
|
114
|
+
* `PUBLIC_APP_URL` env fallback. Throws {@link MissingPublicUrlError} when
|
|
115
|
+
* neither is set — this is the boot-time check, called once, not per-request.
|
|
116
|
+
*/
|
|
117
|
+
function resolveOrigin(options = {}) {
|
|
118
|
+
const origin = options.publicUrl ?? options.env?.PUBLIC_APP_URL;
|
|
119
|
+
if (!origin) throw new MissingPublicUrlError();
|
|
120
|
+
return origin;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region ../sitemap/src/xml.ts
|
|
125
|
+
const XML_ESCAPES = {
|
|
126
|
+
"&": "&",
|
|
127
|
+
"<": "<",
|
|
128
|
+
">": ">",
|
|
129
|
+
"\"": """,
|
|
130
|
+
"'": "'"
|
|
131
|
+
};
|
|
132
|
+
/** Escapes the five XML-significant characters. A URL's query string routinely contains `&`. */
|
|
133
|
+
function escapeXml(value) {
|
|
134
|
+
return value.replace(/[&<>"']/g, (char) => XML_ESCAPES[char] ?? char);
|
|
135
|
+
}
|
|
136
|
+
function entryXml(entry, origin) {
|
|
137
|
+
const lines = [` <url>`, ` <loc>${escapeXml(joinOrigin(origin, entry.path))}</loc>`];
|
|
138
|
+
if (entry.lastmod !== void 0) lines.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);
|
|
139
|
+
if (entry.changefreq !== void 0) lines.push(` <changefreq>${entry.changefreq}</changefreq>`);
|
|
140
|
+
if (entry.priority !== void 0) lines.push(` <priority>${entry.priority}</priority>`);
|
|
141
|
+
lines.push(` </url>`);
|
|
142
|
+
return lines.join("\n");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Serialises entries into a `urlset` sitemap document — the sitemaps.org
|
|
146
|
+
* namespace, `<url>` per entry, element order `loc` / `lastmod` / `changefreq`
|
|
147
|
+
* / `priority` (schema order; a validator that checks order rejects any other).
|
|
148
|
+
*/
|
|
149
|
+
function buildSitemapXml(entries, origin) {
|
|
150
|
+
const body = entries.map((entry) => entryXml(entry, origin)).join("\n");
|
|
151
|
+
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n" + (body.length > 0 ? `${body}\n` : "") + `</urlset>\n`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region ../sitemap/src/sitemap-connector.ts
|
|
156
|
+
/** Default path when `src/config/sitemap.ts` does not set one. */
|
|
157
|
+
const DEFAULT_SITEMAP_PATH = "/sitemap.xml";
|
|
158
|
+
/**
|
|
159
|
+
* Boots after the HTTP connector (`ConnectorPriority.HTTP` is `5`) and after
|
|
160
|
+
* web (`5.5`, `web-connector-factory.ts`) — the route it registers has to
|
|
161
|
+
* land on the same router web's pages already share, and `listRoutablePages`
|
|
162
|
+
* only has a page graph to read once web has scanned it.
|
|
163
|
+
*/
|
|
164
|
+
const SITEMAP_CONNECTOR_PRIORITY = 5.6;
|
|
165
|
+
/**
|
|
166
|
+
* Raised at `boot()` when the sitemap is enabled but has no way to produce
|
|
167
|
+
* entries: `@warlock.js/web` is not installed, so there is no page registry
|
|
168
|
+
* for `listRoutablePages()` to read, AND no `entries` option was supplied.
|
|
169
|
+
* Refuses to boot rather than registering a route that would silently serve
|
|
170
|
+
* an empty `<urlset>` — the same reasoning as {@link MissingPublicUrlError}:
|
|
171
|
+
* a sitemap that looks complete while producing nothing is worse than one
|
|
172
|
+
* that never started.
|
|
173
|
+
*/
|
|
174
|
+
var NoPageRegistryError = class extends Error {
|
|
175
|
+
constructor() {
|
|
176
|
+
super("Sitemap is enabled but has no source of entries: `@warlock.js/web` is not installed, so there is no page registry to read, and no `entries` option was supplied either. Fix this by installing `@warlock.js/web`, or by passing `sitemapConnector({ entries: async () => [...] })` with your own supplier.");
|
|
177
|
+
this.name = "NoPageRegistryError";
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
/** Adapts one `listRoutablePages()` result into the package's own minimal `RoutablePage` shape. */
|
|
181
|
+
function toRoutablePage(page) {
|
|
182
|
+
const metadata = page.metadata;
|
|
183
|
+
const robots = metadata !== null && typeof metadata === "object" && "robots" in metadata ? metadata.robots : void 0;
|
|
184
|
+
return {
|
|
185
|
+
routeName: page.routeName,
|
|
186
|
+
routePath: page.routePath,
|
|
187
|
+
robots: typeof robots === "string" ? robots : void 0,
|
|
188
|
+
sitemap: page.sitemap
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Construct the sitemap connector.
|
|
193
|
+
*
|
|
194
|
+
* At `boot()`: reads the `sitemap` config (a no-op when `enabled` is not
|
|
195
|
+
* `true`), resolves the public origin ONCE — failing loud via
|
|
196
|
+
* {@link resolveOrigin}'s {@link MissingPublicUrlError} rather than falling
|
|
197
|
+
* back to a request-derived host — and registers `GET <config.path>`.
|
|
198
|
+
*
|
|
199
|
+
* The route itself re-reads the page graph on every request via
|
|
200
|
+
* `listRoutablePages()`, not once at boot: the registry can change under
|
|
201
|
+
* `warlock dev`, and a sitemap that only reflects the app's shape at the
|
|
202
|
+
* moment it booted is stale in exactly the way that made `2ede40cf`-class
|
|
203
|
+
* defects expensive.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* // warlock.config.ts
|
|
207
|
+
* import { sitemapConnector } from "@warlock.js/sitemap";
|
|
208
|
+
*
|
|
209
|
+
* export default defineConfig({ connectors: [sitemapConnector()] });
|
|
210
|
+
*/
|
|
211
|
+
function sitemapConnector(options = {}) {
|
|
212
|
+
let active = false;
|
|
213
|
+
const connector = {
|
|
214
|
+
name: "sitemap",
|
|
215
|
+
priority: SITEMAP_CONNECTOR_PRIORITY,
|
|
216
|
+
lifecyclePhase: "late",
|
|
217
|
+
isActive: () => active,
|
|
218
|
+
async boot() {
|
|
219
|
+
const { config, router } = await import("@warlock.js/core");
|
|
220
|
+
const sitemapConfig = options.config ?? config.get("sitemap");
|
|
221
|
+
if (!sitemapConfig?.enabled) return;
|
|
222
|
+
const origin = resolveOrigin({
|
|
223
|
+
publicUrl: config.get("app")?.publicUrl,
|
|
224
|
+
env: process.env
|
|
225
|
+
});
|
|
226
|
+
const path = sitemapConfig.path || "/sitemap.xml";
|
|
227
|
+
let listRoutablePages;
|
|
228
|
+
try {
|
|
229
|
+
({listRoutablePages} = await import("@warlock.js/web/build"));
|
|
230
|
+
} catch {
|
|
231
|
+
listRoutablePages = void 0;
|
|
232
|
+
}
|
|
233
|
+
if (!listRoutablePages && !options.entries) throw new NoPageRegistryError();
|
|
234
|
+
router.get(path, async ({ response }) => {
|
|
235
|
+
const { entries: pageEntries, unresolvedDynamicRoutes } = await collectSitemapEntries(listRoutablePages ? (await listRoutablePages({ appRoot: process.cwd() })).map(toRoutablePage) : [], { defaults: sitemapConfig.defaults });
|
|
236
|
+
const entries = mergeSitemapEntries(pageEntries, options.entries ? (await options.entries()).map((entry) => withDefaults(entry, sitemapConfig.defaults)) : []);
|
|
237
|
+
if (process.env.NODE_ENV !== "production") {
|
|
238
|
+
const diagnostic = describeUnresolvedDynamicRoutes(unresolvedDynamicRoutes);
|
|
239
|
+
if (diagnostic) console.warn(diagnostic);
|
|
240
|
+
}
|
|
241
|
+
const xml = buildSitemapXml(entries, origin);
|
|
242
|
+
return response.setContentType("application/xml").send(xml);
|
|
243
|
+
});
|
|
244
|
+
active = true;
|
|
245
|
+
},
|
|
246
|
+
async start() {},
|
|
247
|
+
async restart() {
|
|
248
|
+
await connector.shutdown();
|
|
249
|
+
await connector.boot();
|
|
250
|
+
},
|
|
251
|
+
async shutdown() {
|
|
252
|
+
active = false;
|
|
253
|
+
},
|
|
254
|
+
shouldRestart(changedFiles) {
|
|
255
|
+
return changedFiles.some((file) => {
|
|
256
|
+
const normalized = file.replace(/\\/g, "/");
|
|
257
|
+
return normalized === "src/config/sitemap.ts" || normalized.endsWith("/src/config/sitemap.ts");
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
return connector;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
//#endregion
|
|
265
|
+
exports.DEFAULT_SITEMAP_PATH = DEFAULT_SITEMAP_PATH;
|
|
266
|
+
exports.MissingPublicUrlError = MissingPublicUrlError;
|
|
267
|
+
exports.NoPageRegistryError = NoPageRegistryError;
|
|
268
|
+
exports.SITEMAP_CONNECTOR_PRIORITY = SITEMAP_CONNECTOR_PRIORITY;
|
|
269
|
+
exports.buildSitemapXml = buildSitemapXml;
|
|
270
|
+
exports.collectSitemapEntries = collectSitemapEntries;
|
|
271
|
+
exports.describeUnresolvedDynamicRoutes = describeUnresolvedDynamicRoutes;
|
|
272
|
+
exports.escapeXml = escapeXml;
|
|
273
|
+
exports.isDynamicRoutePath = isDynamicRoutePath;
|
|
274
|
+
exports.joinOrigin = joinOrigin;
|
|
275
|
+
exports.mergeSitemapEntries = mergeSitemapEntries;
|
|
276
|
+
exports.resolveOrigin = resolveOrigin;
|
|
277
|
+
exports.sitemapConnector = sitemapConnector;
|
|
278
|
+
exports.withDefaults = withDefaults;
|
|
279
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","names":[],"sources":["../../../../../../sitemap/src/collect-entries.ts","../../../../../../sitemap/src/diagnostic.ts","../../../../../../sitemap/src/url.ts","../../../../../../sitemap/src/xml.ts","../../../../../../sitemap/src/sitemap-connector.ts"],"sourcesContent":["import type { RoutablePage } from \"./routable-page\";\r\nimport type { SitemapDefaults, SitemapEntry } from \"./types\";\r\n\r\nconst DYNAMIC_SEGMENT = /(^|\\/):[A-Za-z_][A-Za-z0-9_]*/;\r\n\r\n/** A route path carries a dynamic segment (`[id]` -> `:id`) it cannot enumerate on its own. */\r\nexport function isDynamicRoutePath(routePath: string): boolean {\r\n return DYNAMIC_SEGMENT.test(routePath);\r\n}\r\n\r\nfunction isNoindex(robots: string | undefined): boolean {\r\n return robots !== undefined && /noindex/i.test(robots);\r\n}\r\n\r\n/** Applies the config's `defaults` to any entry that omits `changefreq`/`priority` — shared by page-derived and app-supplied entries alike. */\r\nexport function withDefaults(entry: SitemapEntry, defaults: SitemapDefaults | undefined): SitemapEntry {\r\n return {\r\n ...entry,\r\n changefreq: entry.changefreq ?? defaults?.changefreq,\r\n priority: entry.priority ?? defaults?.priority,\r\n };\r\n}\r\n\r\n/**\r\n * Combines page-derived entries with app-supplied ones (`SitemapConnectorOptions.entries`),\r\n * deduplicating by `path`. App-supplied entries are ADDED, not substituted — an\r\n * app with both a page graph and extra URLs (e.g. rows the page graph can't\r\n * see) wants both — but where the same path appears in both, the app-supplied\r\n * entry wins, since it was written for that exact path on purpose.\r\n */\r\nexport function mergeSitemapEntries(\r\n pageEntries: readonly SitemapEntry[],\r\n appEntries: readonly SitemapEntry[],\r\n): SitemapEntry[] {\r\n const byPath = new Map<string, SitemapEntry>();\r\n\r\n for (const entry of pageEntries) byPath.set(entry.path, entry);\r\n for (const entry of appEntries) byPath.set(entry.path, entry);\r\n\r\n return Array.from(byPath.values());\r\n}\r\n\r\nexport type CollectSitemapEntriesOptions = {\r\n defaults?: SitemapDefaults;\r\n};\r\n\r\nexport type CollectSitemapEntriesResult = {\r\n entries: SitemapEntry[];\r\n /** Route names of dynamic routes with no `sitemap` export — feed to `describeUnresolvedDynamicRoutes`. */\r\n unresolvedDynamicRoutes: string[];\r\n};\r\n\r\n/**\r\n * Walks the routable pages and produces the entries + the unresolved-dynamic\r\n * diagnostic input, applying every exclusion rule:\r\n *\r\n * - `metadata.robots` says `noindex` -> excluded.\r\n * - `sitemap: false` -> excluded.\r\n * - a `sitemap` export (any route) -> its returned entries, in place of the\r\n * page's own route path.\r\n * - a dynamic route with no `sitemap` export -> omitted, name collected.\r\n * - everything else (static routes) -> one entry at the page's own route path.\r\n *\r\n * Not-found and error pages are excluded by construction: the caller is\r\n * expected to hand this only `DiscoveredRoutablePage`-derived entries, and\r\n * the not-found route is never one of those (`@warlock.js/web`'s discovery\r\n * reports it as a routable page for the client matcher, but the runtime\r\n * wiring filters it out before calling here — see the sitemap README).\r\n */\r\nexport async function collectSitemapEntries(\r\n pages: readonly RoutablePage[],\r\n options: CollectSitemapEntriesOptions = {},\r\n): Promise<CollectSitemapEntriesResult> {\r\n const entries: SitemapEntry[] = [];\r\n const unresolvedDynamicRoutes: string[] = [];\r\n\r\n for (const page of pages) {\r\n if (isNoindex(page.robots)) continue;\r\n if (page.sitemap === false) continue;\r\n\r\n if (typeof page.sitemap === \"function\") {\r\n const produced = await page.sitemap();\r\n\r\n for (const entry of produced) entries.push(withDefaults(entry, options.defaults));\r\n\r\n continue;\r\n }\r\n\r\n if (isDynamicRoutePath(page.routePath)) {\r\n unresolvedDynamicRoutes.push(page.routeName);\r\n continue;\r\n }\r\n\r\n entries.push(withDefaults({ path: page.routePath }, options.defaults));\r\n }\r\n\r\n return { entries, unresolvedDynamicRoutes };\r\n}\r\n","/**\n * The dev-mode diagnostic for dynamic routes {@link collectSitemapEntries}\n * (`collect-entries.ts`) could not enumerate — the whole reason this package\n * is written carefully. A dynamic route cannot be enumerated without\n * application data; what the framework controls is whether the developer\n * finds out. Returns `undefined` when there is nothing to report, so a caller\n * can `if (message) console.warn(message)` without an extra length check.\n */\nexport function describeUnresolvedDynamicRoutes(routeNames: readonly string[]): string | undefined {\n if (routeNames.length === 0) return undefined;\n\n const plural = routeNames.length === 1 ? \"\" : \"s\";\n const named = routeNames.map((name) => ` - ${name}`).join(\"\\n\");\n\n return (\n `[warlock:sitemap] ${routeNames.length} dynamic route${plural} ` +\n `${routeNames.length === 1 ? \"has\" : \"have\"} no \\`sitemap\\` export and ` +\n `${routeNames.length === 1 ? \"is\" : \"are\"} OMITTED from sitemap.xml:\\n${named}\\n` +\n \"A dynamic route cannot be enumerated without application data. Add \" +\n \"`export const sitemap: SitemapEntries = async () => [...]` to each page above, \" +\n \"or `export const sitemap = false` to keep it out of the sitemap deliberately.\"\n );\n}\n","/**\n * Joins a configured origin and an app-relative route path into one absolute\n * URL, with exactly one slash at the seam regardless of whether either side\n * already carries one.\n */\nexport function joinOrigin(origin: string, routePath: string): string {\n const trimmedOrigin = origin.endsWith(\"/\") ? origin.slice(0, -1) : origin;\n const normalizedPath = routePath.startsWith(\"/\") ? routePath : `/${routePath}`;\n\n return `${trimmedOrigin}${normalizedPath}`;\n}\n\n/**\n * Raised when the sitemap is enabled but no public origin is configured.\n * Refuses to boot rather than falling back to a request-derived origin: a\n * sitemap served with the wrong host is worse than one that refuses to\n * start, because nothing downstream ever tells you it was wrong.\n */\nexport class MissingPublicUrlError extends Error {\n public constructor() {\n super(\n \"Sitemap is enabled but no public origin is configured. Set `app.publicUrl` \" +\n \"in warlock.config.ts, or the PUBLIC_APP_URL environment variable.\",\n );\n this.name = \"MissingPublicUrlError\";\n }\n}\n\nexport type ResolveOriginOptions = {\n /** `app.publicUrl` from the app's config, when set. */\n publicUrl?: string;\n /** Defaults to `process.env`; overridable for tests. */\n env?: Record<string, string | undefined>;\n};\n\n/**\n * The origin the sitemap is served from: `app.publicUrl` first, then the\n * `PUBLIC_APP_URL` env fallback. Throws {@link MissingPublicUrlError} when\n * neither is set — this is the boot-time check, called once, not per-request.\n */\nexport function resolveOrigin(options: ResolveOriginOptions = {}): string {\n const origin = options.publicUrl ?? options.env?.PUBLIC_APP_URL;\n\n if (!origin) throw new MissingPublicUrlError();\n\n return origin;\n}\n","import type { SitemapEntry } from \"./types\";\nimport { joinOrigin } from \"./url\";\n\nconst XML_ESCAPES: Record<string, string> = {\n \"&\": \"&\",\n \"<\": \"<\",\n \">\": \">\",\n '\"': \""\",\n \"'\": \"'\",\n};\n\n/** Escapes the five XML-significant characters. A URL's query string routinely contains `&`. */\nexport function escapeXml(value: string): string {\n // The character class and the table are written together, so the lookup can\n // only miss if one is edited without the other; falling back to the original\n // character keeps that editing mistake from silently emitting `undefined`\n // into a URL.\n return value.replace(/[&<>\"']/g, (char) => XML_ESCAPES[char] ?? char);\n}\n\nfunction entryXml(entry: SitemapEntry, origin: string): string {\n const lines = [` <url>`, ` <loc>${escapeXml(joinOrigin(origin, entry.path))}</loc>`];\n\n if (entry.lastmod !== undefined) {\n lines.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);\n }\n\n if (entry.changefreq !== undefined) {\n lines.push(` <changefreq>${entry.changefreq}</changefreq>`);\n }\n\n if (entry.priority !== undefined) {\n lines.push(` <priority>${entry.priority}</priority>`);\n }\n\n lines.push(` </url>`);\n\n return lines.join(\"\\n\");\n}\n\n/**\n * Serialises entries into a `urlset` sitemap document — the sitemaps.org\n * namespace, `<url>` per entry, element order `loc` / `lastmod` / `changefreq`\n * / `priority` (schema order; a validator that checks order rejects any other).\n */\nexport function buildSitemapXml(entries: readonly SitemapEntry[], origin: string): string {\n const body = entries.map((entry) => entryXml(entry, origin)).join(\"\\n\");\n\n return (\n `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n` +\n `<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\\n` +\n (body.length > 0 ? `${body}\\n` : \"\") +\n `</urlset>\\n`\n );\n}\n","/**\n * `sitemapConnector()` — the ONE thing `warlock.config.ts` imports from\n * `@warlock.js/sitemap`, and the connector `warlock add sitemap` registers.\n *\n * Deliberately a plain object with TYPE-ONLY imports from core, same\n * reasoning as `queueConnector()`/`webConnector()`: the config file that\n * constructs it must not drag core's runtime graph in at config-load time.\n * `@warlock.js/core` and `@warlock.js/web` are imported lazily, inside\n * `boot()`, where the app has already loaded both.\n */\nimport type { Connector, ConnectorLifecyclePhase, HttpContext } from \"@warlock.js/core\";\nimport { collectSitemapEntries, mergeSitemapEntries, withDefaults } from \"./collect-entries\";\nimport { describeUnresolvedDynamicRoutes } from \"./diagnostic\";\nimport type { RoutablePage } from \"./routable-page\";\nimport type { SitemapConfig, SitemapEntries } from \"./types\";\nimport { resolveOrigin } from \"./url\";\nimport { buildSitemapXml } from \"./xml\";\n\n/** Default path when `src/config/sitemap.ts` does not set one. */\nexport const DEFAULT_SITEMAP_PATH = \"/sitemap.xml\";\n\n/**\n * Boots after the HTTP connector (`ConnectorPriority.HTTP` is `5`) and after\n * web (`5.5`, `web-connector-factory.ts`) — the route it registers has to\n * land on the same router web's pages already share, and `listRoutablePages`\n * only has a page graph to read once web has scanned it.\n */\nexport const SITEMAP_CONNECTOR_PRIORITY = 5.6;\n\nexport type SitemapConnectorOptions = {\n /** Supply the configuration directly instead of reading the `sitemap` config key (`src/config/sitemap.ts`). */\n config?: SitemapConfig;\n /**\n * App-supplied entries — the only source of entries in a Warlock **API-only**\n * project, which has no `@warlock.js/web` page registry for `listRoutablePages()`\n * to read. When `@warlock.js/web` IS installed, these are ADDED to the\n * page-derived entries (see {@link mergeSitemapEntries}), not substituted, so an\n * app with both pages and extra URLs (e.g. rows a database holds) gets both.\n */\n entries?: SitemapEntries;\n};\n\n/**\n * Raised at `boot()` when the sitemap is enabled but has no way to produce\n * entries: `@warlock.js/web` is not installed, so there is no page registry\n * for `listRoutablePages()` to read, AND no `entries` option was supplied.\n * Refuses to boot rather than registering a route that would silently serve\n * an empty `<urlset>` — the same reasoning as {@link MissingPublicUrlError}:\n * a sitemap that looks complete while producing nothing is worse than one\n * that never started.\n */\nexport class NoPageRegistryError extends Error {\n public constructor() {\n super(\n \"Sitemap is enabled but has no source of entries: `@warlock.js/web` is not installed, \" +\n \"so there is no page registry to read, and no `entries` option was supplied either. \" +\n \"Fix this by installing `@warlock.js/web`, or by passing \" +\n \"`sitemapConnector({ entries: async () => [...] })` with your own supplier.\",\n );\n this.name = \"NoPageRegistryError\";\n }\n}\n\n/** Adapts one `listRoutablePages()` result into the package's own minimal `RoutablePage` shape. */\nfunction toRoutablePage(page: {\n routeName: string;\n routePath: string;\n metadata?: unknown;\n sitemap?: unknown;\n}): RoutablePage {\n const metadata = page.metadata;\n const robots =\n metadata !== null && typeof metadata === \"object\" && \"robots\" in metadata\n ? (metadata as { robots?: unknown }).robots\n : undefined;\n\n return {\n routeName: page.routeName,\n routePath: page.routePath,\n robots: typeof robots === \"string\" ? robots : undefined,\n sitemap: page.sitemap as RoutablePage[\"sitemap\"],\n };\n}\n\n/**\n * Construct the sitemap connector.\n *\n * At `boot()`: reads the `sitemap` config (a no-op when `enabled` is not\n * `true`), resolves the public origin ONCE — failing loud via\n * {@link resolveOrigin}'s {@link MissingPublicUrlError} rather than falling\n * back to a request-derived host — and registers `GET <config.path>`.\n *\n * The route itself re-reads the page graph on every request via\n * `listRoutablePages()`, not once at boot: the registry can change under\n * `warlock dev`, and a sitemap that only reflects the app's shape at the\n * moment it booted is stale in exactly the way that made `2ede40cf`-class\n * defects expensive.\n *\n * @example\n * // warlock.config.ts\n * import { sitemapConnector } from \"@warlock.js/sitemap\";\n *\n * export default defineConfig({ connectors: [sitemapConnector()] });\n */\nexport function sitemapConnector(options: SitemapConnectorOptions = {}): Connector {\n let active = false;\n\n const connector: Connector = {\n name: \"sitemap\",\n priority: SITEMAP_CONNECTOR_PRIORITY,\n // Core's `ConnectorLifecyclePhase.Late`; spelled out so this module stays\n // free of a runtime import of core, same as `queueConnector()`.\n lifecyclePhase: \"late\" as ConnectorLifecyclePhase,\n isActive: () => active,\n async boot() {\n const { config, router } = await import(\"@warlock.js/core\");\n\n const sitemapConfig = options.config ?? config.get<SitemapConfig | undefined>(\"sitemap\");\n\n if (!sitemapConfig?.enabled) {\n return;\n }\n\n const appConfig = config.get<{ publicUrl?: string } | undefined>(\"app\");\n const origin = resolveOrigin({ publicUrl: appConfig?.publicUrl, env: process.env });\n const path = sitemapConfig.path || DEFAULT_SITEMAP_PATH;\n\n // Checked once, at boot: `@warlock.js/web`'s presence can't change per\n // request, and failing here — before the route is even registered —\n // surfaces a misconfigured app at startup instead of on its first hit.\n let listRoutablePages: typeof import(\"@warlock.js/web/build\").listRoutablePages | undefined;\n try {\n ({ listRoutablePages } = await import(\"@warlock.js/web/build\"));\n } catch {\n listRoutablePages = undefined;\n }\n\n if (!listRoutablePages && !options.entries) {\n throw new NoPageRegistryError();\n }\n\n router.get(path, async ({ response }: HttpContext) => {\n const pages = listRoutablePages\n ? (await listRoutablePages({ appRoot: process.cwd() })).map(toRoutablePage)\n : [];\n\n const { entries: pageEntries, unresolvedDynamicRoutes } = await collectSitemapEntries(pages, {\n defaults: sitemapConfig.defaults,\n });\n\n const appEntries = options.entries\n ? (await options.entries()).map((entry) => withDefaults(entry, sitemapConfig.defaults))\n : [];\n\n const entries = mergeSitemapEntries(pageEntries, appEntries);\n\n if (process.env.NODE_ENV !== \"production\") {\n const diagnostic = describeUnresolvedDynamicRoutes(unresolvedDynamicRoutes);\n if (diagnostic) console.warn(diagnostic);\n }\n\n const xml = buildSitemapXml(entries, origin);\n\n return response.setContentType(\"application/xml\").send(xml);\n });\n\n active = true;\n },\n async start() {\n // Nothing to start: the route is registered at boot, once the HTTP\n // connector has built its server but before it listens — the same\n // window `queueConnector()`'s dashboard mount uses.\n },\n async restart() {\n await connector.shutdown();\n await connector.boot();\n },\n async shutdown() {\n active = false;\n },\n shouldRestart(changedFiles: string[]) {\n return changedFiles.some((file) => {\n const normalized = file.replace(/\\\\/g, \"/\");\n\n return normalized === \"src/config/sitemap.ts\" || normalized.endsWith(\"/src/config/sitemap.ts\");\n });\n },\n };\n\n return connector;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { SitemapDefaults, SitemapEntry } from "./types.mjs";
|
|
2
|
+
import { RoutablePage } from "./routable-page.mjs";
|
|
3
|
+
|
|
4
|
+
//#region ../sitemap/src/collect-entries.d.ts
|
|
5
|
+
/** A route path carries a dynamic segment (`[id]` -> `:id`) it cannot enumerate on its own. */
|
|
6
|
+
declare function isDynamicRoutePath(routePath: string): boolean;
|
|
7
|
+
/** Applies the config's `defaults` to any entry that omits `changefreq`/`priority` — shared by page-derived and app-supplied entries alike. */
|
|
8
|
+
declare function withDefaults(entry: SitemapEntry, defaults: SitemapDefaults | undefined): SitemapEntry;
|
|
9
|
+
/**
|
|
10
|
+
* Combines page-derived entries with app-supplied ones (`SitemapConnectorOptions.entries`),
|
|
11
|
+
* deduplicating by `path`. App-supplied entries are ADDED, not substituted — an
|
|
12
|
+
* app with both a page graph and extra URLs (e.g. rows the page graph can't
|
|
13
|
+
* see) wants both — but where the same path appears in both, the app-supplied
|
|
14
|
+
* entry wins, since it was written for that exact path on purpose.
|
|
15
|
+
*/
|
|
16
|
+
declare function mergeSitemapEntries(pageEntries: readonly SitemapEntry[], appEntries: readonly SitemapEntry[]): SitemapEntry[];
|
|
17
|
+
type CollectSitemapEntriesOptions = {
|
|
18
|
+
defaults?: SitemapDefaults;
|
|
19
|
+
};
|
|
20
|
+
type CollectSitemapEntriesResult = {
|
|
21
|
+
entries: SitemapEntry[]; /** Route names of dynamic routes with no `sitemap` export — feed to `describeUnresolvedDynamicRoutes`. */
|
|
22
|
+
unresolvedDynamicRoutes: string[];
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Walks the routable pages and produces the entries + the unresolved-dynamic
|
|
26
|
+
* diagnostic input, applying every exclusion rule:
|
|
27
|
+
*
|
|
28
|
+
* - `metadata.robots` says `noindex` -> excluded.
|
|
29
|
+
* - `sitemap: false` -> excluded.
|
|
30
|
+
* - a `sitemap` export (any route) -> its returned entries, in place of the
|
|
31
|
+
* page's own route path.
|
|
32
|
+
* - a dynamic route with no `sitemap` export -> omitted, name collected.
|
|
33
|
+
* - everything else (static routes) -> one entry at the page's own route path.
|
|
34
|
+
*
|
|
35
|
+
* Not-found and error pages are excluded by construction: the caller is
|
|
36
|
+
* expected to hand this only `DiscoveredRoutablePage`-derived entries, and
|
|
37
|
+
* the not-found route is never one of those (`@warlock.js/web`'s discovery
|
|
38
|
+
* reports it as a routable page for the client matcher, but the runtime
|
|
39
|
+
* wiring filters it out before calling here — see the sitemap README).
|
|
40
|
+
*/
|
|
41
|
+
declare function collectSitemapEntries(pages: readonly RoutablePage[], options?: CollectSitemapEntriesOptions): Promise<CollectSitemapEntriesResult>;
|
|
42
|
+
//#endregion
|
|
43
|
+
export { CollectSitemapEntriesOptions, CollectSitemapEntriesResult, collectSitemapEntries, isDynamicRoutePath, mergeSitemapEntries, withDefaults };
|
|
44
|
+
//# sourceMappingURL=collect-entries.d.mts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//#region ../sitemap/src/collect-entries.ts
|
|
2
|
+
const DYNAMIC_SEGMENT = /(^|\/):[A-Za-z_][A-Za-z0-9_]*/;
|
|
3
|
+
/** A route path carries a dynamic segment (`[id]` -> `:id`) it cannot enumerate on its own. */
|
|
4
|
+
function isDynamicRoutePath(routePath) {
|
|
5
|
+
return DYNAMIC_SEGMENT.test(routePath);
|
|
6
|
+
}
|
|
7
|
+
function isNoindex(robots) {
|
|
8
|
+
return robots !== void 0 && /noindex/i.test(robots);
|
|
9
|
+
}
|
|
10
|
+
/** Applies the config's `defaults` to any entry that omits `changefreq`/`priority` — shared by page-derived and app-supplied entries alike. */
|
|
11
|
+
function withDefaults(entry, defaults) {
|
|
12
|
+
return {
|
|
13
|
+
...entry,
|
|
14
|
+
changefreq: entry.changefreq ?? defaults?.changefreq,
|
|
15
|
+
priority: entry.priority ?? defaults?.priority
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Combines page-derived entries with app-supplied ones (`SitemapConnectorOptions.entries`),
|
|
20
|
+
* deduplicating by `path`. App-supplied entries are ADDED, not substituted — an
|
|
21
|
+
* app with both a page graph and extra URLs (e.g. rows the page graph can't
|
|
22
|
+
* see) wants both — but where the same path appears in both, the app-supplied
|
|
23
|
+
* entry wins, since it was written for that exact path on purpose.
|
|
24
|
+
*/
|
|
25
|
+
function mergeSitemapEntries(pageEntries, appEntries) {
|
|
26
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
27
|
+
for (const entry of pageEntries) byPath.set(entry.path, entry);
|
|
28
|
+
for (const entry of appEntries) byPath.set(entry.path, entry);
|
|
29
|
+
return Array.from(byPath.values());
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Walks the routable pages and produces the entries + the unresolved-dynamic
|
|
33
|
+
* diagnostic input, applying every exclusion rule:
|
|
34
|
+
*
|
|
35
|
+
* - `metadata.robots` says `noindex` -> excluded.
|
|
36
|
+
* - `sitemap: false` -> excluded.
|
|
37
|
+
* - a `sitemap` export (any route) -> its returned entries, in place of the
|
|
38
|
+
* page's own route path.
|
|
39
|
+
* - a dynamic route with no `sitemap` export -> omitted, name collected.
|
|
40
|
+
* - everything else (static routes) -> one entry at the page's own route path.
|
|
41
|
+
*
|
|
42
|
+
* Not-found and error pages are excluded by construction: the caller is
|
|
43
|
+
* expected to hand this only `DiscoveredRoutablePage`-derived entries, and
|
|
44
|
+
* the not-found route is never one of those (`@warlock.js/web`'s discovery
|
|
45
|
+
* reports it as a routable page for the client matcher, but the runtime
|
|
46
|
+
* wiring filters it out before calling here — see the sitemap README).
|
|
47
|
+
*/
|
|
48
|
+
async function collectSitemapEntries(pages, options = {}) {
|
|
49
|
+
const entries = [];
|
|
50
|
+
const unresolvedDynamicRoutes = [];
|
|
51
|
+
for (const page of pages) {
|
|
52
|
+
if (isNoindex(page.robots)) continue;
|
|
53
|
+
if (page.sitemap === false) continue;
|
|
54
|
+
if (typeof page.sitemap === "function") {
|
|
55
|
+
const produced = await page.sitemap();
|
|
56
|
+
for (const entry of produced) entries.push(withDefaults(entry, options.defaults));
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (isDynamicRoutePath(page.routePath)) {
|
|
60
|
+
unresolvedDynamicRoutes.push(page.routeName);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
entries.push(withDefaults({ path: page.routePath }, options.defaults));
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
entries,
|
|
67
|
+
unresolvedDynamicRoutes
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
export { collectSitemapEntries, isDynamicRoutePath, mergeSitemapEntries, withDefaults };
|
|
73
|
+
//# sourceMappingURL=collect-entries.mjs.map
|