@soloworks/smking-next 0.8.0 → 0.9.1

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 CHANGED
@@ -1,5 +1,86 @@
1
1
  # @soloworks/smking-next
2
2
 
3
+ ## 0.9.1 — 2026-05-13
4
+
5
+ **`smking-next doctor` subcommand for self-check + machine-readable output.** The CLI bin now accepts two subcommands: `install` (existing one-shot scaffold) and the new `doctor` (self-check). With `--json`, doctor emits structured output for the new `@smking/wizard` install agent's `run_doctor` tool.
6
+
7
+ ### What's new
8
+
9
+ `npx @soloworks/smking-next doctor` runs four checks:
10
+
11
+ - `SMKING_API_KEY` set (and starts with `pk_`)
12
+ - `SMKING_BASE_URL` set (and starts with `http`)
13
+ - `<SmkingAEO />` imported in the root `app/layout.{tsx,jsx,ts,js}`
14
+ - Live probe of `${SMKING_BASE_URL}/api/v1/public/aeo` (3s timeout)
15
+
16
+ Exit code is `0` when every required check passes, `1` otherwise. Identical between pretty and JSON modes.
17
+
18
+ ### `--json` shape
19
+
20
+ ```json
21
+ {
22
+ "checks": [
23
+ { "name": "...", "status": "pass" | "fail" | "info", "detail": "..." }
24
+ ],
25
+ "summary": { "passed": N, "failed": N, "info": N, "ok": <bool> }
26
+ }
27
+ ```
28
+
29
+ `summary.ok` is the short-circuit boolean for agentic consumers. The shape is a stable wizard contract — schema changes will bump wizard version.
30
+
31
+ ### Why
32
+
33
+ Mirrors `smking/laravel`'s `php artisan smking:doctor --json` (v0.10.1). The `@smking/wizard` CLI now runs the same install verification step for both stacks without needing to scrape ANSI-coloured terminal output.
34
+
35
+ Pure addition — existing `npx @soloworks/smking-next install` calls are unchanged, default behaviour preserved.
36
+
37
+ ## 0.9.0 — 2026-05-12
38
+
39
+ **Drop-in takeover for `/sitemap.xml`, `/robots.txt`, and `/llms.txt` plus a one-shot install CLI.** Customers with no sitemap (or a broken one), no `robots.txt`, or no `llms.txt` for AI agents previously had to write all three themselves. With v0.9.0 the SDK serves them from the smking SaaS — one line of customer code per file, scaffolded automatically.
40
+
41
+ ### New exports
42
+
43
+ - `@soloworks/smking-next/sitemap` — `default` export for `app/sitemap.ts`. Fetches `/api/v1/public/sitemap.xml`, returns Next.js `MetadataRoute.Sitemap` shape.
44
+ - `@soloworks/smking-next/robots` — gains a new `default` export for `app/robots.ts` (returns `Response` with `Content-Type: text/plain`, since `MetadataRoute.Robots` can't carry the `Content-Signal:` directive). Existing named exports (`smkingRobotsRules`, `smkingRobotsTxt`) are unchanged — no breaking change.
45
+ - `@soloworks/smking-next/llms-txt` — `GET` export for `app/llms.txt/route.ts`. Proxies `/api/v1/public/llms-txt`.
46
+
47
+ All three carry an `X-Smking-Takeover: <kind>` response header so audits can confirm the SDK is the one serving.
48
+
49
+ ### One-shot install CLI
50
+
51
+ ```bash
52
+ npx @soloworks/smking-next install
53
+ ```
54
+
55
+ Scaffolds `app/sitemap.ts`, `app/robots.ts`, and `app/llms.txt/route.ts` with one-line re-exports pointing at the SDK helpers. Idempotent — skips any file that already exists, never overwrites customer code. Reports created vs skipped counts.
56
+
57
+ ### Customer-side usage (after running the CLI, the files are these single lines)
58
+
59
+ ```ts
60
+ // app/sitemap.ts
61
+ export { default } from "@soloworks/smking-next/sitemap";
62
+
63
+ // app/robots.ts
64
+ export { default } from "@soloworks/smking-next/robots";
65
+
66
+ // app/llms.txt/route.ts
67
+ export { GET } from "@soloworks/smking-next/llms-txt";
68
+ ```
69
+
70
+ ### Opt-out
71
+
72
+ Set any of these env vars to `1` to disable takeover for that file (helper falls back to a permissive default or a 404 — your build won't break):
73
+
74
+ ```dotenv
75
+ SMKING_DISABLE_TAKEOVER_SITEMAP=1
76
+ SMKING_DISABLE_TAKEOVER_ROBOTS=1
77
+ SMKING_DISABLE_TAKEOVER_LLMS_TXT=1
78
+ ```
79
+
80
+ ### Why this matters
81
+
82
+ Aligns Next.js parity with `smking/laravel` v0.9.0's path-takeover middleware: both stacks now have a "set and forget" story for the three canonical AEO/SEO infrastructure files. Sites that fail `sitemap_xml` / `robots_txt` in `auditAgentReadiness` will start passing automatically after the install CLI runs once.
83
+
3
84
  ## 0.8.0 — 2026-05-05
4
85
 
5
86
  **Renamed from `@smking/next` → `@soloworks/smking-next`.** First-time npm publish discovered the `smking` scope was already taken on the registry; rather than build a new brand around a different scope, we kept the product name `smking-next` in the package name and shipped under the existing `@soloworks` org. No prior version was ever published on npm under `@smking/next`, so no downstream upgrade pain — first install everywhere is `pnpm add @soloworks/smking-next`.
package/bin/install.ts ADDED
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx @soloworks/smking-next [install|doctor] [--json]`
4
+ *
5
+ * Two subcommands share this entry point (Node 22+ strips TS at runtime, so
6
+ * the package ships .ts source directly — no build step):
7
+ *
8
+ * - **install** (default) — One-shot scaffold for the three takeover
9
+ * drop-in files (sitemap.ts / robots.ts / llms.txt route). Idempotent:
10
+ * already-existing files are skipped, never overwritten.
11
+ *
12
+ * - **doctor** — Self-check (env presence + <SmkingAEO /> usage + API
13
+ * reachable). `--json` flag emits structured output for the
14
+ * @smking/wizard install agent's `run_doctor` MCP tool.
15
+ *
16
+ * Conventional Next.js layout assumed: `app/` at repo root (or under
17
+ * `src/app/` — detected). Both subcommands exit 1 on missing app/.
18
+ */
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+
22
+ // ── install (既有功能) ──────────────────────────────────────────
23
+
24
+ interface FileSpec {
25
+ path: string; // relative to detected app root
26
+ content: string;
27
+ label: string;
28
+ }
29
+
30
+ const FILES: FileSpec[] = [
31
+ {
32
+ path: "sitemap.ts",
33
+ label: "sitemap.ts → /sitemap.xml",
34
+ content:
35
+ 'export { default } from "@soloworks/smking-next/sitemap";\n',
36
+ },
37
+ {
38
+ path: "robots.ts",
39
+ label: "robots.ts → /robots.txt",
40
+ content:
41
+ 'export { default } from "@soloworks/smking-next/robots";\n',
42
+ },
43
+ {
44
+ path: "llms.txt/route.ts",
45
+ label: "llms.txt/route.ts → /llms.txt",
46
+ content:
47
+ 'export { GET } from "@soloworks/smking-next/llms-txt";\n',
48
+ },
49
+ ];
50
+
51
+ function detectAppDir(): string | null {
52
+ const candidates = ["app", "src/app"];
53
+ for (const c of candidates) {
54
+ if (existsSync(c)) return c;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function runInstall(): number {
60
+ const appDir = detectAppDir();
61
+ if (!appDir) {
62
+ console.error(
63
+ "❌ No app/ or src/app/ directory found. Run this from your Next.js project root.",
64
+ );
65
+ return 1;
66
+ }
67
+ console.log(`📂 Using app directory: ${appDir}/\n`);
68
+
69
+ let created = 0;
70
+ let skipped = 0;
71
+ for (const file of FILES) {
72
+ const fullPath = join(appDir, file.path);
73
+ if (existsSync(fullPath)) {
74
+ console.log(`⊝ skip: ${file.label} (already exists)`);
75
+ skipped += 1;
76
+ continue;
77
+ }
78
+ const dir = fullPath.includes("/")
79
+ ? fullPath.substring(0, fullPath.lastIndexOf("/"))
80
+ : appDir;
81
+ mkdirSync(dir, { recursive: true });
82
+ writeFileSync(fullPath, file.content, "utf-8");
83
+ console.log(`✓ created: ${file.label}`);
84
+ created += 1;
85
+ }
86
+
87
+ console.log(
88
+ `\n${created} created, ${skipped} skipped. Set SMKING_API_KEY and SMKING_BASE_URL in your environment.`,
89
+ );
90
+ return 0;
91
+ }
92
+
93
+ // ── doctor (新增) ──────────────────────────────────────────────
94
+
95
+ interface DoctorCheck {
96
+ name: string;
97
+ status: "pass" | "fail" | "info";
98
+ detail: string;
99
+ }
100
+
101
+ function checkEnv(name: string, prefix?: string): DoctorCheck {
102
+ const value = process.env[name];
103
+ if (!value) {
104
+ return {
105
+ name: `${name} set`,
106
+ status: "fail",
107
+ detail: `not set; add ${name}=... to .env.local`,
108
+ };
109
+ }
110
+ if (prefix && !value.startsWith(prefix)) {
111
+ return {
112
+ name: `${name} set`,
113
+ status: "fail",
114
+ detail: `value must start with \`${prefix}\``,
115
+ };
116
+ }
117
+ return {
118
+ name: `${name} set`,
119
+ status: "pass",
120
+ detail: `${value.slice(0, 8)}…`,
121
+ };
122
+ }
123
+
124
+ function checkLayoutUsage(appDir: string): DoctorCheck {
125
+ // Mirror Next.js's layout file resolution. Order matters — TS > JS to
126
+ // match what Next.js itself would pick.
127
+ const candidates = [
128
+ join(appDir, "layout.tsx"),
129
+ join(appDir, "layout.jsx"),
130
+ join(appDir, "layout.ts"),
131
+ join(appDir, "layout.js"),
132
+ ];
133
+ const layoutPath = candidates.find((p) => existsSync(p));
134
+ if (!layoutPath) {
135
+ return {
136
+ name: "<SmkingAEO /> in root layout",
137
+ status: "info",
138
+ detail: `no layout file found under ${appDir}/ — skipped`,
139
+ };
140
+ }
141
+
142
+ const content = readFileSync(layoutPath, "utf-8");
143
+ // Substring check is intentional rather than AST parsing. A customer
144
+ // commenting it out or aliasing the import shows up as fail/info either
145
+ // way, and AST adds a TypeScript parser dependency for trivial value.
146
+ if (!content.includes("SmkingAEO")) {
147
+ return {
148
+ name: "<SmkingAEO /> in root layout",
149
+ status: "fail",
150
+ detail: `${layoutPath} does not import SmkingAEO. Add: import { SmkingAEO } from "@soloworks/smking-next"; then render <SmkingAEO apiKey={process.env.SMKING_API_KEY!} /> inside <body>.`,
151
+ };
152
+ }
153
+ return {
154
+ name: "<SmkingAEO /> in root layout",
155
+ status: "pass",
156
+ detail: layoutPath,
157
+ };
158
+ }
159
+
160
+ async function checkApiReachable(): Promise<DoctorCheck> {
161
+ const apiKey = process.env.SMKING_API_KEY;
162
+ const baseUrl = process.env.SMKING_BASE_URL;
163
+ if (!apiKey || !baseUrl) {
164
+ return {
165
+ name: "API reachable",
166
+ status: "info",
167
+ detail: "skipped — SMKING_API_KEY or SMKING_BASE_URL not set",
168
+ };
169
+ }
170
+
171
+ // Probe the actual AEO endpoint, not the base URL root. Hitting root
172
+ // would pass for any live host (a typo'd domain, google.com); the
173
+ // endpoint either returns the AEO payload or a known auth/validation
174
+ // status — both confirm the route exists.
175
+ const url = `${baseUrl.replace(/\/$/, "")}/api/v1/public/aeo?path=%2F`;
176
+ try {
177
+ const res = await fetch(url, {
178
+ headers: { authorization: `Bearer ${apiKey}` },
179
+ signal: AbortSignal.timeout(3000),
180
+ });
181
+
182
+ if (res.ok) {
183
+ return {
184
+ name: "API reachable",
185
+ status: "pass",
186
+ detail: `${url} → HTTP ${res.status}`,
187
+ };
188
+ }
189
+ if (res.status === 401) {
190
+ return {
191
+ name: "API reachable",
192
+ status: "fail",
193
+ detail: `HTTP 401 from ${url} — SMKING_API_KEY rejected`,
194
+ };
195
+ }
196
+ if (res.status === 404) {
197
+ return {
198
+ name: "API reachable",
199
+ status: "fail",
200
+ detail: `HTTP 404 from ${url} — base_url likely points at the wrong host`,
201
+ };
202
+ }
203
+ if (res.status >= 500) {
204
+ return {
205
+ name: "API reachable",
206
+ status: "fail",
207
+ detail: `upstream HTTP ${res.status} from ${url}`,
208
+ };
209
+ }
210
+ // 4xx (other than 401/404) — likely validation error, endpoint exists
211
+ return {
212
+ name: "API reachable",
213
+ status: "info",
214
+ detail: `${url} → HTTP ${res.status} (endpoint exists)`,
215
+ };
216
+ } catch (err) {
217
+ return {
218
+ name: "API reachable",
219
+ status: "fail",
220
+ detail: `connection failed: ${err instanceof Error ? err.message : String(err)}`,
221
+ };
222
+ }
223
+ }
224
+
225
+ async function runDoctor(jsonOutput: boolean): Promise<number> {
226
+ const appDir = detectAppDir();
227
+ const checks: DoctorCheck[] = [
228
+ checkEnv("SMKING_API_KEY", "pk_"),
229
+ checkEnv("SMKING_BASE_URL", "http"),
230
+ appDir
231
+ ? checkLayoutUsage(appDir)
232
+ : {
233
+ name: "<SmkingAEO /> in root layout",
234
+ status: "info" as const,
235
+ detail: "no app/ or src/app/ directory found — skipped",
236
+ },
237
+ await checkApiReachable(),
238
+ ];
239
+
240
+ const hasFailure = checks.some((c) => c.status === "fail");
241
+
242
+ // JSON mode: parseable structured output for @smking/wizard's run_doctor
243
+ // tool. Stable shape — do not break without bumping wizard version.
244
+ if (jsonOutput) {
245
+ const summary = {
246
+ passed: checks.filter((c) => c.status === "pass").length,
247
+ failed: checks.filter((c) => c.status === "fail").length,
248
+ info: checks.filter((c) => c.status === "info").length,
249
+ ok: !hasFailure,
250
+ };
251
+ console.log(JSON.stringify({ checks, summary }));
252
+ return hasFailure ? 1 : 0;
253
+ }
254
+
255
+ for (const check of checks) {
256
+ const icon =
257
+ check.status === "pass"
258
+ ? "✅"
259
+ : check.status === "fail"
260
+ ? "❌"
261
+ : "ℹ️ ";
262
+ console.log(`${icon} ${check.name} — ${check.detail}`);
263
+ }
264
+ console.log();
265
+ if (hasFailure) {
266
+ console.log("❌ smking: install incomplete — fix the items above.");
267
+ return 1;
268
+ }
269
+ console.log("✅ smking: install OK.");
270
+ return 0;
271
+ }
272
+
273
+ // ── Entry dispatcher ────────────────────────────────────────────
274
+
275
+ async function main(): Promise<number> {
276
+ const subcommand = process.argv[2];
277
+ const jsonOutput = process.argv.includes("--json");
278
+
279
+ if (subcommand === "doctor") {
280
+ return runDoctor(jsonOutput);
281
+ }
282
+ if (subcommand === "install" || subcommand === undefined) {
283
+ return runInstall();
284
+ }
285
+ console.error(
286
+ `Unknown subcommand: ${subcommand}. Available: install (default), doctor.`,
287
+ );
288
+ return 1;
289
+ }
290
+
291
+ main().then((code) => process.exit(code));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soloworks/smking-next",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "AI-native SEO (AEO) for Next.js — auto-inject JSON-LD, FAQ, AI summary, and SEO metadata so AI crawlers (ChatGPT, Perplexity, Google AI) can cite your pages.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/sillyleo/smking/tree/main/packages/smking-next",
@@ -22,10 +22,22 @@
22
22
  "./robots": {
23
23
  "types": "./src/lib/robots.ts",
24
24
  "default": "./src/lib/robots.ts"
25
+ },
26
+ "./sitemap": {
27
+ "types": "./src/lib/sitemap.ts",
28
+ "default": "./src/lib/sitemap.ts"
29
+ },
30
+ "./llms-txt": {
31
+ "types": "./src/lib/llms-txt-route.ts",
32
+ "default": "./src/lib/llms-txt-route.ts"
25
33
  }
