@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 +81 -0
- package/bin/install.ts +291 -0
- package/package.json +13 -1
- package/src/lib/llms-txt-route.ts +48 -0
- package/src/lib/robots.ts +57 -0
- package/src/lib/sitemap.ts +87 -0
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.
|
|
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(/&/g, "&")
|
|
83
|
+
.replace(/</g, "<")
|
|
84
|
+
.replace(/>/g, ">")
|
|
85
|
+
.replace(/"/g, '"')
|
|
86
|
+
.replace(/'/g, "'");
|
|
87
|
+
}
|