@soloworks/smking-next 0.21.6 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +37 -22
- package/bin/install.mjs +242 -199
- package/package.json +1 -1
- package/src/lib/client.ts +8 -5
- package/src/lib/path.ts +17 -0
- package/src/lib/sitemap.ts +8 -2
- package/src/lib/version.ts +1 -1
- package/src/lib/webhook-route.ts +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @soloworks/smking-next
|
|
2
2
|
|
|
3
|
+
## 0.22.1 — 2026-07-26
|
|
4
|
+
|
|
5
|
+
- Sitemap fetches now carry a shared cache tag.
|
|
6
|
+
- Signed AEO and CMS publish webhooks immediately invalidate the sitemap tag,
|
|
7
|
+
so newly published Blog pages do not wait for the one-day fallback TTL.
|
|
8
|
+
|
|
9
|
+
## 0.22.0 — 2026-07-25
|
|
10
|
+
|
|
11
|
+
- AEO requests now skip the configured `SMKING_CMS_PATH`, preventing duplicate
|
|
12
|
+
SEO and JSON-LD on Page Zero Blog pages.
|
|
13
|
+
- AEO-only installs report `cms_base_path: null`; the wizard writes the Blog
|
|
14
|
+
path explicitly whenever Blog is selected.
|
|
15
|
+
- `smking-next doctor` accepts `--surfaces=aeo`, `cms`, or both and verifies
|
|
16
|
+
`transpilePackages`, Blog runtime, catch-all, preview, webhook, and secrets.
|
|
17
|
+
|
|
3
18
|
## 0.21.6 — 2026-07-20
|
|
4
19
|
|
|
5
20
|
**Next.js metadata can now be authoritative instead of duplicated.**
|
package/README.md
CHANGED
|
@@ -8,7 +8,9 @@ AI-native SEO (AEO) for Next.js. One server component injects JSON-LD, OG tags,
|
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Your Page Zero dashboard generates the source-of-truth install prompt with the
|
|
12
|
+
real environment values, selected surfaces, Blog path, route shims, and
|
|
13
|
+
version-aligned verification command.
|
|
12
14
|
|
|
13
15
|
Two ways to get it:
|
|
14
16
|
|
|
@@ -20,18 +22,29 @@ npx @soloworks/smking-wizard@latest
|
|
|
20
22
|
# install panel into your editor / coding agent.
|
|
21
23
|
```
|
|
22
24
|
|
|
23
|
-
The wizard
|
|
25
|
+
The wizard can install **AEO + SEO**, **Blog only**, or both. It owns package
|
|
26
|
+
installation, selected layout mounts, environment writes, Blog routes, the
|
|
27
|
+
surface-aware doctor, and the real production build.
|
|
24
28
|
|
|
25
|
-
##
|
|
29
|
+
## Authoritative metadata
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
Use `withSmkingMetadata()` from the real root layout's `generateMetadata`
|
|
32
|
+
export, then mount `<SmkingAEO includeSeo={false} />` for JSON-LD and hidden
|
|
33
|
+
AEO fragments. This produces one authoritative title and description instead
|
|
34
|
+
of relying on render order.
|
|
28
35
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
```tsx
|
|
37
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
38
|
+
return withSmkingMetadata(hostMetadata, {
|
|
39
|
+
apiKey: process.env.SMKING_API_KEY!,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
33
42
|
|
|
34
|
-
|
|
43
|
+
<SmkingAEO
|
|
44
|
+
apiKey={process.env.SMKING_API_KEY!}
|
|
45
|
+
includeSeo={false}
|
|
46
|
+
/>
|
|
47
|
+
```
|
|
35
48
|
|
|
36
49
|
## How outage tolerance works
|
|
37
50
|
|
|
@@ -53,7 +66,9 @@ interface SmkingAEOProps {
|
|
|
53
66
|
apiKey: string; // required
|
|
54
67
|
baseUrl?: string; // override SMKING_BASE_URL env
|
|
55
68
|
path?: string; // explicit path; auto-resolved from headers() otherwise
|
|
69
|
+
url?: string; // explicit absolute request URL when path is overridden
|
|
56
70
|
revalidate?: number; // ISR seconds; default 3600 (1h)
|
|
71
|
+
includeSeo?: boolean; // false when using withSmkingMetadata()
|
|
57
72
|
}
|
|
58
73
|
```
|
|
59
74
|
|
|
@@ -72,17 +87,13 @@ if (aeo?.status === 'ready') {
|
|
|
72
87
|
}
|
|
73
88
|
```
|
|
74
89
|
|
|
75
|
-
###
|
|
76
|
-
|
|
77
|
-
```json
|
|
78
|
-
POST /api/smking-revalidate
|
|
79
|
-
Authorization: Bearer <SMKING_WEBHOOK_TOKEN>
|
|
80
|
-
Content-Type: application/json
|
|
81
|
-
|
|
82
|
-
{ "paths": ["/products/abc", "/products/xyz"] }
|
|
83
|
-
```
|
|
90
|
+
### Publish webhook
|
|
84
91
|
|
|
85
|
-
|
|
92
|
+
The source-of-truth install prompt creates
|
|
93
|
+
`app/api/smking/webhook/route.ts`, which re-exports `POST` from
|
|
94
|
+
`@soloworks/smking-next/webhook`. The handler verifies the
|
|
95
|
+
`SMKING_WEBHOOK_SECRET` HMAC and invalidates the affected AEO or Blog cache
|
|
96
|
+
tags.
|
|
86
97
|
|
|
87
98
|
## Mount the runtime once (v0.16.1+)
|
|
88
99
|
|
|
@@ -112,11 +123,15 @@ Optional `baseUrl` prop overrides `process.env.SMKING_BASE_URL`. Default: `https
|
|
|
112
123
|
|
|
113
124
|
Without `<SmkingRuntime />`, `<SmkingCms>` content still renders but the Tailwind utility classes from the dashboard's cva variants resolve to dead strings — the page reaches the browser unstyled. The wizard installer auto-adds this mount; if you're upgrading manually from < v0.16.1, add the one line above.
|
|
114
125
|
|
|
115
|
-
##
|
|
126
|
+
## Page Zero Blog (optional)
|
|
116
127
|
|
|
117
|
-
|
|
128
|
+
If you publish Blog pages from the Page Zero dashboard, render them with the
|
|
129
|
+
`<SmkingCms slug="…" />` Server Component.
|
|
118
130
|
|
|
119
|
-
|
|
131
|
+
Choose a URL path such as `/blog`, `/knowledge`, or `/shop/articles` and set
|
|
132
|
+
the same value as `SMKING_CMS_PATH`. The wizard writes it automatically.
|
|
133
|
+
Root AEO requests skip that path so `<SmkingCms>` is the only source of SEO and
|
|
134
|
+
JSON-LD on Blog pages.
|
|
120
135
|
|
|
121
136
|
### Optional catch-all route (handles the CMS root + nested slugs)
|
|
122
137
|
|
package/bin/install.mjs
CHANGED
|
@@ -1,26 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* `
|
|
4
|
-
*
|
|
5
|
-
* Two subcommands share this entry point. The file ships as plain ESM
|
|
6
|
-
* (`.mjs`) since Node's `--experimental-strip-types` is excluded from
|
|
7
|
-
* files under `node_modules` by design — so `bin/install.ts` (the
|
|
8
|
-
* previous v0.12.0 layout) fails with "Type-Stripping is not supported
|
|
9
|
-
* for files under node_modules" the moment a customer runs `npx
|
|
10
|
-
* @soloworks/smking-next`. Source modules (`src/*.ts`) are still TS —
|
|
11
|
-
* Next.js's bundler handles them at customer build time. Only this
|
|
12
|
-
* standalone-executable bin script needed to drop TS.
|
|
13
|
-
*
|
|
14
|
-
* - **install** (default) — One-shot scaffold for the three takeover
|
|
15
|
-
* drop-in files (sitemap.ts / robots.ts / llms.txt route). Idempotent:
|
|
16
|
-
* already-existing files are skipped, never overwritten.
|
|
17
|
-
*
|
|
18
|
-
* - **doctor** — Self-check (env presence + authoritative AEO/SEO layout
|
|
19
|
-
* integration + API reachable). `--json` flag emits structured output for the
|
|
20
|
-
* @smking/wizard install agent's `run_doctor` MCP tool.
|
|
21
|
-
*
|
|
22
|
-
* Conventional Next.js layout assumed: `app/` at repo root (or under
|
|
23
|
-
* `src/app/` — detected). Both subcommands exit 1 on missing app/.
|
|
3
|
+
* `smking-next install` scaffolds missing AEO discovery routes.
|
|
4
|
+
* `smking-next doctor` verifies the surfaces selected by the installer.
|
|
24
5
|
*/
|
|
25
6
|
import {
|
|
26
7
|
existsSync,
|
|
@@ -31,13 +12,6 @@ import {
|
|
|
31
12
|
} from "node:fs";
|
|
32
13
|
import { join } from "node:path";
|
|
33
14
|
|
|
34
|
-
// ── install (file scaffold) ─────────────────────────────────────
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* @typedef {{ path: string, content: string, label: string }} FileSpec
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
/** @type {FileSpec[]} */
|
|
41
15
|
const FILES = [
|
|
42
16
|
{
|
|
43
17
|
path: "sitemap.ts",
|
|
@@ -57,11 +31,7 @@ const FILES = [
|
|
|
57
31
|
];
|
|
58
32
|
|
|
59
33
|
function detectAppDir() {
|
|
60
|
-
|
|
61
|
-
for (const c of candidates) {
|
|
62
|
-
if (existsSync(c)) return c;
|
|
63
|
-
}
|
|
64
|
-
return null;
|
|
34
|
+
return ["app", "src/app"].find((path) => existsSync(path)) ?? null;
|
|
65
35
|
}
|
|
66
36
|
|
|
67
37
|
function runInstall() {
|
|
@@ -72,7 +42,6 @@ function runInstall() {
|
|
|
72
42
|
);
|
|
73
43
|
return 1;
|
|
74
44
|
}
|
|
75
|
-
console.log(`📂 Using app directory: ${appDir}/\n`);
|
|
76
45
|
|
|
77
46
|
let created = 0;
|
|
78
47
|
let skipped = 0;
|
|
@@ -83,62 +52,28 @@ function runInstall() {
|
|
|
83
52
|
skipped += 1;
|
|
84
53
|
continue;
|
|
85
54
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
writeFileSync(fullPath, file.content, "utf-8");
|
|
55
|
+
mkdirSync(fullPath.slice(0, fullPath.lastIndexOf("/")), {
|
|
56
|
+
recursive: true,
|
|
57
|
+
});
|
|
58
|
+
writeFileSync(fullPath, file.content, "utf8");
|
|
91
59
|
console.log(`✓ created: ${file.label}`);
|
|
92
60
|
created += 1;
|
|
93
61
|
}
|
|
94
|
-
|
|
95
|
-
console.log(
|
|
96
|
-
`\n${created} created, ${skipped} skipped. Set SMKING_API_KEY and SMKING_BASE_URL in your environment.`,
|
|
97
|
-
);
|
|
62
|
+
console.log(`\n${created} created, ${skipped} skipped.`);
|
|
98
63
|
return 0;
|
|
99
64
|
}
|
|
100
65
|
|
|
101
|
-
// ── doctor (self-check) ─────────────────────────────────────────
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Load `.env.local` + `.env` from cwd into `process.env`, matching the
|
|
105
|
-
* Next.js convention so `npx @soloworks/smking-next doctor` sees the same
|
|
106
|
-
* env vars the customer's `next dev` / `next build` would. Standalone
|
|
107
|
-
* Node bin scripts don't get framework env loading for free — without
|
|
108
|
-
* this, doctor would fail "SMKING_API_KEY not set" on every install where
|
|
109
|
-
* the wizard wrote to `.env.local` instead of shell env.
|
|
110
|
-
*
|
|
111
|
-
* `process.loadEnvFile` is available Node 20.6+ (experimental → stable
|
|
112
|
-
* in 21.7+). Next.js 16 requires Node 20+ so the customer environment is
|
|
113
|
-
* guaranteed to have it. Existing process env wins (we don't overwrite —
|
|
114
|
-
* matches Next.js precedence: shell env > .env.local > .env).
|
|
115
|
-
*
|
|
116
|
-
* Files load in reverse-precedence order (lowest first) so later files
|
|
117
|
-
* win when both define the same key. Next.js's actual order: .env.local
|
|
118
|
-
* > .env.development.local > .env.development > .env. We do the minimal
|
|
119
|
-
* pair customers actually use.
|
|
120
|
-
*/
|
|
121
66
|
function loadEnvFiles() {
|
|
122
67
|
for (const file of [".env", ".env.local"]) {
|
|
123
68
|
if (!existsSync(file)) continue;
|
|
124
69
|
try {
|
|
125
70
|
process.loadEnvFile(file);
|
|
126
71
|
} catch {
|
|
127
|
-
//
|
|
128
|
-
// will report SMKING_API_KEY not set as it would have anyway.
|
|
72
|
+
// The following checks surface missing values.
|
|
129
73
|
}
|
|
130
74
|
}
|
|
131
75
|
}
|
|
132
76
|
|
|
133
|
-
/**
|
|
134
|
-
* @typedef {{ name: string, status: "pass" | "fail" | "info", detail: string }} DoctorCheck
|
|
135
|
-
*/
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* @param {string} name
|
|
139
|
-
* @param {string} [prefix]
|
|
140
|
-
* @returns {DoctorCheck}
|
|
141
|
-
*/
|
|
142
77
|
function checkEnv(name, prefix) {
|
|
143
78
|
const value = process.env[name];
|
|
144
79
|
if (!value) {
|
|
@@ -152,7 +87,7 @@ function checkEnv(name, prefix) {
|
|
|
152
87
|
return {
|
|
153
88
|
name: `${name} set`,
|
|
154
89
|
status: "fail",
|
|
155
|
-
detail: `value must start with
|
|
90
|
+
detail: `value must start with ${prefix}`,
|
|
156
91
|
};
|
|
157
92
|
}
|
|
158
93
|
return {
|
|
@@ -162,20 +97,12 @@ function checkEnv(name, prefix) {
|
|
|
162
97
|
};
|
|
163
98
|
}
|
|
164
99
|
|
|
165
|
-
/**
|
|
166
|
-
* Find the shallowest layout that owns html + body. This supports
|
|
167
|
-
* internationalized roots such as app/[locale]/layout.tsx without
|
|
168
|
-
* mistaking a nested section layout for the application root.
|
|
169
|
-
* @param {string} appDir
|
|
170
|
-
* @returns {string | null}
|
|
171
|
-
*/
|
|
172
100
|
function findRootLayout(appDir) {
|
|
173
101
|
const conventional = ["tsx", "jsx", "ts", "js"]
|
|
174
102
|
.map((extension) => join(appDir, `layout.${extension}`))
|
|
175
103
|
.find((path) => existsSync(path));
|
|
176
104
|
if (conventional) return conventional;
|
|
177
105
|
|
|
178
|
-
/** @type {Array<{ path: string, depth: number }>} */
|
|
179
106
|
const nested = [];
|
|
180
107
|
function visit(directory, depth) {
|
|
181
108
|
if (depth > 4) return;
|
|
@@ -187,23 +114,51 @@ function findRootLayout(appDir) {
|
|
|
187
114
|
continue;
|
|
188
115
|
}
|
|
189
116
|
if (!/^layout\.(tsx|jsx|ts|js)$/.test(entry.name)) continue;
|
|
190
|
-
const content = readFileSync(path, "
|
|
117
|
+
const content = readFileSync(path, "utf8");
|
|
191
118
|
if (/<html(?:\s|>)/.test(content) && /<body(?:\s|>)/.test(content)) {
|
|
192
119
|
nested.push({ path, depth });
|
|
193
120
|
}
|
|
194
121
|
}
|
|
195
122
|
}
|
|
196
|
-
|
|
197
123
|
visit(appDir, 0);
|
|
198
124
|
nested.sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path));
|
|
199
125
|
return nested[0]?.path ?? null;
|
|
200
126
|
}
|
|
201
127
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
128
|
+
function checkNextConfig() {
|
|
129
|
+
const configPath = [
|
|
130
|
+
"next.config.ts",
|
|
131
|
+
"next.config.mjs",
|
|
132
|
+
"next.config.js",
|
|
133
|
+
"next.config.cjs",
|
|
134
|
+
].find((path) => existsSync(path));
|
|
135
|
+
if (!configPath) {
|
|
136
|
+
return {
|
|
137
|
+
name: "Next.js transpilePackages",
|
|
138
|
+
status: "fail",
|
|
139
|
+
detail:
|
|
140
|
+
"next.config.* not found; add @soloworks/smking-next to transpilePackages",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const content = readFileSync(configPath, "utf8");
|
|
144
|
+
if (
|
|
145
|
+
!content.includes("transpilePackages") ||
|
|
146
|
+
!content.includes("@soloworks/smking-next")
|
|
147
|
+
) {
|
|
148
|
+
return {
|
|
149
|
+
name: "Next.js transpilePackages",
|
|
150
|
+
status: "fail",
|
|
151
|
+
detail: `${configPath} must include @soloworks/smking-next in transpilePackages`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
name: "Next.js transpilePackages",
|
|
156
|
+
status: "pass",
|
|
157
|
+
detail: configPath,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function checkAeoLayout(appDir) {
|
|
207
162
|
const layoutPath = findRootLayout(appDir);
|
|
208
163
|
if (!layoutPath) {
|
|
209
164
|
return {
|
|
@@ -212,177 +167,265 @@ function checkLayoutUsage(appDir) {
|
|
|
212
167
|
detail: `no root layout owning <html> and <body> found under ${appDir}/`,
|
|
213
168
|
};
|
|
214
169
|
}
|
|
215
|
-
|
|
216
|
-
const content = readFileSync(layoutPath, "utf-8");
|
|
170
|
+
const content = readFileSync(layoutPath, "utf8");
|
|
217
171
|
const missing = [];
|
|
218
172
|
if (!content.includes("SmkingAEO")) missing.push("SmkingAEO");
|
|
219
173
|
if (!content.includes("withSmkingMetadata(")) {
|
|
220
|
-
missing.push("withSmkingMetadata()
|
|
174
|
+
missing.push("withSmkingMetadata()");
|
|
221
175
|
}
|
|
222
176
|
if (!/includeSeo\s*=\s*\{\s*false\s*\}/.test(content)) {
|
|
223
177
|
missing.push("includeSeo={false}");
|
|
224
178
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
179
|
+
return missing.length === 0
|
|
180
|
+
? {
|
|
181
|
+
name: "AEO + SEO in root layout",
|
|
182
|
+
status: "pass",
|
|
183
|
+
detail: layoutPath,
|
|
184
|
+
}
|
|
185
|
+
: {
|
|
186
|
+
name: "AEO + SEO in root layout",
|
|
187
|
+
status: "fail",
|
|
188
|
+
detail: `${layoutPath} is missing ${missing.join(", ")}`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseBlogPath() {
|
|
193
|
+
const prefix = process.env.SMKING_CMS_PATH;
|
|
194
|
+
if (
|
|
195
|
+
!prefix ||
|
|
196
|
+
prefix.length > 160 ||
|
|
197
|
+
!/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/.test(prefix)
|
|
198
|
+
) {
|
|
199
|
+
return null;
|
|
231
200
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
201
|
+
const segments = prefix.slice(1).split("/");
|
|
202
|
+
if (
|
|
203
|
+
segments.some((segment) => segment === "." || segment === "..") ||
|
|
204
|
+
["api", "_next", "smking-preview"].includes(segments[0].toLowerCase())
|
|
205
|
+
) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
return { prefix, segments };
|
|
237
209
|
}
|
|
238
210
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
211
|
+
function checkBlog(appDir) {
|
|
212
|
+
const parsed = parseBlogPath();
|
|
213
|
+
if (!parsed) {
|
|
214
|
+
return [
|
|
215
|
+
{
|
|
216
|
+
name: "SMKING_CMS_PATH set",
|
|
217
|
+
status: "fail",
|
|
218
|
+
detail: "set a safe Blog path such as /blog",
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const secret = process.env.SMKING_WEBHOOK_SECRET;
|
|
224
|
+
const layoutPath = findRootLayout(appDir);
|
|
225
|
+
const layout = layoutPath ? readFileSync(layoutPath, "utf8") : "";
|
|
226
|
+
const blogDir = join(appDir, ...parsed.segments, "[[...slug]]");
|
|
227
|
+
const existsWithExtension = (base, name, extensions) =>
|
|
228
|
+
extensions.some((extension) => existsSync(join(base, `${name}.${extension}`)));
|
|
229
|
+
|
|
230
|
+
return [
|
|
231
|
+
{
|
|
232
|
+
name: "SMKING_CMS_PATH set",
|
|
233
|
+
status: "pass",
|
|
234
|
+
detail: parsed.prefix,
|
|
235
|
+
},
|
|
236
|
+
secret && /^[A-Fa-f0-9]{64}$/.test(secret)
|
|
237
|
+
? {
|
|
238
|
+
name: "SMKING_WEBHOOK_SECRET set",
|
|
239
|
+
status: "pass",
|
|
240
|
+
detail: `${secret.slice(0, 8)}…`,
|
|
241
|
+
}
|
|
242
|
+
: {
|
|
243
|
+
name: "SMKING_WEBHOOK_SECRET set",
|
|
244
|
+
status: "fail",
|
|
245
|
+
detail: "must be a 64-character hex secret",
|
|
246
|
+
},
|
|
247
|
+
layoutPath &&
|
|
248
|
+
layout.includes("SmkingRuntime") &&
|
|
249
|
+
/<SmkingRuntime[^>]*apiKey\s*=/.test(layout)
|
|
250
|
+
? {
|
|
251
|
+
name: "Blog runtime in root layout",
|
|
252
|
+
status: "pass",
|
|
253
|
+
detail: layoutPath,
|
|
254
|
+
}
|
|
255
|
+
: {
|
|
256
|
+
name: "Blog runtime in root layout",
|
|
257
|
+
status: "fail",
|
|
258
|
+
detail:
|
|
259
|
+
"mount <SmkingRuntime apiKey={process.env.SMKING_API_KEY!} /> in the root layout",
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
name: "Blog catch-all route",
|
|
263
|
+
status: existsWithExtension(blogDir, "page", [
|
|
264
|
+
"tsx",
|
|
265
|
+
"jsx",
|
|
266
|
+
"ts",
|
|
267
|
+
"js",
|
|
268
|
+
])
|
|
269
|
+
? "pass"
|
|
270
|
+
: "fail",
|
|
271
|
+
detail: `${blogDir}/page.*`,
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
name: "Blog preview route",
|
|
275
|
+
status: existsWithExtension(
|
|
276
|
+
join(appDir, "smking-preview"),
|
|
277
|
+
"route",
|
|
278
|
+
["ts", "js"],
|
|
279
|
+
)
|
|
280
|
+
? "pass"
|
|
281
|
+
: "fail",
|
|
282
|
+
detail: `${appDir}/smking-preview/route.*`,
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
name: "Blog publish webhook",
|
|
286
|
+
status: existsWithExtension(
|
|
287
|
+
join(appDir, "api", "smking", "webhook"),
|
|
288
|
+
"route",
|
|
289
|
+
["ts", "js"],
|
|
290
|
+
)
|
|
291
|
+
? "pass"
|
|
292
|
+
: "fail",
|
|
293
|
+
detail: `${appDir}/api/smking/webhook/route.*`,
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function checkApi(surface) {
|
|
243
299
|
const apiKey = process.env.SMKING_API_KEY;
|
|
244
300
|
const baseUrl = process.env.SMKING_BASE_URL;
|
|
301
|
+
const label = surface === "cms" ? "Blog API reachable" : "AEO API reachable";
|
|
245
302
|
if (!apiKey || !baseUrl) {
|
|
246
303
|
return {
|
|
247
|
-
name:
|
|
304
|
+
name: label,
|
|
248
305
|
status: "info",
|
|
249
|
-
detail: "skipped
|
|
306
|
+
detail: "skipped because base environment is incomplete",
|
|
250
307
|
};
|
|
251
308
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
309
|
+
const endpoint =
|
|
310
|
+
surface === "cms"
|
|
311
|
+
? `${baseUrl.replace(/\/$/, "")}/api/v1/public/page`
|
|
312
|
+
: `${baseUrl.replace(/\/$/, "")}/api/v1/public/aeo`;
|
|
313
|
+
const url =
|
|
314
|
+
surface === "cms"
|
|
315
|
+
? `${endpoint}?key=${encodeURIComponent(apiKey)}&slug=__page_zero_doctor__`
|
|
316
|
+
: `${endpoint}?key=${encodeURIComponent(apiKey)}&path=%2F`;
|
|
259
317
|
try {
|
|
260
|
-
const
|
|
261
|
-
signal: AbortSignal.timeout(
|
|
318
|
+
const response = await fetch(url, {
|
|
319
|
+
signal: AbortSignal.timeout(3_000),
|
|
262
320
|
});
|
|
263
|
-
|
|
264
|
-
if (res.ok) {
|
|
321
|
+
if (response.ok) {
|
|
265
322
|
return {
|
|
266
|
-
name:
|
|
323
|
+
name: label,
|
|
267
324
|
status: "pass",
|
|
268
|
-
detail: `${endpoint} → HTTP ${
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
if (res.status === 401) {
|
|
272
|
-
return {
|
|
273
|
-
name: "API reachable",
|
|
274
|
-
status: "fail",
|
|
275
|
-
detail: `HTTP 401 from ${endpoint} — SMKING_API_KEY rejected`,
|
|
325
|
+
detail: `${endpoint} → HTTP ${response.status}; API key accepted`,
|
|
276
326
|
};
|
|
277
327
|
}
|
|
278
|
-
if (
|
|
279
|
-
const payload = await
|
|
328
|
+
if (response.status === 404) {
|
|
329
|
+
const payload = await response
|
|
280
330
|
.clone()
|
|
281
331
|
.json()
|
|
282
332
|
.catch(() => null);
|
|
283
333
|
if (payload?.status === "not_found") {
|
|
284
334
|
return {
|
|
285
|
-
name:
|
|
335
|
+
name: label,
|
|
286
336
|
status: "pass",
|
|
287
337
|
detail: `${endpoint} → HTTP 404 not_found; API key accepted`,
|
|
288
338
|
};
|
|
289
339
|
}
|
|
290
|
-
return {
|
|
291
|
-
name: "API reachable",
|
|
292
|
-
status: "fail",
|
|
293
|
-
detail: `HTTP 404 from ${endpoint} — SMKING_BASE_URL likely points at the wrong host`,
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
if (res.status >= 500) {
|
|
297
|
-
return {
|
|
298
|
-
name: "API reachable",
|
|
299
|
-
status: "fail",
|
|
300
|
-
detail: `upstream HTTP ${res.status} from ${endpoint}`,
|
|
301
|
-
};
|
|
302
340
|
}
|
|
303
341
|
return {
|
|
304
|
-
name:
|
|
342
|
+
name: label,
|
|
305
343
|
status: "fail",
|
|
306
|
-
detail: `${endpoint} returned
|
|
344
|
+
detail: `${endpoint} returned HTTP ${response.status}`,
|
|
307
345
|
};
|
|
308
|
-
} catch (
|
|
346
|
+
} catch (error) {
|
|
309
347
|
return {
|
|
310
|
-
name:
|
|
348
|
+
name: label,
|
|
311
349
|
status: "fail",
|
|
312
|
-
detail: `connection failed: ${
|
|
350
|
+
detail: `connection failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
313
351
|
};
|
|
314
352
|
}
|
|
315
353
|
}
|
|
316
354
|
|
|
317
|
-
|
|
318
|
-
* @param {boolean} jsonOutput
|
|
319
|
-
* @returns {Promise<number>}
|
|
320
|
-
*/
|
|
321
|
-
async function runDoctor(jsonOutput) {
|
|
322
|
-
// Mirror Next.js env loading so doctor sees what `next dev` would.
|
|
323
|
-
// Customers run this from project root; .env.local is the wizard's
|
|
324
|
-
// write target for SMKING_API_KEY + SMKING_BASE_URL.
|
|
355
|
+
async function runDoctor(jsonOutput, surfaces) {
|
|
325
356
|
loadEnvFiles();
|
|
326
|
-
|
|
327
357
|
const appDir = detectAppDir();
|
|
328
|
-
/** @type {DoctorCheck[]} */
|
|
329
358
|
const checks = [
|
|
330
359
|
checkEnv("SMKING_API_KEY", "pk_"),
|
|
331
360
|
checkEnv("SMKING_BASE_URL", "http"),
|
|
332
|
-
|
|
333
|
-
? checkLayoutUsage(appDir)
|
|
334
|
-
: {
|
|
335
|
-
name: "AEO + SEO in root layout",
|
|
336
|
-
status: "info",
|
|
337
|
-
detail: "no app/ or src/app/ directory found — skipped",
|
|
338
|
-
},
|
|
339
|
-
await checkApiReachable(),
|
|
361
|
+
checkNextConfig(),
|
|
340
362
|
];
|
|
341
363
|
|
|
342
|
-
|
|
364
|
+
if (!appDir) {
|
|
365
|
+
checks.push({
|
|
366
|
+
name: "App Router",
|
|
367
|
+
status: "fail",
|
|
368
|
+
detail: "no app/ or src/app/ directory found",
|
|
369
|
+
});
|
|
370
|
+
} else {
|
|
371
|
+
if (surfaces.includes("aeo")) checks.push(checkAeoLayout(appDir));
|
|
372
|
+
else {
|
|
373
|
+
checks.push({
|
|
374
|
+
name: "AEO + SEO selection",
|
|
375
|
+
status: "info",
|
|
376
|
+
detail: "disabled by Blog-only install",
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (surfaces.includes("cms")) checks.push(...checkBlog(appDir));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (surfaces.includes("aeo")) checks.push(await checkApi("aeo"));
|
|
383
|
+
if (surfaces.includes("cms")) checks.push(await checkApi("cms"));
|
|
343
384
|
|
|
344
|
-
|
|
345
|
-
|
|
385
|
+
const summary = {
|
|
386
|
+
passed: checks.filter((check) => check.status === "pass").length,
|
|
387
|
+
failed: checks.filter((check) => check.status === "fail").length,
|
|
388
|
+
info: checks.filter((check) => check.status === "info").length,
|
|
389
|
+
ok: !checks.some((check) => check.status === "fail"),
|
|
390
|
+
};
|
|
346
391
|
if (jsonOutput) {
|
|
347
|
-
const summary = {
|
|
348
|
-
passed: checks.filter((c) => c.status === "pass").length,
|
|
349
|
-
failed: checks.filter((c) => c.status === "fail").length,
|
|
350
|
-
info: checks.filter((c) => c.status === "info").length,
|
|
351
|
-
ok: !hasFailure,
|
|
352
|
-
};
|
|
353
392
|
console.log(JSON.stringify({ checks, summary }));
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
console.log(
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
return 1;
|
|
393
|
+
} else {
|
|
394
|
+
for (const check of checks) {
|
|
395
|
+
const icon =
|
|
396
|
+
check.status === "pass" ? "✅" : check.status === "fail" ? "❌" : "ℹ️ ";
|
|
397
|
+
console.log(`${icon} ${check.name} — ${check.detail}`);
|
|
398
|
+
}
|
|
399
|
+
console.log(
|
|
400
|
+
summary.ok
|
|
401
|
+
? "\n✅ Page Zero: install OK."
|
|
402
|
+
: "\n❌ Page Zero: install incomplete.",
|
|
403
|
+
);
|
|
366
404
|
}
|
|
367
|
-
|
|
368
|
-
return 0;
|
|
405
|
+
return summary.ok ? 0 : 1;
|
|
369
406
|
}
|
|
370
407
|
|
|
371
|
-
// ── Entry dispatcher ────────────────────────────────────────────
|
|
372
|
-
|
|
373
408
|
async function main() {
|
|
374
409
|
const subcommand = process.argv[2];
|
|
375
410
|
const jsonOutput = process.argv.includes("--json");
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
411
|
+
const surfacesArg = process.argv.find((arg) => arg.startsWith("--surfaces="));
|
|
412
|
+
const rawSurfaces = surfacesArg
|
|
413
|
+
? surfacesArg.slice("--surfaces=".length).split(",")
|
|
414
|
+
: ["aeo"];
|
|
415
|
+
if (
|
|
416
|
+
rawSurfaces.length === 0 ||
|
|
417
|
+
rawSurfaces.some((surface) => surface !== "aeo" && surface !== "cms")
|
|
418
|
+
) {
|
|
419
|
+
console.error("Use --surfaces=aeo, --surfaces=cms, or --surfaces=aeo,cms.");
|
|
420
|
+
return 1;
|
|
379
421
|
}
|
|
422
|
+
const surfaces = [...new Set(rawSurfaces)];
|
|
423
|
+
|
|
424
|
+
if (subcommand === "doctor") return runDoctor(jsonOutput, surfaces);
|
|
380
425
|
if (subcommand === "install" || subcommand === undefined) {
|
|
381
426
|
return runInstall();
|
|
382
427
|
}
|
|
383
|
-
console.error(
|
|
384
|
-
`Unknown subcommand: ${subcommand}. Available: install (default), doctor.`,
|
|
385
|
-
);
|
|
428
|
+
console.error(`Unknown subcommand: ${subcommand}. Use install or doctor.`);
|
|
386
429
|
return 1;
|
|
387
430
|
}
|
|
388
431
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soloworks/smking-next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "AI-native SEO (AEO) for Next.js — auto-inject JSON-LD, FAQ, AI summary, and SEO metadata so AI crawlers (ChatGPT, Perplexity, Google AI) can cite your pages.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/sillyleo/smking/tree/main/packages/smking-next",
|
package/src/lib/client.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import type {} from "next";
|
|
4
4
|
|
|
5
5
|
import type { AeoResponse, DiscoverParams } from "../types";
|
|
6
|
-
import { normalizePath, resolveRequestPath } from "./path";
|
|
6
|
+
import { isPathWithinPrefix, normalizePath, resolveRequestPath } from "./path";
|
|
7
7
|
import { SDK_VERSION } from "./version";
|
|
8
8
|
|
|
9
9
|
const DEFAULT_REVALIDATE_SECONDS = 3600;
|
|
@@ -91,6 +91,9 @@ export async function getAeoContent(
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
path = normalizePath(path);
|
|
94
|
+
if (isPathWithinPrefix(path, process.env.SMKING_CMS_PATH)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
94
97
|
|
|
95
98
|
try {
|
|
96
99
|
const res = await fetch(`${baseUrl}/api/v1/public/aeo`, {
|
|
@@ -110,11 +113,11 @@ export async function getAeoContent(
|
|
|
110
113
|
sdk_version: SDK_VERSION,
|
|
111
114
|
app_env: process.env.NODE_ENV ?? null,
|
|
112
115
|
host: safeHostname(url),
|
|
113
|
-
// Site-level
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
+
// Site-level Blog mount → dashboard "View page" URLs. Null means
|
|
117
|
+
// Blog was not selected; the wizard always writes the value when it
|
|
118
|
+
// wires Blog, including the default /blog path.
|
|
119
|
+
cms_base_path: process.env.SMKING_CMS_PATH ?? null,
|
|
116
120
|
},
|
|
117
|
-
|
|
118
121
|
}),
|
|
119
122
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
120
123
|
next: {
|
package/src/lib/path.ts
CHANGED
|
@@ -65,3 +65,20 @@ export function setRequestPathHeaders(
|
|
|
65
65
|
export function normalizePath(path: string): string {
|
|
66
66
|
return path.startsWith("/") ? path : "/" + path;
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True when a request belongs to the configured Page Zero Blog mount.
|
|
71
|
+
* Segment-aware matching avoids treating `/blogger` as a child of `/blog`.
|
|
72
|
+
*/
|
|
73
|
+
export function isPathWithinPrefix(
|
|
74
|
+
path: string,
|
|
75
|
+
prefix: string | undefined,
|
|
76
|
+
): boolean {
|
|
77
|
+
if (!prefix) return false;
|
|
78
|
+
const normalizedPath = normalizePath(path).replace(/\/+$/, "") || "/";
|
|
79
|
+
const normalizedPrefix = normalizePath(prefix).replace(/\/+$/, "") || "/";
|
|
80
|
+
return (
|
|
81
|
+
normalizedPath === normalizedPrefix ||
|
|
82
|
+
normalizedPath.startsWith(`${normalizedPrefix}/`)
|
|
83
|
+
);
|
|
84
|
+
}
|
package/src/lib/sitemap.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { MetadataRoute } from "next";
|
|
2
2
|
|
|
3
|
+
export const SMKING_SITEMAP_CACHE_TAG = "smking:sitemap";
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Drop-in `app/sitemap.ts` default export. Fetches the canonical sitemap
|
|
5
7
|
* served by Page Zero at `/api/v1/public/sitemap.xml?key=...`, parses
|
|
@@ -36,8 +38,12 @@ export default async function smkingSitemap(): Promise<MetadataRoute.Sitemap> {
|
|
|
36
38
|
`${baseUrl}/api/v1/public/sitemap.xml?key=${encodeURIComponent(apiKey)}`,
|
|
37
39
|
{
|
|
38
40
|
// Daily revalidation — sitemap doesn't change second-to-second.
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
+
// The signed publish webhook invalidates this tag immediately when
|
|
42
|
+
// AEO or CMS content changes; the TTL remains the fail-safe.
|
|
43
|
+
next: {
|
|
44
|
+
revalidate: 86_400,
|
|
45
|
+
tags: [SMKING_SITEMAP_CACHE_TAG],
|
|
46
|
+
},
|
|
41
47
|
signal: AbortSignal.timeout(10_000),
|
|
42
48
|
},
|
|
43
49
|
);
|
package/src/lib/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = "0.
|
|
1
|
+
export const SDK_VERSION = "0.22.1";
|
package/src/lib/webhook-route.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { revalidateTag } from "next/cache";
|
|
2
2
|
import { Buffer } from "node:buffer";
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
|
+
import { SMKING_SITEMAP_CACHE_TAG } from "./sitemap";
|
|
4
5
|
|
|
5
6
|
interface WebhookPayload {
|
|
6
7
|
kind?: string;
|
|
@@ -153,6 +154,23 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
// The canonical sitemap fetch has a daily TTL. A publish must evict it
|
|
158
|
+
// immediately or newly published CMS pages can stay absent for 24 hours.
|
|
159
|
+
// Limit this shared tag to content kinds that actually affect the sitemap;
|
|
160
|
+
// unknown future payloads keep their forward-compatible no-op behavior.
|
|
161
|
+
if (kind === "aeo" || kind === "cms_page") {
|
|
162
|
+
try {
|
|
163
|
+
revalidateTag(SMKING_SITEMAP_CACHE_TAG, "default");
|
|
164
|
+
revalidated++;
|
|
165
|
+
} catch (err) {
|
|
166
|
+
errors++;
|
|
167
|
+
console.warn(
|
|
168
|
+
"[@soloworks/smking-next/webhook] sitemap revalidateTag failed:",
|
|
169
|
+
err,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
156
174
|
return Response.json({ ok: true, kind, revalidated, errors });
|
|
157
175
|
}
|
|
158
176
|
|