@soloworks/smking-next 0.8.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,109 @@
1
+ # @soloworks/smking-next
2
+
3
+ ## 0.8.0 — 2026-05-05
4
+
5
+ **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`.
6
+
7
+ Adds AI bot rules + Cloudflare `Content-Signal` directive helpers, mirroring the new `smking:publish-robots` artisan command in `smking/laravel` v0.8.0. Same defect surfaced from an `isitagentready.com` audit on a customer storefront — two of the four required `robots.txt` agent-readiness checks (RFC 9309 AI bot rules, Cloudflare Content-Signal) require the customer's `robots.txt` to carry directives the SDK can't influence post-render.
8
+
9
+ ### Version jump 0.3 → 0.8
10
+
11
+ Cross-package version sync with `smking/laravel`. Both SDKs now ship the same robots feature in the same release; pinning to a matching version on both stacks (e.g. internal monorepo with both Next.js and Laravel surfaces) avoids drift confusion. Future releases will keep the major.minor in sync where the change is conceptual parity, even when only one package has code changes.
12
+
13
+ This skips published 0.4 / 0.5 / 0.6 / 0.7 lines on npm. None of those versions exist on the registry — 0.3.0 is the previous published version, and 0.8.0 is a strict superset (everything 0.3.0 exported is still there at the same import path with the same signature). No deprecations, no removals.
14
+
15
+ ### feat: `@soloworks/smking-next/robots` — two helpers, two surfaces
16
+
17
+ Next.js's `MetadataRoute.Robots` type doesn't model Cloudflare's `Content-Signal:` directive, so the package ships both shapes:
18
+
19
+ ```ts
20
+ // app/robots.ts — Next.js MetadataRoute (NO Content-Signal)
21
+ import type { MetadataRoute } from 'next';
22
+ import { smkingRobotsRules } from '@soloworks/smking-next/robots';
23
+
24
+ export default function robots(): MetadataRoute.Robots {
25
+ return {
26
+ rules: [
27
+ { userAgent: '*', disallow: ['/admin/'] },
28
+ ...smkingRobotsRules(),
29
+ ],
30
+ sitemap: 'https://example.com/sitemap.xml',
31
+ };
32
+ }
33
+ ```
34
+
35
+ ```ts
36
+ // app/robots.txt/route.ts — full robots.txt body (Content-Signal included)
37
+ import { smkingRobotsTxt } from '@soloworks/smking-next/robots';
38
+
39
+ export function GET() {
40
+ return new Response(
41
+ smkingRobotsTxt({
42
+ rules: [{ userAgent: '*', disallow: ['/admin/'] }],
43
+ sitemap: 'https://example.com/sitemap.xml',
44
+ }),
45
+ { headers: { 'Content-Type': 'text/plain' } },
46
+ );
47
+ }
48
+ ```
49
+
50
+ Customer rules render *before* the smking AI bot block in both surfaces. `bots` config replaces (not merges) the default list — `{ CCBot: 'disallow' }` produces only one bot block, not eight. `contentSignal: null` (or `""`) drops the directive entirely; passing nothing uses the default `search=yes, ai-input=no, ai-train=no`.
51
+
52
+ ### Default policy
53
+
54
+ Allow major search + AI crawlers (GPTBot, ChatGPT-User, ClaudeBot, PerplexityBot, Google-Extended, Bingbot, Applebot-Extended). Emit Content-Signal that permits search indexing but disallows AI training/input. Override per-bot or globally.
55
+
56
+ ### Tests
57
+
58
+ 12 new tests in `__tests__/robots.test.ts` covering rules-array shape, customer rules ordering, array-typed userAgent/allow/disallow inputs, contentSignal omission semantics, sitemap + host emission, custom-bots policy replacement, and trailing-newline normalisation. Total suite: 59 tests, all passing.
59
+
60
+ ### Why two functions and not one
61
+
62
+ `smkingRobotsRules()` returns objects shaped to `MetadataRoute.Robots['rules']`, which is the framework-native path and what most customers will reach for. `smkingRobotsTxt()` returns a string and lets the customer set arbitrary directives Next.js doesn't model — Content-Signal today, anything else tomorrow. Forcing customers who only need bot rules onto the route-handler path would be over-engineering; forcing customers who need Content-Signal onto MetadataRoute would be impossible.
63
+
64
+ ## 0.3.0 — 2026-04-29
65
+
66
+ Minimal-surface rewrite. The package is now three focused files instead of a 21-task SDK.
67
+
68
+ ### What's in
69
+
70
+ - **`<SmkingAEO />`** server component. Place once in root layout. Auto-resolves request path via `headers()`, fetches per-URL AEO content from the smking SaaS, and emits:
71
+ - JSON-LD `<script>` for AI crawlers
72
+ - `<title>` / `og:*` / `<meta name="description">` head tags (React 19 head hoist)
73
+ - sr-only `<div>` with FAQ, AI summary, product image (in-DOM for crawlers, visually hidden)
74
+ - **`getAeoContent(params)`** — fetch helper with 2s `AbortSignal.timeout`, 1h ISR backstop, and `next.tags: ['smking:path:<path>']` for webhook-driven invalidation.
75
+ - **`@soloworks/smking-next/route`** — drop-in `POST` + `GET` handlers for the customer's `/api/smking-revalidate` endpoint. Bearer-token auth + per-path `revalidateTag`.
76
+
77
+ ### Public surface
78
+
79
+ ```ts
80
+ import { SmkingAEO, getAeoContent } from '@soloworks/smking-next';
81
+ import { POST, GET } from '@soloworks/smking-next/route';
82
+ ```
83
+
84
+ Plus types: `AeoResponse`, `AeoStatus`, `SeoMeta`, `FaqItem`, `ChatLinks`, `DiscoverParams`.
85
+
86
+ ### What's not in (vs. earlier v0.3 design)
87
+
88
+ These were dropped after PR review surfaced them as Laravel parity for parity's sake:
89
+
90
+ - `withSmkingMetadata` HOF — React 19 head dedup + Next.js Metadata API order handles precedence. Customer writes `generateMetadata` to override; doesn't write it to use smking's defaults.
91
+ - `getSmkingHtmlAttrs` — `<html data-smking-injected>` mark was for `doctor` to inspect; both removed.
92
+ - `createSmkingMiddleware` factory — markdown content negotiation, alternate Link header, X-Smking-Path: AI crawlers consume HTML; markdown is a future feature.
93
+ - `serveMarkdown` — same.
94
+ - `defineSmkingConfig` schema — three component props are enough; YAML-style config is over-engineering.
95
+ - `npx @soloworks/smking-next init` codemod (jscodeshift, commander, tsx, fast-glob deps) — two files to paste, no tooling.
96
+ - `npx @soloworks/smking-next doctor` — install too simple to need verification.
97
+ - `npx @soloworks/smking-next status` — no circuit breaker, no status to query.
98
+ - Per-surface circuit breaker (in-memory tombstone) — `AbortSignal.timeout(2000)` + Next.js ISR cache cover the same outage scenarios.
99
+ - `MockSmkingProvider` — customers `vi.mock('@soloworks/smking-next')` directly.
100
+ - `<SmkingDevtools />` — Vercel logs / network tab show injection state.
101
+ - `visibility` prop (sr_only / visible / noscript) — sr-only is the only sensible option for AEO content.
102
+ - Test environment auto-skip — fetch is mocked in customer tests anyway.
103
+ - Defense-in-depth fallback head tag layer — single component owns all SEO emission.
104
+
105
+ Behavior covered by the three files matches the original design intent (per-URL AEO injection + outage tolerance + push-driven update). The Laravel SDK's `smking/laravel` four-tier TTL + circuit breaker + doctor all map cleanly to Next.js's declarative cache + webhook + ISR — no need for an SDK reimplementation.
106
+
107
+ ## 0.1.0 — 2026-04-28
108
+
109
+ Initial release: `<SmkingAEO />` + `getSmkingMetadata` + `mergeMetadata` (the metadata helpers were dropped in 0.3.0).
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # @soloworks/smking-next
2
+
3
+ AI-native SEO (AEO) for Next.js. One server component injects JSON-LD, OG tags, AI summary, and FAQ on every page so AI crawlers (ChatGPT, Perplexity, Google AI) can cite your content.
4
+
5
+ - **One server component** in your root layout — every URL gets its own AEO content automatically (`/products/nike-air`, `/products/adidas/red`, anything dynamic, no codemod needed).
6
+ - **Fail-fast, fail-open.** 2-second timeout + Next.js ISR — if smking is down, your page renders without injection. Never blocks.
7
+ - **Push updates** — webhook handler invalidates only the changed paths via `revalidateTag`.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @soloworks/smking-next
13
+ ```
14
+
15
+ Set two environment variables:
16
+
17
+ ```bash
18
+ SMKING_API_KEY=pk_... # public key from smking dashboard
19
+ SMKING_BASE_URL=https://... # smking deployment origin
20
+ ```
21
+
22
+ ### 1. Drop the component into your root layout
23
+
24
+ ```tsx
25
+ // app/layout.tsx
26
+ import { SmkingAEO } from '@soloworks/smking-next';
27
+
28
+ export default function RootLayout({
29
+ children,
30
+ }: {
31
+ children: React.ReactNode;
32
+ }) {
33
+ return (
34
+ <html lang="en">
35
+ <body>
36
+ <SmkingAEO apiKey={process.env.SMKING_API_KEY!} />
37
+ {children}
38
+ </body>
39
+ </html>
40
+ );
41
+ }
42
+ ```
43
+
44
+ That's it for AEO injection. Every request to any URL fetches that URL's AEO content from smking and emits:
45
+
46
+ - `<script type="application/ld+json">` for AI crawlers
47
+ - `<title>`, `<meta name="description">`, `og:*` head tags
48
+ - sr-only `<div>` containing FAQ, AI summary, and product image (visually hidden, in-DOM for crawlers)
49
+
50
+ ### 2. Wire the webhook for instant cache invalidation
51
+
52
+ ```ts
53
+ // app/api/smking-revalidate/route.ts
54
+ export { POST, GET } from '@soloworks/smking-next/route';
55
+ ```
56
+
57
+ Add to environment:
58
+
59
+ ```bash
60
+ SMKING_WEBHOOK_TOKEN=... # any random string; share with smking SaaS
61
+ ```
62
+
63
+ Configure smking SaaS to POST `{ paths: [...] }` to your `/api/smking-revalidate` endpoint with `Authorization: Bearer <token>`. The handler calls `revalidateTag('smking:path:<path>')` for each path.
64
+
65
+ ## How metadata wins / loses
66
+
67
+ Both `<SmkingAEO />` and your own `generateMetadata` emit head tags. Next.js + React 19 head dedup applies last-write-wins:
68
+
69
+ - **No `generateMetadata`** → smking's `<title>` / `og:*` are used.
70
+ - **You write `generateMetadata` in a layout / page** → your tags override smking's for that route segment.
71
+
72
+ This is the pattern: smking provides AEO/SEO baseline, you override per-page when you want. No HOF, no codemod.
73
+
74
+ For client pages (`'use client'`) that need dynamic metadata, write a sibling `layout.tsx` with `generateMetadata` — standard Next.js workflow, unrelated to smking.
75
+
76
+ ## How outage tolerance works
77
+
78
+ `getAeoContent` wraps `fetch` with `AbortSignal.timeout(2000)` and Next.js ISR (`next: { revalidate: 3600, tags: ['smking:path:<path>'] }`):
79
+
80
+ - **Cache hit** (the common path): zero network. Tags allow webhook-driven invalidation.
81
+ - **Cache miss + smking healthy**: one network roundtrip, response cached for 1h.
82
+ - **Cache miss + smking down / hung**: returns null after at most 2s, page renders without injection. Next.js ISR retries on the next request after `revalidate`.
83
+ - **5xx / 4xx / parse error**: same fail-open path.
84
+
85
+ No circuit breaker, no retry, no status command — Next.js infrastructure already covers what those would do.
86
+
87
+ ## API
88
+
89
+ ### `<SmkingAEO />` props
90
+
91
+ ```ts
92
+ interface SmkingAEOProps {
93
+ apiKey: string; // required
94
+ baseUrl?: string; // override SMKING_BASE_URL env
95
+ path?: string; // explicit path; auto-resolved from headers() otherwise
96
+ revalidate?: number; // ISR seconds; default 3600 (1h)
97
+ }
98
+ ```
99
+
100
+ Path auto-detection works for any URL schema (`/products/[slug]`, `/shop/[cat]/[id]/[variant]`). Pass `path` explicitly only for static-export contexts where `headers()` is unavailable.
101
+
102
+ ### `getAeoContent(params)`
103
+
104
+ Lower-level helper if you want to fetch the AEO response and render yourself. Same params, returns `Promise<AeoResponse | null>`.
105
+
106
+ ```ts
107
+ import { getAeoContent } from '@soloworks/smking-next';
108
+
109
+ const aeo = await getAeoContent({ apiKey: ..., path: '/products/abc' });
110
+ if (aeo?.status === 'ready') {
111
+ // aeo.jsonLd, aeo.faq, aeo.summary, aeo.seo, aeo.chatLinks, ...
112
+ }
113
+ ```
114
+
115
+ ### Webhook payload
116
+
117
+ ```json
118
+ POST /api/smking-revalidate
119
+ Authorization: Bearer <SMKING_WEBHOOK_TOKEN>
120
+ Content-Type: application/json
121
+
122
+ { "paths": ["/products/abc", "/products/xyz"] }
123
+ ```
124
+
125
+ Response: `{ revalidated: number, errors: number }`. `errors > 0` means some tags couldn't be revalidated (others still succeeded — partial-success delivery).
126
+
127
+ ## Versions
128
+
129
+ See [CHANGELOG.md](./CHANGELOG.md). Aligned with `smking/laravel` for SaaS-side parity.
130
+
131
+ ## License
132
+
133
+ MIT
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@soloworks/smking-next",
3
+ "version": "0.8.0",
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
+ "license": "MIT",
6
+ "homepage": "https://github.com/sillyleo/smking/tree/main/packages/smking-next",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/sillyleo/smking",
10
+ "directory": "packages/smking-next"
11
+ },
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./src/index.ts",
16
+ "default": "./src/index.ts"
17
+ },
18
+ "./route": {
19
+ "types": "./src/route.ts",
20
+ "default": "./src/route.ts"
21
+ },
22
+ "./robots": {
23
+ "types": "./src/lib/robots.ts",
24
+ "default": "./src/lib/robots.ts"
25
+ }
26
+ },
27
+ "files": [
28
+ "src",
29
+ "README.md",
30
+ "CHANGELOG.md"
31
+ ],
32
+ "keywords": [
33
+ "nextjs",
34
+ "react",
35
+ "seo",
36
+ "aeo",
37
+ "ai",
38
+ "json-ld",
39
+ "schema",
40
+ "chatgpt",
41
+ "perplexity",
42
+ "smking"
43
+ ],
44
+ "peerDependencies": {
45
+ "next": "^15.0.0 || ^16.0.0",
46
+ "react": "^18.0.0 || ^19.0.0"
47
+ },
48
+ "devDependencies": {
49
+ "@testing-library/jest-dom": "^6.9.1",
50
+ "@testing-library/react": "^16.3.2",
51
+ "@types/react": "^19.0.0",
52
+ "@vitejs/plugin-react": "^6.0.1",
53
+ "jsdom": "^29.1.0",
54
+ "next": "^16.2.2",
55
+ "react": "^19.0.0",
56
+ "typescript": "^5",
57
+ "vitest": "^4.1.5"
58
+ },
59
+ "scripts": {
60
+ "typecheck": "tsc --noEmit",
61
+ "test": "vitest run",
62
+ "test:watch": "vitest"
63
+ }
64
+ }
@@ -0,0 +1,134 @@
1
+ import type { DiscoverParams } from "../types";
2
+ import { getAeoContent } from "../lib/client";
3
+
4
+ const SR_ONLY_STYLE: React.CSSProperties = {
5
+ position: "absolute",
6
+ width: "1px",
7
+ height: "1px",
8
+ padding: 0,
9
+ margin: "-1px",
10
+ overflow: "hidden",
11
+ clip: "rect(0,0,0,0)",
12
+ whiteSpace: "nowrap",
13
+ border: 0,
14
+ };
15
+
16
+ export interface SmkingAEOProps extends DiscoverParams {}
17
+
18
+ /**
19
+ * Server Component that injects AEO + SEO content for the current
20
+ * request. Place once in the root layout, inside `<body>`:
21
+ *
22
+ * ```tsx
23
+ * import { SmkingAEO } from '@soloworks/smking-next';
24
+ *
25
+ * export default function RootLayout({ children }) {
26
+ * return (
27
+ * <html>
28
+ * <body>
29
+ * <SmkingAEO apiKey={process.env.SMKING_API_KEY!} />
30
+ * {children}
31
+ * </body>
32
+ * </html>
33
+ * );
34
+ * }
35
+ * ```
36
+ *
37
+ * Each request the component reads the path from request headers, fetches
38
+ * the SaaS for that exact URL, and emits:
39
+ *
40
+ * - JSON-LD `<script>` for AI crawlers
41
+ * - `<title>`, `<meta name="description">`, `og:*` head tags (React 19
42
+ * hoists these into `<head>`; if a deeper layout / page also sets
43
+ * them, Next.js's last-write-wins semantics let the host override)
44
+ * - sr-only body fragments containing FAQ HTML, AI summary, and product
45
+ * image — visually hidden but in the DOM for AI crawlers
46
+ *
47
+ * Returns null when the response isn't `ready` (pending / not_found /
48
+ * unreachable / mis-configured). Fail-open by design: the page renders
49
+ * without injection rather than throwing.
50
+ *
51
+ * Security: dangerouslySetInnerHTML is bounded to two trusted sources —
52
+ * JSON-LD (escaped via `safeJson`) and SaaS-built HTML fragments
53
+ * (server-side `escapeHtml` on every user-facing field). If you point
54
+ * `baseUrl` at a custom origin, audit that origin's escape behavior
55
+ * before deploying — the trust boundary is "smking SaaS output", not
56
+ * arbitrary content fetched anywhere.
57
+ */
58
+ export async function SmkingAEO(props: SmkingAEOProps) {
59
+ const aeo = await getAeoContent(props);
60
+ if (!aeo || aeo.status !== "ready") return null;
61
+
62
+ const seo = aeo.seo ?? null;
63
+ const description = seo?.ogDescription ?? aeo.metaDescription ?? null;
64
+ const jsonLdString = aeo.jsonLd ? safeJson(aeo.jsonLd) : null;
65
+ const hasBodyFragments = Boolean(
66
+ aeo.summaryHtml || aeo.faqHtml || seo?.ogImageUrl,
67
+ );
68
+
69
+ return (
70
+ <>
71
+ {jsonLdString && (
72
+ <script
73
+ type="application/ld+json"
74
+ data-smking="aeo"
75
+ dangerouslySetInnerHTML={{ __html: jsonLdString }}
76
+ />
77
+ )}
78
+
79
+ {seo?.title && <title data-smking="aeo">{seo.title}</title>}
80
+ {description && (
81
+ <meta name="description" content={description} data-smking="aeo" />
82
+ )}
83
+ {seo?.ogTitle && (
84
+ <meta property="og:title" content={seo.ogTitle} data-smking="aeo" />
85
+ )}
86
+ {seo?.ogDescription && (
87
+ <meta
88
+ property="og:description"
89
+ content={seo.ogDescription}
90
+ data-smking="aeo"
91
+ />
92
+ )}
93
+ {seo?.ogImageUrl && (
94
+ <meta
95
+ property="og:image"
96
+ content={seo.ogImageUrl}
97
+ data-smking="aeo"
98
+ />
99
+ )}
100
+
101
+ {hasBodyFragments && (
102
+ <div data-smking="aeo" style={SR_ONLY_STYLE}>
103
+ {aeo.summaryHtml && (
104
+ <div dangerouslySetInnerHTML={{ __html: aeo.summaryHtml }} />
105
+ )}
106
+ {aeo.faqHtml && (
107
+ <div dangerouslySetInnerHTML={{ __html: aeo.faqHtml }} />
108
+ )}
109
+ {seo?.ogImageUrl && (
110
+ <img
111
+ src={seo.ogImageUrl}
112
+ alt={seo.ogTitle ?? seo.title ?? ""}
113
+ loading="lazy"
114
+ />
115
+ )}
116
+ </div>
117
+ )}
118
+ </>
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Escape JSON for safe `<script>` embedding. Covers the three values
124
+ * that can break out:
125
+ * - `</` (any `</script>`-like sequence)
126
+ * - U+2028 / U+2029 (treated as line terminators inside a JS string
127
+ * literal, which would corrupt the embedded JSON)
128
+ */
129
+ function safeJson(value: Record<string, unknown>): string {
130
+ return JSON.stringify(value)
131
+ .replace(/<\//g, "<\\/")
132
+ .replace(/\u2028/g, "\\u2028")
133
+ .replace(/\u2029/g, "\\u2029");
134
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { SmkingAEO } from "./components/smking-aeo";
2
+ export { getAeoContent } from "./lib/client";
3
+ export type {
4
+ AeoResponse,
5
+ AeoStatus,
6
+ ChatLinks,
7
+ DiscoverParams,
8
+ FaqItem,
9
+ SeoMeta,
10
+ } from "./types";
@@ -0,0 +1,95 @@
1
+ // Type-only import loads Next.js's RequestInit augmentation so the
2
+ // `next: { revalidate, tags }` property on fetch options typechecks.
3
+ import type {} from "next";
4
+
5
+ import type { AeoResponse, DiscoverParams } from "../types";
6
+ import { normalizePath, resolveRequestPath } from "./path";
7
+
8
+ const DEFAULT_REVALIDATE_SECONDS = 3600;
9
+ const FETCH_TIMEOUT_MS = 2000;
10
+
11
+ const _warnedKeys = new Set<string>();
12
+ function warnOnce(key: string, message: string): void {
13
+ if (process.env.NODE_ENV === "production") return;
14
+ if (_warnedKeys.has(key)) return;
15
+ _warnedKeys.add(key);
16
+ console.warn(message);
17
+ }
18
+
19
+ /**
20
+ * Fetch AEO content for a path. Returns null when:
21
+ * - apiKey or baseUrl missing (fail-open in dev / staging; one-time
22
+ * dev warning so customers notice the misconfiguration)
23
+ * - request scope unavailable and no path passed
24
+ * - network failure / 2s timeout (next request retries via ISR)
25
+ * - 4xx / 5xx response
26
+ * - JSON parse failure
27
+ *
28
+ * On success, cached by Next.js data cache for 1h (ISR backstop). Tagged
29
+ * with `smking:path:<path>` so the webhook handler at `@soloworks/smking-next/route`
30
+ * can `revalidateTag` to invalidate instantly when SaaS pushes an update.
31
+ *
32
+ * Sends `{ key, path, url }` — `url` is required for first-sight
33
+ * registration: when the SaaS hasn't seen this path before it queues a
34
+ * background crawl using the URL. Without it, brand-new pages never
35
+ * leave `not_found`.
36
+ *
37
+ * No circuit breaker / retry / status command — Next.js fetch + ISR +
38
+ * AbortSignal already cover the outage scenarios. A hung upstream returns
39
+ * null after 2s and the page renders without injection (fail-open).
40
+ */
41
+ export async function getAeoContent(
42
+ params: DiscoverParams,
43
+ ): Promise<AeoResponse | null> {
44
+ if (!params.apiKey) {
45
+ warnOnce(
46
+ "missing-api-key",
47
+ "[@soloworks/smking-next] apiKey is empty — skipping AEO injection. Set SMKING_API_KEY or pass apiKey prop.",
48
+ );
49
+ return null;
50
+ }
51
+
52
+ const baseUrl = (params.baseUrl ?? process.env.SMKING_BASE_URL)?.replace(
53
+ /\/$/,
54
+ "",
55
+ );
56
+ if (!baseUrl) {
57
+ warnOnce(
58
+ "missing-base-url",
59
+ "[@soloworks/smking-next] SMKING_BASE_URL is not configured — skipping AEO injection. Set the env var or pass baseUrl prop.",
60
+ );
61
+ return null;
62
+ }
63
+
64
+ let path = params.path;
65
+ let url = params.url;
66
+ if (!path || !url) {
67
+ try {
68
+ const resolved = await resolveRequestPath();
69
+ path = path ?? resolved.path;
70
+ url = url ?? resolved.url;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+ path = normalizePath(path);
76
+
77
+ try {
78
+ const res = await fetch(`${baseUrl}/api/v1/public/aeo`, {
79
+ method: "POST",
80
+ headers: { "content-type": "application/json" },
81
+ body: JSON.stringify({ key: params.apiKey, path, url }),
82
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
83
+ next: {
84
+ revalidate: params.revalidate ?? DEFAULT_REVALIDATE_SECONDS,
85
+ tags: [`smking:path:${path}`],
86
+ },
87
+ });
88
+ if (!res.ok) return null;
89
+ return (await res.json()) as AeoResponse;
90
+ } catch {
91
+ // Network failure, 2s timeout, or JSON parse error → fail-open.
92
+ // The next request after `revalidate` retries via Next.js ISR.
93
+ return null;
94
+ }
95
+ }
@@ -0,0 +1,51 @@
1
+ import { headers } from "next/headers";
2
+
3
+ /**
4
+ * Resolve the current request's path + absolute URL from Next.js request
5
+ * headers. Used by `<SmkingAEO />` when the caller didn't pass `path`
6
+ * explicitly.
7
+ *
8
+ * `url` is needed by the SaaS for first-sight registration: when a path
9
+ * is hit for the first time and no content row exists, the API uses
10
+ * `url` to enqueue a background crawl. Omitting it (as a previous
11
+ * minimal pass did) breaks first-sight discovery — every brand-new page
12
+ * stays `not_found` forever instead of getting registered.
13
+ *
14
+ * Throws if called outside a request scope (e.g. during static export
15
+ * without `generateStaticParams`). Callers should pass `path` explicitly
16
+ * for static-export pages.
17
+ */
18
+ export async function resolveRequestPath(): Promise<{
19
+ path: string;
20
+ url: string;
21
+ }> {
22
+ const h = await headers();
23
+
24
+ const xUrl = h.get("x-url");
25
+ if (xUrl) {
26
+ try {
27
+ const u = new URL(xUrl);
28
+ return { path: u.pathname, url: xUrl };
29
+ } catch {
30
+ // fall through to header-derived URL
31
+ }
32
+ }
33
+
34
+ const xPathname = h.get("x-pathname") ?? h.get("x-invoke-path");
35
+ const host = h.get("x-forwarded-host") ?? h.get("host");
36
+ const proto = h.get("x-forwarded-proto") ?? "https";
37
+ const path = xPathname ?? "/";
38
+ const url = host ? `${proto}://${host}${path}` : path;
39
+ return { path, url };
40
+ }
41
+
42
+ /**
43
+ * Normalize a path so cache tags align across hand-written and
44
+ * auto-resolved callers. `<SmkingAEO path="products/abc" />` and
45
+ * `<SmkingAEO path="/products/abc" />` must hit the same Next.js cache
46
+ * entry — otherwise the webhook handler's `revalidateTag('smking:path:/products/abc')`
47
+ * misses the version of the entry that lacks the leading slash.
48
+ */
49
+ export function normalizePath(path: string): string {
50
+ return path.startsWith("/") ? path : "/" + path;
51
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * AI-bot directives + Cloudflare `Content-Signal` for the customer's
3
+ * `robots.txt`. Two surfaces because Next.js `MetadataRoute.Robots` doesn't
4
+ * model `Content-Signal:` — customers who care about that directive must
5
+ * serve from a route handler that returns `text/plain` directly.
6
+ *
7
+ * - `smkingRobotsRules()` — drop into `app/robots.ts` rules array.
8
+ * Bot User-agent blocks only; no Content-Signal (Next.js limitation).
9
+ * - `smkingRobotsTxt()` — full robots.txt body string for
10
+ * `app/robots.txt/route.ts`. Includes Content-Signal directive.
11
+ */
12
+
13
+ export type SmkingRobotsRule = "allow" | "disallow";
14
+
15
+ export type SmkingRobotsBots = Record<string, SmkingRobotsRule>;
16
+
17
+ export interface SmkingRobotsConfig {
18
+ /** Override the bot list. Default policy below. */
19
+ bots?: SmkingRobotsBots;
20
+ /**
21
+ * Cloudflare Content-Signal directive value. Default permits search
22
+ * indexing but disallows AI training/input use. Pass `null` to omit
23
+ * the directive entirely; pass an empty string for the same effect.
24
+ */
25
+ contentSignal?: string | null;
26
+ }
27
+
28
+ /** Default bot list — major search + AI crawlers, all allowed. */
29
+ export const SMKING_DEFAULT_BOTS: SmkingRobotsBots = {
30
+ GPTBot: "allow",
31
+ "ChatGPT-User": "allow",
32
+ ClaudeBot: "allow",
33
+ PerplexityBot: "allow",
34
+ "Google-Extended": "allow",
35
+ Bingbot: "allow",
36
+ "Applebot-Extended": "allow",
37
+ };
38
+
39
+ /** Default Content-Signal: allow search indexing, deny AI training. */
40
+ export const SMKING_DEFAULT_CONTENT_SIGNAL =
41
+ "search=yes, ai-input=no, ai-train=no";
42
+
43
+ /**
44
+ * One rule per bot, suitable for spreading into a Next.js `app/robots.ts`
45
+ * `MetadataRoute.Robots['rules']` array.
46
+ *
47
+ * ```ts
48
+ * // app/robots.ts
49
+ * import type { MetadataRoute } from 'next';
50
+ * import { smkingRobotsRules } from '@soloworks/smking-next/robots';
51
+ *
52
+ * export default function robots(): MetadataRoute.Robots {
53
+ * return {
54
+ * rules: [
55
+ * { userAgent: '*', disallow: ['/admin/'] },
56
+ * ...smkingRobotsRules(),
57
+ * ],
58
+ * sitemap: 'https://example.com/sitemap.xml',
59
+ * };
60
+ * }
61
+ * ```
62
+ *
63
+ * NOTE: `MetadataRoute.Robots` doesn't model `Content-Signal:` —
64
+ * customers who need that directive should switch to
65
+ * `smkingRobotsTxt()` and serve via a route handler.
66
+ */
67
+ export function smkingRobotsRules(
68
+ config: SmkingRobotsConfig = {},
69
+ ): Array<{
70
+ userAgent: string;
71
+ allow?: string;
72
+ disallow?: string;
73
+ }> {
74
+ const bots = config.bots ?? SMKING_DEFAULT_BOTS;
75
+ return Object.entries(bots).map(([userAgent, rule]) =>
76
+ rule === "disallow"
77
+ ? { userAgent, disallow: "/" }
78
+ : { userAgent, allow: "/" },
79
+ );
80
+ }
81
+
82
+ export interface SmkingRobotsTxtRule {
83
+ userAgent: string | string[];
84
+ allow?: string | string[];
85
+ disallow?: string | string[];
86
+ }
87
+
88
+ export interface SmkingRobotsTxtOptions extends SmkingRobotsConfig {
89
+ /** Customer rules emitted *before* the smking AI bot block. */
90
+ rules?: SmkingRobotsTxtRule[];
91
+ /** One or more Sitemap entries, emitted at the end of the file. */
92
+ sitemap?: string | string[];
93
+ /** Optional `Host:` directive, emitted after Sitemap. */
94
+ host?: string;
95
+ }
96
+
97
+ /**
98
+ * Full robots.txt body string including AI bot rules + Content-Signal.
99
+ * Use from a route handler:
100
+ *
101
+ * ```ts
102
+ * // app/robots.txt/route.ts
103
+ * import { smkingRobotsTxt } from '@soloworks/smking-next/robots';
104
+ *
105
+ * export function GET() {
106
+ * return new Response(
107
+ * smkingRobotsTxt({
108
+ * rules: [{ userAgent: '*', disallow: ['/admin/'] }],
109
+ * sitemap: 'https://example.com/sitemap.xml',
110
+ * }),
111
+ * { headers: { 'Content-Type': 'text/plain' } },
112
+ * );
113
+ * }
114
+ * ```
115
+ *
116
+ * Content-Signal is omitted when `contentSignal` is `null` or `""`.
117
+ */
118
+ export function smkingRobotsTxt(options: SmkingRobotsTxtOptions = {}): string {
119
+ const lines: string[] = [];
120
+
121
+ for (const rule of options.rules ?? []) {
122
+ for (const ua of toArray(rule.userAgent)) {
123
+ lines.push(`User-agent: ${ua}`);
124
+ }
125
+ for (const path of toArray(rule.allow)) {
126
+ lines.push(`Allow: ${path}`);
127
+ }
128
+ for (const path of toArray(rule.disallow)) {
129
+ lines.push(`Disallow: ${path}`);
130
+ }
131
+ lines.push("");
132
+ }
133
+
134
+ const bots = options.bots ?? SMKING_DEFAULT_BOTS;
135
+ for (const [userAgent, rule] of Object.entries(bots)) {
136
+ lines.push(`User-agent: ${userAgent}`);
137
+ lines.push(rule === "disallow" ? "Disallow: /" : "Allow: /");
138
+ lines.push("");
139
+ }
140
+
141
+ // contentSignal === undefined → use default
142
+ // contentSignal === null or "" → omit the directive entirely
143
+ const signalRaw =
144
+ options.contentSignal === undefined
145
+ ? SMKING_DEFAULT_CONTENT_SIGNAL
146
+ : options.contentSignal;
147
+ if (signalRaw !== null && signalRaw !== "") {
148
+ lines.push("User-agent: *");
149
+ lines.push(`Content-Signal: ${signalRaw}`);
150
+ lines.push("");
151
+ }
152
+
153
+ for (const sm of toArray(options.sitemap)) {
154
+ lines.push(`Sitemap: ${sm}`);
155
+ }
156
+ if (options.host !== undefined && options.host !== "") {
157
+ lines.push(`Host: ${options.host}`);
158
+ }
159
+
160
+ // Collapse trailing empty lines into a single newline so the file
161
+ // ends cleanly regardless of which optional sections were emitted.
162
+ return lines.join("\n").replace(/\n+$/, "") + "\n";
163
+ }
164
+
165
+ function toArray<T>(value: T | T[] | undefined): T[] {
166
+ if (value === undefined) return [];
167
+ return Array.isArray(value) ? value : [value];
168
+ }
package/src/route.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { revalidateTag } from "next/cache";
2
+ import { Buffer } from "node:buffer";
3
+ import crypto from "node:crypto";
4
+
5
+ interface WebhookPayload {
6
+ paths?: string[];
7
+ }
8
+
9
+ const BEARER_PREFIX = "Bearer ";
10
+
11
+ /**
12
+ * Webhook handler for SaaS-pushed cache invalidation. Drop-in install:
13
+ *
14
+ * ```ts
15
+ * // app/api/smking-revalidate/route.ts
16
+ * export { POST, GET } from '@soloworks/smking-next/route';
17
+ * ```
18
+ *
19
+ * Then set `SMKING_WEBHOOK_TOKEN` in your environment and configure the
20
+ * SaaS to POST `{ paths: [...] }` with `Authorization: Bearer <token>`.
21
+ *
22
+ * Bearer-token auth (not HMAC body signing) — sufficient for v1.
23
+ * HMAC-signed body verification can layer on later if attack surface
24
+ * justifies it. Token compare runs through `crypto.timingSafeEqual` to
25
+ * avoid leaking length / prefix info via response time.
26
+ */
27
+ export async function POST(request: Request): Promise<Response> {
28
+ const expected = process.env.SMKING_WEBHOOK_TOKEN;
29
+ if (!expected) {
30
+ return Response.json(
31
+ { error: "webhook_not_configured" },
32
+ { status: 503 },
33
+ );
34
+ }
35
+
36
+ const auth = request.headers.get("authorization");
37
+ if (!auth || !verifyBearer(auth, expected)) {
38
+ return Response.json({ error: "unauthorized" }, { status: 401 });
39
+ }
40
+
41
+ let payload: WebhookPayload;
42
+ try {
43
+ payload = (await request.json()) as WebhookPayload;
44
+ } catch {
45
+ return Response.json({ error: "invalid_json" }, { status: 400 });
46
+ }
47
+
48
+ let revalidated = 0;
49
+ let errors = 0;
50
+ for (const p of payload.paths ?? []) {
51
+ try {
52
+ // Next.js 16 requires a cache profile; 'default' matches the
53
+ // implicit profile a normal `'use cache'` block uses.
54
+ revalidateTag(`smking:path:${p}`, "default");
55
+ revalidated++;
56
+ } catch (err) {
57
+ errors++;
58
+ console.warn(
59
+ `[@soloworks/smking-next] revalidateTag failed for path "${p}":`,
60
+ err,
61
+ );
62
+ }
63
+ }
64
+
65
+ return Response.json({ revalidated, errors });
66
+ }
67
+
68
+ /**
69
+ * Liveness probe — POST-only public endpoint, but `GET` returns 401 with
70
+ * a `WWW-Authenticate` header so customers can verify the route is wired
71
+ * up by sending a token. Discourages anonymous probes from confirming
72
+ * the endpoint exists.
73
+ */
74
+ export async function GET(request: Request): Promise<Response> {
75
+ const expected = process.env.SMKING_WEBHOOK_TOKEN;
76
+ if (!expected) {
77
+ return Response.json(
78
+ { error: "webhook_not_configured" },
79
+ { status: 503 },
80
+ );
81
+ }
82
+ const auth = request.headers.get("authorization");
83
+ if (auth && verifyBearer(auth, expected)) {
84
+ return new Response("OK", { status: 200 });
85
+ }
86
+ return new Response("Unauthorized", {
87
+ status: 401,
88
+ headers: { "WWW-Authenticate": 'Bearer realm="smking-webhook"' },
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Constant-time bearer-token compare. Returns false on:
94
+ * - missing or non-Bearer header
95
+ * - presented token has different length than expected (early-out;
96
+ * the lengths themselves leak nothing because the secret is set by
97
+ * the customer with a known length)
98
+ * - byte-different but same-length token
99
+ */
100
+ function verifyBearer(authHeader: string, expected: string): boolean {
101
+ if (!authHeader.startsWith(BEARER_PREFIX)) return false;
102
+ const presented = authHeader.slice(BEARER_PREFIX.length);
103
+ if (presented.length !== expected.length) return false;
104
+ try {
105
+ return crypto.timingSafeEqual(
106
+ Buffer.from(presented),
107
+ Buffer.from(expected),
108
+ );
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
package/src/types.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * TypeScript shape of the smking Public AEO API response. Mirrors the
3
+ * server-side AeoResponse — every field is either a usable value or
4
+ * absent / null, and consumers should treat null as "no signal".
5
+ */
6
+
7
+ export type AeoStatus = "ready" | "pending" | "not_found";
8
+
9
+ export interface FaqItem {
10
+ q: string;
11
+ a: string;
12
+ }
13
+
14
+ export interface ChatLinks {
15
+ chatgpt: string;
16
+ perplexity: string;
17
+ google: string;
18
+ }
19
+
20
+ /**
21
+ * Server-resolved SEO metadata. Public API does fallback chains
22
+ * server-side (ogTitle → title, ogDescription → metaDescription,
23
+ * ogImageUrl → imageUrl, canonicalUrl → pageUrl).
24
+ */
25
+ export interface SeoMeta {
26
+ title: string | null;
27
+ ogTitle: string | null;
28
+ ogDescription: string | null;
29
+ ogImageUrl: string | null;
30
+ canonicalUrl: string | null;
31
+ }
32
+
33
+ export interface AeoResponse {
34
+ status: AeoStatus;
35
+ jsonLd?: Record<string, unknown>;
36
+ faq?: FaqItem[];
37
+ summary?: string;
38
+ metaDescription?: string;
39
+ faqHtml?: string;
40
+ summaryHtml?: string;
41
+ chatLinks?: ChatLinks;
42
+ seo?: SeoMeta | null;
43
+ }
44
+
45
+ export interface DiscoverParams {
46
+ /** Public API key (`pk_*`). Required. */
47
+ apiKey: string;
48
+ /**
49
+ * URL path to look up. Auto-resolved from request headers when omitted.
50
+ * Pass explicitly only when you know it (saves one header read) or for
51
+ * static-export contexts where `headers()` is unavailable.
52
+ */
53
+ path?: string;
54
+ /**
55
+ * Full request URL — used by the SaaS to register a new path for
56
+ * background crawling on first sight. Auto-resolved from headers when
57
+ * omitted; pass explicitly alongside `path` only when overriding.
58
+ */
59
+ url?: string;
60
+ /**
61
+ * smking deployment origin. Required — pass directly or set
62
+ * `SMKING_BASE_URL` env. Missing value short-circuits to fail-open
63
+ * (no fetch, no inject, dev-only warning).
64
+ */
65
+ baseUrl?: string;
66
+ /** Next.js `fetch` revalidate seconds. Defaults to 3600 (1h ISR backstop). */
67
+ revalidate?: number;
68
+ }