@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 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
- **Don't follow this README to install.** Your Page Zero dashboard generates a per-site install prompt with the real `SMKING_API_KEY`, `SMKING_BASE_URL`, and (if you use CMS) `SMKING_WEBHOOK_SECRET` baked in, plus copy-pasteable layout / route shims. The prompt is the source of truth and stays in sync with the SDK version.
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 owns: `pnpm add @soloworks/smking-next`, `<SmkingAEO />` mount in `app/layout.tsx`, env writes, `app/api/smking/webhook/route.ts` shim, and doctor verification.
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
- ## How metadata wins / loses
29
+ ## Authoritative metadata
26
30
 
27
- Both `<SmkingAEO />` and your own `generateMetadata` emit head tags. Next.js + React 19 head dedup applies last-write-wins:
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
- - **No `generateMetadata`** → Page Zero's `<title>` / `og:*` are used.
30
- - **You write `generateMetadata` in a layout / page** → your tags override Page Zero's for that route segment.
31
-
32
- This is the pattern: Page Zero provides the AEO/SEO baseline, and you override per-page when needed. No HOF, no codemod.
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
- For client pages (`'use client'`) that need dynamic metadata, write a sibling `layout.tsx` with `generateMetadata` — standard Next.js workflow, unrelated to smking.
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
- ### Webhook payload
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
- Response: `{ revalidated: number, errors: number }`. `errors > 0` means some tags couldn't be revalidated (others still succeeded — partial-success delivery).
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
- ## CMS rendering (optional, v0.11.0+)
126
+ ## Page Zero Blog (optional)
116
127
 
117
- The base install only wires AEO. If you author content in the Page Zero dashboard CMS and want to render it on your Next.js site, use the `<SmkingCms slug="…" />` Server Component.
128
+ If you publish Blog pages from the Page Zero dashboard, render them with the
129
+ `<SmkingCms slug="…" />` Server Component.
118
130
 
119
- The SDK **does not have a "CMS root" config** — you choose any URL prefix (`/blog`, `/knowledge`, `/shop/articles`) and wire your own route. The component takes a `slug` prop, fetches the published page from `${SMKING_BASE_URL}/api/v1/public/page?slug=…`, and renders the Tiptap ProseMirror JSON as `<article class="smk-cms">…</article>` via `@tiptap/static-renderer/pm/react`. SEO `<title>` / `<meta>` / `og:*` / canonical hoist into `<head>` automatically via React 19.
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
- * `npx @soloworks/smking-next [install|doctor] [--json]`
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
- const candidates = ["app", "src/app"];
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
- const dir = fullPath.includes("/")
87
- ? fullPath.substring(0, fullPath.lastIndexOf("/"))
88
- : appDir;
89
- mkdirSync(dir, { recursive: true });
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
- // Older Node fallback or unreadable file — silently skip; doctor
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 \`${prefix}\``,
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, "utf-8");
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
- * @param {string} appDir
204
- * @returns {DoctorCheck}
205
- */
206
- function checkLayoutUsage(appDir) {
207
- const layoutPath = findRootLayout(appDir);
208
- if (!layoutPath) {
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: "<SmkingAEO /> in root layout",
137
+ name: "Next.js transpilePackages",
211
138
  status: "fail",
212
- detail: `no root layout owning <html> and <body> found under ${appDir}/`,
139
+ detail:
140
+ "next.config.* not found; add @soloworks/smking-next to transpilePackages",
213
141
  };
214
142
  }
215
-
216
- const content = readFileSync(layoutPath, "utf-8");
217
- if (!content.includes("SmkingAEO")) {
143
+ const content = readFileSync(configPath, "utf8");
144
+ if (
145
+ !content.includes("transpilePackages") ||
146
+ !content.includes("@soloworks/smking-next")
147
+ ) {
218
148
  return {
219
- name: "<SmkingAEO /> in root layout",
149
+ name: "Next.js transpilePackages",
220
150
  status: "fail",
221
- detail: `${layoutPath} does not import SmkingAEO. Add: import { SmkingAEO } from "@soloworks/smking-next"; then render <SmkingAEO apiKey={process.env.SMKING_API_KEY!} /> inside <body>.`,
151
+ detail: `${configPath} must include @soloworks/smking-next in transpilePackages`,
222
152
  };
223
153
  }
224
154
  return {
225
- name: "<SmkingAEO /> in root layout",
155
+ name: "Next.js transpilePackages",
226
156
  status: "pass",
227
- detail: layoutPath,
157
+ detail: configPath,
228
158
  };
229
159
  }
230
160
 
231
- /**
232
- * @returns {Promise<DoctorCheck>}
233
- */
234
- async function checkApiReachable() {
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: "API reachable",
304
+ name: label,
240
305
  status: "info",
241
- detail: "skipped — SMKING_API_KEY or SMKING_BASE_URL not set",
306
+ detail: "skipped because base environment is incomplete",
242
307
  };
243
308
  }
244
-
245
- // Probe the actual AEO endpoint, not the base URL root. Hitting root
246
- // would pass for any live host (a typo'd domain, google.com); the
247
- // endpoint either returns the AEO payload or a known auth/validation
248
- // status — both confirm the route exists.
249
- const endpoint = `${baseUrl.replace(/\/$/, "")}/api/v1/public/aeo`;
250
- const url = `${endpoint}?key=${encodeURIComponent(apiKey)}&path=%2F`;
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 res = await fetch(url, {
253
- signal: AbortSignal.timeout(3000),
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: "API reachable",
323
+ name: label,
259
324
  status: "pass",
260
- detail: `${endpoint} → HTTP ${res.status}; API key accepted`,
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 (res.status === 404) {
271
- const payload = await res
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: "API reachable",
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: "API reachable",
342
+ name: label,
297
343
  status: "fail",
298
- detail: `${endpoint} returned unexpected HTTP ${res.status}`,
344
+ detail: `${endpoint} returned HTTP ${response.status}`,
299
345
  };
300
- } catch (err) {
346
+ } catch (error) {
301
347
  return {
302
- name: "API reachable",
348
+ name: label,
303
349
  status: "fail",
304
- detail: `connection failed: ${err instanceof Error ? err.message : String(err)}`,
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
- appDir
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
- const hasFailure = checks.some((c) => c.status === "fail");
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
- // JSON mode: parseable structured output for @smking/wizard's run_doctor
337
- // tool. Stable shape — do not break without bumping wizard version.
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
- return hasFailure ? 1 : 0;
347
- }
348
-
349
- for (const check of checks) {
350
- const icon =
351
- check.status === "pass" ? "✅" : check.status === "fail" ? "❌" : "ℹ️ ";
352
- console.log(`${icon} ${check.name} — ${check.detail}`);
353
- }
354
- console.log();
355
- if (hasFailure) {
356
- console.log("❌ Page Zero: install incomplete — fix the items above.");
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
- console.log("✅ Page Zero: install OK.");
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
- if (subcommand === "doctor") {
370
- return runDoctor(jsonOutput);
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.21.5",
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 && <title data-smking="aeo">{seo.title}</title>}
97
- {description && (
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 CMS base path → smking dashboard "View page" URLs.
114
- // Default "/blog"; override with SMKING_CMS_PATH (wizard sets it).
115
- cms_base_path: process.env.SMKING_CMS_PATH ?? "/blog",
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
+ }
@@ -1 +1 @@
1
- export const SDK_VERSION = "0.21.5";
1
+ export const SDK_VERSION = "0.22.0";