@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 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
+ "&": "&amp;",
127
+ "<": "&lt;",
128
+ ">": "&gt;",
129
+ "\"": "&quot;",
130
+ "'": "&apos;"
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 \"&\": \"&amp;\",\n \"<\": \"&lt;\",\n \">\": \"&gt;\",\n '\"': \"&quot;\",\n \"'\": \"&apos;\",\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