@soloworks/smking-next 0.8.0 → 0.9.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 CHANGED
@@ -1,5 +1,52 @@
1
1
  # @soloworks/smking-next
2
2
 
3
+ ## 0.9.0 — 2026-05-12
4
+
5
+ **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.
6
+
7
+ ### New exports
8
+
9
+ - `@soloworks/smking-next/sitemap` — `default` export for `app/sitemap.ts`. Fetches `/api/v1/public/sitemap.xml`, returns Next.js `MetadataRoute.Sitemap` shape.
10
+ - `@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.
11
+ - `@soloworks/smking-next/llms-txt` — `GET` export for `app/llms.txt/route.ts`. Proxies `/api/v1/public/llms-txt`.
12
+
13
+ All three carry an `X-Smking-Takeover: <kind>` response header so audits can confirm the SDK is the one serving.
14
+
15
+ ### One-shot install CLI
16
+
17
+ ```bash
18
+ npx @soloworks/smking-next install
19
+ ```
20
+
21
+ 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.
22
+
23
+ ### Customer-side usage (after running the CLI, the files are these single lines)
24
+
25
+ ```ts
26
+ // app/sitemap.ts
27
+ export { default } from "@soloworks/smking-next/sitemap";
28
+
29
+ // app/robots.ts
30
+ export { default } from "@soloworks/smking-next/robots";
31
+
32
+ // app/llms.txt/route.ts
33
+ export { GET } from "@soloworks/smking-next/llms-txt";
34
+ ```
35
+
36
+ ### Opt-out
37
+
38
+ 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):
39
+
40
+ ```dotenv
41
+ SMKING_DISABLE_TAKEOVER_SITEMAP=1
42
+ SMKING_DISABLE_TAKEOVER_ROBOTS=1
43
+ SMKING_DISABLE_TAKEOVER_LLMS_TXT=1
44
+ ```
45
+
46
+ ### Why this matters
47
+
48
+ 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.
49
+
3
50
  ## 0.8.0 — 2026-05-05
4
51
 
5
52
  **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,85 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `npx @soloworks/smking-next install`
4
+ *
5
+ * One-shot scaffold for the three takeover drop-in files. Idempotent: if a
6
+ * file already exists, we leave it alone and tell the user — never
7
+ * overwrite customer code. Each generated file is one line of re-export
8
+ * pointing at the SDK helper that does the real work.
9
+ *
10
+ * Conventional Next.js layout assumed: `app/` at repo root (or under
11
+ * `src/app/` — detected). The CLI exits 1 if neither exists.
12
+ */
13
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ interface FileSpec {
17
+ path: string; // relative to detected app root
18
+ content: string;
19
+ label: string;
20
+ }
21
+
22
+ const FILES: FileSpec[] = [
23
+ {
24
+ path: "sitemap.ts",
25
+ label: "sitemap.ts → /sitemap.xml",
26
+ content:
27
+ 'export { default } from "@soloworks/smking-next/sitemap";\n',
28
+ },
29
+ {
30
+ path: "robots.ts",
31
+ label: "robots.ts → /robots.txt",
32
+ content:
33
+ 'export { default } from "@soloworks/smking-next/robots";\n',
34
+ },
35
+ {
36
+ path: "llms.txt/route.ts",
37
+ label: "llms.txt/route.ts → /llms.txt",
38
+ content:
39
+ 'export { GET } from "@soloworks/smking-next/llms-txt";\n',
40
+ },
41
+ ];
42
+
43
+ function detectAppDir(): string | null {
44
+ const candidates = ["app", "src/app"];
45
+ for (const c of candidates) {
46
+ if (existsSync(c)) return c;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function main(): number {
52
+ const appDir = detectAppDir();
53
+ if (!appDir) {
54
+ console.error(
55
+ "❌ No app/ or src/app/ directory found. Run this from your Next.js project root.",
56
+ );
57
+ return 1;
58
+ }
59
+ console.log(`📂 Using app directory: ${appDir}/\n`);
60
+
61
+ let created = 0;
62
+ let skipped = 0;
63
+ for (const file of FILES) {
64
+ const fullPath = join(appDir, file.path);
65
+ if (existsSync(fullPath)) {
66
+ console.log(`⊝ skip: ${file.label} (already exists)`);
67
+ skipped += 1;
68
+ continue;
69
+ }
70
+ const dir = fullPath.includes("/")
71
+ ? fullPath.substring(0, fullPath.lastIndexOf("/"))
72
+ : appDir;
73
+ mkdirSync(dir, { recursive: true });
74
+ writeFileSync(fullPath, file.content, "utf-8");
75
+ console.log(`✓ created: ${file.label}`);
76
+ created += 1;
77
+ }
78
+
79
+ console.log(
80
+ `\n${created} created, ${skipped} skipped. Set SMKING_API_KEY and SMKING_BASE_URL in your environment.`,
81
+ );
82
+ return 0;
83
+ }
84
+
85
+ process.exit(main());
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.0",
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
+ }