@soloworks/smking-next 0.21.5 → 0.22.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 +19 -0
- package/README.md +37 -22
- package/bin/install.mjs +247 -196
- package/package.json +1 -1
- package/src/components/smking-aeo.tsx +16 -6
- package/src/index.ts +1 -0
- package/src/lib/client.ts +8 -5
- package/src/lib/metadata.ts +40 -0
- package/src/lib/path.ts +17 -0
- package/src/lib/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @soloworks/smking-next
|
|
2
2
|
|
|
3
|
+
## 0.22.0 — 2026-07-25
|
|
4
|
+
|
|
5
|
+
- AEO requests now skip the configured `SMKING_CMS_PATH`, preventing duplicate
|
|
6
|
+
SEO and JSON-LD on Page Zero Blog pages.
|
|
7
|
+
- AEO-only installs report `cms_base_path: null`; the wizard writes the Blog
|
|
8
|
+
path explicitly whenever Blog is selected.
|
|
9
|
+
- `smking-next doctor` accepts `--surfaces=aeo`, `cms`, or both and verifies
|
|
10
|
+
`transpilePackages`, Blog runtime, catch-all, preview, webhook, and secrets.
|
|
11
|
+
|
|
12
|
+
## 0.21.6 — 2026-07-20
|
|
13
|
+
|
|
14
|
+
**Next.js metadata can now be authoritative instead of duplicated.**
|
|
15
|
+
|
|
16
|
+
- Added `withSmkingMetadata()` for the App Router `generateMetadata` export.
|
|
17
|
+
Ready Page Zero SEO fields override the matching host fields while unrelated
|
|
18
|
+
host metadata stays intact.
|
|
19
|
+
- Added `<SmkingAEO includeSeo={false} />` so JSON-LD and hidden AEO content
|
|
20
|
+
remain rendered without emitting duplicate title and meta elements.
|
|
21
|
+
|
|
3
22
|
## 0.21.5 — 2026-07-20
|
|
4
23
|
|
|
5
24
|
**AEO path discovery now works on Vercel without replacing host proxy responses.**
|
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 + <SmkingAEO /> usage + API
|
|
19
|
-
* 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,194 +114,318 @@ 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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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) {
|
|
209
136
|
return {
|
|
210
|
-
name: "
|
|
137
|
+
name: "Next.js transpilePackages",
|
|
211
138
|
status: "fail",
|
|
212
|
-
detail:
|
|
139
|
+
detail:
|
|
140
|
+
"next.config.* not found; add @soloworks/smking-next to transpilePackages",
|
|
213
141
|
};
|
|
214
142
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
143
|
+
const content = readFileSync(configPath, "utf8");
|
|
144
|
+
if (
|
|
145
|
+
!content.includes("transpilePackages") ||
|
|
146
|
+
!content.includes("@soloworks/smking-next")
|
|
147
|
+
) {
|
|
218
148
|
return {
|
|
219
|
-
name: "
|
|
149
|
+
name: "Next.js transpilePackages",
|
|
220
150
|
status: "fail",
|
|
221
|
-
detail: `${
|
|
151
|
+
detail: `${configPath} must include @soloworks/smking-next in transpilePackages`,
|
|
222
152
|
};
|
|
223
153
|
}
|
|
224
154
|
return {
|
|
225
|
-
name: "
|
|
155
|
+
name: "Next.js transpilePackages",
|
|
226
156
|
status: "pass",
|
|
227
|
-
detail:
|
|
157
|
+
detail: configPath,
|
|
228
158
|
};
|
|
229
159
|
}
|
|
230
160
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
161
|
+
function checkAeoLayout(appDir) {
|
|
162
|
+
const layoutPath = findRootLayout(appDir);
|
|
163
|
+
if (!layoutPath) {
|
|
164
|
+
return {
|
|
165
|
+
name: "AEO + SEO in root layout",
|
|
166
|
+
status: "fail",
|
|
167
|
+
detail: `no root layout owning <html> and <body> found under ${appDir}/`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const content = readFileSync(layoutPath, "utf8");
|
|
171
|
+
const missing = [];
|
|
172
|
+
if (!content.includes("SmkingAEO")) missing.push("SmkingAEO");
|
|
173
|
+
if (!content.includes("withSmkingMetadata(")) {
|
|
174
|
+
missing.push("withSmkingMetadata()");
|
|
175
|
+
}
|
|
176
|
+
if (!/includeSeo\s*=\s*\{\s*false\s*\}/.test(content)) {
|
|
177
|
+
missing.push("includeSeo={false}");
|
|
178
|
+
}
|
|
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;
|
|
200
|
+
}
|
|
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 };
|
|
209
|
+
}
|
|
210
|
+
|
|
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) {
|
|
235
299
|
const apiKey = process.env.SMKING_API_KEY;
|
|
236
300
|
const baseUrl = process.env.SMKING_BASE_URL;
|
|
301
|
+
const label = surface === "cms" ? "Blog API reachable" : "AEO API reachable";
|
|
237
302
|
if (!apiKey || !baseUrl) {
|
|
238
303
|
return {
|
|
239
|
-
name:
|
|
304
|
+
name: label,
|
|
240
305
|
status: "info",
|
|
241
|
-
detail: "skipped
|
|
306
|
+
detail: "skipped because base environment is incomplete",
|
|
242
307
|
};
|
|
243
308
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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`;
|
|
251
317
|
try {
|
|
252
|
-
const
|
|
253
|
-
signal: AbortSignal.timeout(
|
|
318
|
+
const response = await fetch(url, {
|
|
319
|
+
signal: AbortSignal.timeout(3_000),
|
|
254
320
|
});
|
|
255
|
-
|
|
256
|
-
if (res.ok) {
|
|
321
|
+
if (response.ok) {
|
|
257
322
|
return {
|
|
258
|
-
name:
|
|
323
|
+
name: label,
|
|
259
324
|
status: "pass",
|
|
260
|
-
detail: `${endpoint} → HTTP ${
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
if (res.status === 401) {
|
|
264
|
-
return {
|
|
265
|
-
name: "API reachable",
|
|
266
|
-
status: "fail",
|
|
267
|
-
detail: `HTTP 401 from ${endpoint} — SMKING_API_KEY rejected`,
|
|
325
|
+
detail: `${endpoint} → HTTP ${response.status}; API key accepted`,
|
|
268
326
|
};
|
|
269
327
|
}
|
|
270
|
-
if (
|
|
271
|
-
const payload = await
|
|
328
|
+
if (response.status === 404) {
|
|
329
|
+
const payload = await response
|
|
272
330
|
.clone()
|
|
273
331
|
.json()
|
|
274
332
|
.catch(() => null);
|
|
275
333
|
if (payload?.status === "not_found") {
|
|
276
334
|
return {
|
|
277
|
-
name:
|
|
335
|
+
name: label,
|
|
278
336
|
status: "pass",
|
|
279
337
|
detail: `${endpoint} → HTTP 404 not_found; API key accepted`,
|
|
280
338
|
};
|
|
281
339
|
}
|
|
282
|
-
return {
|
|
283
|
-
name: "API reachable",
|
|
284
|
-
status: "fail",
|
|
285
|
-
detail: `HTTP 404 from ${endpoint} — SMKING_BASE_URL likely points at the wrong host`,
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
if (res.status >= 500) {
|
|
289
|
-
return {
|
|
290
|
-
name: "API reachable",
|
|
291
|
-
status: "fail",
|
|
292
|
-
detail: `upstream HTTP ${res.status} from ${endpoint}`,
|
|
293
|
-
};
|
|
294
340
|
}
|
|
295
341
|
return {
|
|
296
|
-
name:
|
|
342
|
+
name: label,
|
|
297
343
|
status: "fail",
|
|
298
|
-
detail: `${endpoint} returned
|
|
344
|
+
detail: `${endpoint} returned HTTP ${response.status}`,
|
|
299
345
|
};
|
|
300
|
-
} catch (
|
|
346
|
+
} catch (error) {
|
|
301
347
|
return {
|
|
302
|
-
name:
|
|
348
|
+
name: label,
|
|
303
349
|
status: "fail",
|
|
304
|
-
detail: `connection failed: ${
|
|
350
|
+
detail: `connection failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
305
351
|
};
|
|
306
352
|
}
|
|
307
353
|
}
|
|
308
354
|
|
|
309
|
-
|
|
310
|
-
* @param {boolean} jsonOutput
|
|
311
|
-
* @returns {Promise<number>}
|
|
312
|
-
*/
|
|
313
|
-
async function runDoctor(jsonOutput) {
|
|
314
|
-
// Mirror Next.js env loading so doctor sees what `next dev` would.
|
|
315
|
-
// Customers run this from project root; .env.local is the wizard's
|
|
316
|
-
// write target for SMKING_API_KEY + SMKING_BASE_URL.
|
|
355
|
+
async function runDoctor(jsonOutput, surfaces) {
|
|
317
356
|
loadEnvFiles();
|
|
318
|
-
|
|
319
357
|
const appDir = detectAppDir();
|
|
320
|
-
/** @type {DoctorCheck[]} */
|
|
321
358
|
const checks = [
|
|
322
359
|
checkEnv("SMKING_API_KEY", "pk_"),
|
|
323
360
|
checkEnv("SMKING_BASE_URL", "http"),
|
|
324
|
-
|
|
325
|
-
? checkLayoutUsage(appDir)
|
|
326
|
-
: {
|
|
327
|
-
name: "<SmkingAEO /> in root layout",
|
|
328
|
-
status: "info",
|
|
329
|
-
detail: "no app/ or src/app/ directory found — skipped",
|
|
330
|
-
},
|
|
331
|
-
await checkApiReachable(),
|
|
361
|
+
checkNextConfig(),
|
|
332
362
|
];
|
|
333
363
|
|
|
334
|
-
|
|
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"));
|
|
335
384
|
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
};
|
|
338
391
|
if (jsonOutput) {
|
|
339
|
-
const summary = {
|
|
340
|
-
passed: checks.filter((c) => c.status === "pass").length,
|
|
341
|
-
failed: checks.filter((c) => c.status === "fail").length,
|
|
342
|
-
info: checks.filter((c) => c.status === "info").length,
|
|
343
|
-
ok: !hasFailure,
|
|
344
|
-
};
|
|
345
392
|
console.log(JSON.stringify({ checks, summary }));
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
console.log(
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
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
|
+
);
|
|
358
404
|
}
|
|
359
|
-
|
|
360
|
-
return 0;
|
|
405
|
+
return summary.ok ? 0 : 1;
|
|
361
406
|
}
|
|
362
407
|
|
|
363
|
-
// ── Entry dispatcher ────────────────────────────────────────────
|
|
364
|
-
|
|
365
408
|
async function main() {
|
|
366
409
|
const subcommand = process.argv[2];
|
|
367
410
|
const jsonOutput = process.argv.includes("--json");
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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;
|
|
371
421
|
}
|
|
422
|
+
const surfaces = [...new Set(rawSurfaces)];
|
|
423
|
+
|
|
424
|
+
if (subcommand === "doctor") return runDoctor(jsonOutput, surfaces);
|
|
372
425
|
if (subcommand === "install" || subcommand === undefined) {
|
|
373
426
|
return runInstall();
|
|
374
427
|
}
|
|
375
|
-
console.error(
|
|
376
|
-
`Unknown subcommand: ${subcommand}. Available: install (default), doctor.`,
|
|
377
|
-
);
|
|
428
|
+
console.error(`Unknown subcommand: ${subcommand}. Use install or doctor.`);
|
|
378
429
|
return 1;
|
|
379
430
|
}
|
|
380
431
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soloworks/smking-next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "AI-native SEO (AEO) for Next.js — auto-inject JSON-LD, FAQ, AI summary, and SEO metadata so AI crawlers (ChatGPT, Perplexity, Google AI) can cite your pages.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/sillyleo/smking/tree/main/packages/smking-next",
|
|
@@ -16,7 +16,14 @@ const SR_ONLY_STYLE: React.CSSProperties = {
|
|
|
16
16
|
border: 0,
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
export interface SmkingAEOProps extends DiscoverParams {
|
|
19
|
+
export interface SmkingAEOProps extends DiscoverParams {
|
|
20
|
+
/**
|
|
21
|
+
* Keep the legacy React 19 metadata tags. Set false when the host uses
|
|
22
|
+
* `withSmkingMetadata()` from `generateMetadata`, which is the authoritative
|
|
23
|
+
* Next.js integration and avoids duplicate title/meta elements.
|
|
24
|
+
*/
|
|
25
|
+
includeSeo?: boolean;
|
|
26
|
+
}
|
|
20
27
|
|
|
21
28
|
async function wantsOriginBypass(): Promise<boolean> {
|
|
22
29
|
try {
|
|
@@ -82,6 +89,7 @@ export async function SmkingAEO(props: SmkingAEOProps) {
|
|
|
82
89
|
const hasBodyFragments = Boolean(
|
|
83
90
|
aeo.summaryHtml || aeo.faqHtml || seo?.ogImageUrl,
|
|
84
91
|
);
|
|
92
|
+
const includeSeo = props.includeSeo ?? true;
|
|
85
93
|
|
|
86
94
|
return (
|
|
87
95
|
<>
|
|
@@ -93,21 +101,23 @@ export async function SmkingAEO(props: SmkingAEOProps) {
|
|
|
93
101
|
/>
|
|
94
102
|
)}
|
|
95
103
|
|
|
96
|
-
{seo?.title &&
|
|
97
|
-
|
|
104
|
+
{includeSeo && seo?.title && (
|
|
105
|
+
<title data-smking="aeo">{seo.title}</title>
|
|
106
|
+
)}
|
|
107
|
+
{includeSeo && description && (
|
|
98
108
|
<meta name="description" content={description} data-smking="aeo" />
|
|
99
109
|
)}
|
|
100
|
-
{seo?.ogTitle && (
|
|
110
|
+
{includeSeo && seo?.ogTitle && (
|
|
101
111
|
<meta property="og:title" content={seo.ogTitle} data-smking="aeo" />
|
|
102
112
|
)}
|
|
103
|
-
{seo?.ogDescription && (
|
|
113
|
+
{includeSeo && seo?.ogDescription && (
|
|
104
114
|
<meta
|
|
105
115
|
property="og:description"
|
|
106
116
|
content={seo.ogDescription}
|
|
107
117
|
data-smking="aeo"
|
|
108
118
|
/>
|
|
109
119
|
)}
|
|
110
|
-
{seo?.ogImageUrl && (
|
|
120
|
+
{includeSeo && seo?.ogImageUrl && (
|
|
111
121
|
<meta
|
|
112
122
|
property="og:image"
|
|
113
123
|
content={seo.ogImageUrl}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { SmkingCms } from "./components/smking-cms";
|
|
|
3
3
|
export { SmkingRuntime } from "./components/smking-runtime";
|
|
4
4
|
export { getAeoContent } from "./lib/client";
|
|
5
5
|
export { getCmsPage } from "./lib/cms-client";
|
|
6
|
+
export { withSmkingMetadata } from "./lib/metadata";
|
|
6
7
|
export type {
|
|
7
8
|
AeoResponse,
|
|
8
9
|
AeoStatus,
|
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: {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
|
|
3
|
+
import type { DiscoverParams } from "../types";
|
|
4
|
+
import { getAeoContent } from "./client";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Merge Page Zero's ready SEO fields over an existing App Router metadata
|
|
8
|
+
* object. Use this from the root layout's `generateMetadata` export so Next.js
|
|
9
|
+
* emits one authoritative title and description instead of duplicate tags.
|
|
10
|
+
*/
|
|
11
|
+
export async function withSmkingMetadata(
|
|
12
|
+
hostMetadata: Metadata,
|
|
13
|
+
params: DiscoverParams,
|
|
14
|
+
): Promise<Metadata> {
|
|
15
|
+
const aeo = await getAeoContent(params);
|
|
16
|
+
if (!aeo || aeo.status !== "ready") return hostMetadata;
|
|
17
|
+
|
|
18
|
+
const seo = aeo.seo ?? null;
|
|
19
|
+
const title = seo?.title ?? null;
|
|
20
|
+
const description = seo?.ogDescription ?? aeo.metaDescription ?? null;
|
|
21
|
+
const ogTitle = seo?.ogTitle ?? title;
|
|
22
|
+
const ogDescription = seo?.ogDescription ?? aeo.metaDescription ?? null;
|
|
23
|
+
const ogImageUrl = seo?.ogImageUrl ?? null;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
...hostMetadata,
|
|
27
|
+
...(title ? { title } : {}),
|
|
28
|
+
...(description ? { description } : {}),
|
|
29
|
+
...(ogTitle || ogDescription || ogImageUrl
|
|
30
|
+
? {
|
|
31
|
+
openGraph: {
|
|
32
|
+
...(hostMetadata.openGraph ?? {}),
|
|
33
|
+
...(ogTitle ? { title: ogTitle } : {}),
|
|
34
|
+
...(ogDescription ? { description: ogDescription } : {}),
|
|
35
|
+
...(ogImageUrl ? { images: [ogImageUrl] } : {}),
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
: {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
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/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = "0.
|
|
1
|
+
export const SDK_VERSION = "0.22.0";
|