@soloworks/smking-next 0.21.6 → 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,14 @@
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
+
3
12
  ## 0.21.6 — 2026-07-20
4
13
 
5
14
  **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
- **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 + 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
- 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,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, "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) {
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() in generateMetadata");
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
- if (missing.length > 0) {
226
- return {
227
- name: "AEO + SEO in root layout",
228
- status: "fail",
229
- detail: `${layoutPath} is missing ${missing.join(", ")}. Use withSmkingMetadata() for authoritative Next.js metadata and render <SmkingAEO includeSeo={false} /> inside <body>.`,
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
- return {
233
- name: "AEO + SEO in root layout",
234
- status: "pass",
235
- detail: layoutPath,
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
- * @returns {Promise<DoctorCheck>}
241
- */
242
- async function checkApiReachable() {
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: "API reachable",
304
+ name: label,
248
305
  status: "info",
249
- detail: "skipped — SMKING_API_KEY or SMKING_BASE_URL not set",
306
+ detail: "skipped because base environment is incomplete",
250
307
  };
251
308
  }
252
-
253
- // Probe the actual AEO endpoint, not the base URL root. Hitting root
254
- // would pass for any live host (a typo'd domain, google.com); the
255
- // endpoint either returns the AEO payload or a known auth/validation
256
- // status — both confirm the route exists.
257
- const endpoint = `${baseUrl.replace(/\/$/, "")}/api/v1/public/aeo`;
258
- 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`;
259
317
  try {
260
- const res = await fetch(url, {
261
- signal: AbortSignal.timeout(3000),
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: "API reachable",
323
+ name: label,
267
324
  status: "pass",
268
- detail: `${endpoint} → HTTP ${res.status}; API key accepted`,
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 (res.status === 404) {
279
- const payload = await res
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: "API reachable",
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: "API reachable",
342
+ name: label,
305
343
  status: "fail",
306
- detail: `${endpoint} returned unexpected HTTP ${res.status}`,
344
+ detail: `${endpoint} returned HTTP ${response.status}`,
307
345
  };
308
- } catch (err) {
346
+ } catch (error) {
309
347
  return {
310
- name: "API reachable",
348
+ name: label,
311
349
  status: "fail",
312
- detail: `connection failed: ${err instanceof Error ? err.message : String(err)}`,
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
- appDir
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
- 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"));
343
384
 
344
- // JSON mode: parseable structured output for @smking/wizard's run_doctor
345
- // 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
+ };
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
- return hasFailure ? 1 : 0;
355
- }
356
-
357
- for (const check of checks) {
358
- const icon =
359
- check.status === "pass" ? "✅" : check.status === "fail" ? "❌" : "ℹ️ ";
360
- console.log(`${icon} ${check.name} — ${check.detail}`);
361
- }
362
- console.log();
363
- if (hasFailure) {
364
- console.log("❌ Page Zero: install incomplete — fix the items above.");
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
- console.log("✅ Page Zero: install OK.");
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
- if (subcommand === "doctor") {
378
- 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;
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.21.6",
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",
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: {
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.6";
1
+ export const SDK_VERSION = "0.22.0";