26
34
  },
35
+ "bin": {
36
+ "smking-next": "./bin/install.ts"
37
+ },
27
38
  "files": [
28
39
  "src",
40
+ "bin",
29
41
  "README.md",
30
42
  "CHANGELOG.md"
31
43
  ],
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Drop-in `app/llms.txt/route.ts` GET handler. Proxies the canonical
3
+ * llms.txt served by the smking SaaS at `/api/v1/public/llms-txt?key=...`.
4
+ *
5
+ * ```ts
6
+ * // app/llms.txt/route.ts
7
+ * export { GET } from "@soloworks/smking-next/llms-txt";
8
+ * ```
9
+ *
10
+ * The SaaS endpoint generates the markdown index from
11
+ * `product_content where status = 'ready'`, so as soon as the customer
12
+ * approves any new content via the dashboard / chat agent, this file
13
+ * reflects it on the next request (subject to Next.js cache TTL).
14
+ *
15
+ * Opt out: `SMKING_DISABLE_TAKEOVER_LLMS_TXT=1` → 404.
16
+ */
17
+ export async function GET(): Promise<Response> {
18
+ if (process.env.SMKING_DISABLE_TAKEOVER_LLMS_TXT === "1") {
19
+ return new Response("Not found", { status: 404 });
20
+ }
21
+ const apiKey = process.env.SMKING_API_KEY;
22
+ if (!apiKey) {
23
+ return new Response("Not found", { status: 404 });
24
+ }
25
+ const baseUrl = process.env.SMKING_BASE_URL ?? "https://saas.smking.com";
26
+
27
+ try {
28
+ const res = await fetch(
29
+ `${baseUrl}/api/v1/public/llms-txt?key=${encodeURIComponent(apiKey)}`,
30
+ {
31
+ next: { revalidate: 3600 },
32
+ signal: AbortSignal.timeout(10_000),
33
+ },
34
+ );
35
+ if (!res.ok) {
36
+ return new Response("Not found", { status: 404 });
37
+ }
38
+ const body = await res.text();
39
+ return new Response(body, {
40
+ headers: {
41
+ "Content-Type": "text/plain; charset=utf-8",
42
+ "X-Smking-Takeover": "llms_txt",
43
+ },
44
+ });
45
+ } catch {
46
+ return new Response("Not found", { status: 404 });
47
+ }
48
+ }
package/src/lib/robots.ts CHANGED
@@ -166,3 +166,60 @@ function toArray<T>(value: T | T[] | undefined): T[] {
166
166
  if (value === undefined) return [];
167
167
  return Array.isArray(value) ? value : [value];
168
168
  }
