@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 +109 -0
- package/README.md +133 -0
- package/package.json +64 -0
- package/src/components/smking-aeo.tsx +134 -0
- package/src/index.ts +10 -0
- package/src/lib/client.ts +95 -0
- package/src/lib/path.ts +51 -0
- package/src/lib/robots.ts +168 -0
- package/src/route.ts +112 -0
- package/src/types.ts +68 -0
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,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
|
+
}
|
package/src/lib/path.ts
ADDED
|
@@ -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
|
+
}
|