169
+
170
+ /**
171
+ * Drop-in `app/robots.ts` default export. Fetches the canonical robots.txt
172
+ * served by the smking SaaS at `/api/v1/public/robots.txt?key=...` and
173
+ * returns a `Response` with `text/plain`. Customer code stays one line:
174
+ *
175
+ * ```ts
176
+ * // app/robots.ts
177
+ * export { default } from "@soloworks/smking-next/robots";
178
+ * ```
179
+ *
180
+ * Next.js detects an `app/robots.ts` default export with a Response return
181
+ * and treats the file as a route handler for `/robots.txt`. This bypasses
182
+ * the `MetadataRoute.Robots` type, which can't carry `Content-Signal:`.
183
+ *
184
+ * Customers who prefer the per-rule helper API (because they want to mix
185
+ * smking's AI bot block with their own rules in a typed `MetadataRoute.Robots`)
186
+ * can still import `smkingRobotsRules` / `smkingRobotsTxt` by name — those
187
+ * named exports remain available from this same module.
188
+ *
189
+ * Opt out: `SMKING_DISABLE_TAKEOVER_ROBOTS=1` returns a permissive
190
+ * `User-agent: * Allow: /` body (build doesn't break). Same for missing
191
+ * `SMKING_API_KEY` — fail-open with the safe default.
192
+ */
193
+ export default async function smkingRobotsRoute(): Promise<Response> {
194
+ const permissiveFallback = () =>
195
+ new Response("User-agent: *\nAllow: /\n", {
196
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
197
+ });
198
+
199
+ if (process.env.SMKING_DISABLE_TAKEOVER_ROBOTS === "1") {
200
+ return permissiveFallback();
201
+ }
202
+ const apiKey = process.env.SMKING_API_KEY;
203
+ if (!apiKey) return permissiveFallback();
204
+ const baseUrl = process.env.SMKING_BASE_URL ?? "https://saas.smking.com";
205
+
206
+ try {
207
+ const res = await fetch(
208
+ `${baseUrl}/api/v1/public/robots.txt?key=${encodeURIComponent(apiKey)}`,
209
+ {
210
+ next: { revalidate: 86_400 },
211
+ signal: AbortSignal.timeout(10_000),
212
+ },
213
+ );
214
+ if (!res.ok) return permissiveFallback();
215
+ const body = await res.text();
216
+ return new Response(body, {
217
+ headers: {
218
+ "Content-Type": "text/plain; charset=utf-8",
219
+ "X-Smking-Takeover": "robots",
220
+ },
221
+ });
222
+ } catch {
223
+ return permissiveFallback();
224
+ }
225
+ }
@@ -0,0 +1,87 @@
1
+ import type { MetadataRoute } from "next";
2
+
3
+ /**
4
+ * Drop-in `app/sitemap.ts` default export. Fetches the canonical sitemap
5
+ * served by the smking SaaS at `/api/v1/public/sitemap.xml?key=...`, parses
6
+ * the XML, and returns Next.js's `MetadataRoute.Sitemap` shape so the
7
+ * framework can build the static sitemap at request time.
8
+ *
9
+ * ```ts
10
+ * // app/sitemap.ts
11
+ * export { default } from "@soloworks/smking-next/sitemap";
12
+ * ```
13
+ *
14
+ * One line of customer code; everything else lives here. The SaaS endpoint
15
+ * derives URLs from `site_pages` (sitemap → wc_api → homepage_crawl →
16
+ * product_content → manual → sdk_traffic discovery tiers) so even sites
17
+ * with no upstream sitemap end up with a complete inventory.
18
+ *
19
+ * Customer can opt out by setting `SMKING_DISABLE_TAKEOVER_SITEMAP=1` —
20
+ * the helper returns an empty array, letting any subsequent sitemap
21
+ * source (e.g. next-sitemap) take over without crashing the build.
22
+ *
23
+ * Required env:
24
+ * - `SMKING_API_KEY` (publishable, the `pk_…` key)
25
+ * - `SMKING_BASE_URL` (defaults to https://saas.smking.com)
26
+ */
27
+ export default async function smkingSitemap(): Promise<MetadataRoute.Sitemap> {
28
+ if (process.env.SMKING_DISABLE_TAKEOVER_SITEMAP === "1") return [];
29
+ const apiKey = process.env.SMKING_API_KEY;
30
+ if (!apiKey) return [];
31
+ const baseUrl = process.env.SMKING_BASE_URL ?? "https://saas.smking.com";
32
+
33
+ let body: string;
34
+ try {
35
+ const res = await fetch(
36
+ `${baseUrl}/api/v1/public/sitemap.xml?key=${encodeURIComponent(apiKey)}`,
37
+ {
38
+ // Daily revalidation — sitemap doesn't change second-to-second.
39
+ // Customer can override via env if their build cadence differs.
40
+ next: { revalidate: 86_400 },
41
+ signal: AbortSignal.timeout(10_000),
42
+ },
43
+ );
44
+ if (!res.ok) return [];
45
+ body = await res.text();
46
+ } catch {
47
+ return [];
48
+ }
49
+
50
+ return parseSitemapEntries(body);
51
+ }
52
+
53
+ /**
54
+ * Pure XML → MetadataRoute.Sitemap conversion. Lives outside the default
55
+ * export so the unit test can drive it without a fetch mock.
56
+ */
57
+ export function parseSitemapEntries(xml: string): MetadataRoute.Sitemap {
58
+ const entries: MetadataRoute.Sitemap = [];
59
+ const urlBlockRe = /<url>([\s\S]*?)<\/url>/g;
60
+ const locRe = /<loc>([^<]+)<\/loc>/;
61
+ const lastmodRe = /<lastmod>([^<]+)<\/lastmod>/;
62
+
63
+ let match: RegExpExecArray | null;
64
+ while ((match = urlBlockRe.exec(xml)) !== null) {
65
+ const block = match[1];
66
+ const loc = block.match(locRe)?.[1]?.trim();
67
+ if (!loc) continue;
68
+ const lastmodStr = block.match(lastmodRe)?.[1]?.trim();
69
+ const lastModified = lastmodStr ? new Date(lastmodStr) : undefined;
70
+ entries.push({
71
+ url: decodeXmlEntities(loc),
72
+ ...(lastModified && !Number.isNaN(lastModified.getTime())
73
+ ? { lastModified }
74
+ : {}),
75
+ });
76
+ }
77
+ return entries;
78
+ }
79
+
80
+ function decodeXmlEntities(s: string): string {
81
+ return s
82
+ .replace(/&amp;/g, "&")
83
+ .replace(/&lt;/g, "<")
84
+ .replace(/&gt;/g, ">")
85
+ .replace(/&quot;/g, '"')
86
+ .replace(/&apos;/g, "'");
87
+ }