@soloworks/smking-next 0.21.2 → 0.21.4
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 +30 -14
- package/README.md +42 -32
- package/bin/install.mjs +69 -29
- package/package.json +1 -1
- package/src/cms-blocks.ts +112 -16
- package/src/components/smking-aeo.tsx +16 -0
- package/src/components/smking-runtime.tsx +7 -2
- package/src/index.ts +1 -1
- package/src/lib/llms-txt-route.ts +2 -2
- package/src/lib/robots.ts +3 -5
- package/src/lib/sitemap.ts +3 -3
- package/src/lib/version.ts +1 -1
- package/src/types.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @soloworks/smking-next
|
|
2
2
|
|
|
3
|
+
## 0.21.4 — 2026-07-20
|
|
4
|
+
|
|
5
|
+
**Doctor checks now validate the actual App Router root and the public API contract.**
|
|
6
|
+
|
|
7
|
+
- Recursively finds nested root layouts such as `app/[locale]/layout.tsx`; a missing root is now a failure.
|
|
8
|
+
- Probes the public AEO endpoint with its documented `?key=` parameter.
|
|
9
|
+
- Treats a valid `not_found` response as proof that the API key was accepted and fails on unexpected `4xx` responses.
|
|
10
|
+
- Uses Page Zero as the default SaaS origin and in customer-facing CLI output.
|
|
11
|
+
|
|
12
|
+
## 0.21.3 — 2026-07-03
|
|
13
|
+
|
|
14
|
+
**AEO original-vs-enhanced audits can now fetch the true host HTML.**
|
|
15
|
+
|
|
16
|
+
`<SmkingAEO>` now honors `x-smking-origin-mode: raw` by returning `null`
|
|
17
|
+
before calling the SaaS. This lets Page Zero fetch a customer page's real
|
|
18
|
+
pre-injection HTML for source-change detection, then compare it against the
|
|
19
|
+
served enhanced version. No customer code change; customers already on `^0.21`
|
|
20
|
+
receive it on the next install/update.
|
|
21
|
+
|
|
3
22
|
## 0.21.2 — 2026-07-02
|
|
4
23
|
|
|
5
24
|
**CMS taxonomy page types caught up with the SaaS catalog (types only — no runtime change).**
|
|
@@ -521,36 +540,33 @@ Next.js's `MetadataRoute.Robots` type doesn't model Cloudflare's `Content-Signal
|
|
|
521
540
|
|
|
522
541
|
```ts
|
|
523
542
|
// app/robots.ts — Next.js MetadataRoute (NO Content-Signal)
|
|
524
|
-
import type { MetadataRoute } from
|
|
525
|
-
import { smkingRobotsRules } from
|
|
543
|
+
import type { MetadataRoute } from "next";
|
|
544
|
+
import { smkingRobotsRules } from "@soloworks/smking-next/robots";
|
|
526
545
|
|
|
527
546
|
export default function robots(): MetadataRoute.Robots {
|
|
528
547
|
return {
|
|
529
|
-
rules: [
|
|
530
|
-
|
|
531
|
-
...smkingRobotsRules(),
|
|
532
|
-
],
|
|
533
|
-
sitemap: 'https://example.com/sitemap.xml',
|
|
548
|
+
rules: [{ userAgent: "*", disallow: ["/admin/"] }, ...smkingRobotsRules()],
|
|
549
|
+
sitemap: "https://example.com/sitemap.xml",
|
|
534
550
|
};
|
|
535
551
|
}
|
|
536
552
|
```
|
|
537
553
|
|
|
538
554
|
```ts
|
|
539
555
|
// app/robots.txt/route.ts — full robots.txt body (Content-Signal included)
|
|
540
|
-
import { smkingRobotsTxt } from
|
|
556
|
+
import { smkingRobotsTxt } from "@soloworks/smking-next/robots";
|
|
541
557
|
|
|
542
558
|
export function GET() {
|
|
543
559
|
return new Response(
|
|
544
560
|
smkingRobotsTxt({
|
|
545
|
-
rules: [{ userAgent:
|
|
546
|
-
sitemap:
|
|
561
|
+
rules: [{ userAgent: "*", disallow: ["/admin/"] }],
|
|
562
|
+
sitemap: "https://example.com/sitemap.xml",
|
|
547
563
|
}),
|
|
548
|
-
{ headers: {
|
|
564
|
+
{ headers: { "Content-Type": "text/plain" } },
|
|
549
565
|
);
|
|
550
566
|
}
|
|
551
567
|
```
|
|
552
568
|
|
|
553
|
-
Customer rules render
|
|
569
|
+
Customer rules render _before_ the smking AI bot block in both surfaces. `bots` config replaces (not merges) the default list — `{ CCBot: 'disallow' }` produces only one bot block, not eight. `contentSignal: null` (or `""`) drops the directive entirely; passing nothing uses the default `search=yes, ai-input=no, ai-train=no`.
|
|
554
570
|
|
|
555
571
|
### Default policy
|
|
556
572
|
|
|
@@ -580,8 +596,8 @@ Minimal-surface rewrite. The package is now three focused files instead of a 21-
|
|
|
580
596
|
### Public surface
|
|
581
597
|
|
|
582
598
|
```ts
|
|
583
|
-
import { SmkingAEO, getAeoContent } from
|
|
584
|
-
import { POST, GET } from
|
|
599
|
+
import { SmkingAEO, getAeoContent } from "@soloworks/smking-next";
|
|
600
|
+
import { POST, GET } from "@soloworks/smking-next/route";
|
|
585
601
|
```
|
|
586
602
|
|
|
587
603
|
Plus types: `AeoResponse`, `AeoStatus`, `SeoMeta`, `FaqItem`, `ChatLinks`, `DiscoverParams`.
|
package/README.md
CHANGED
|
@@ -3,20 +3,20 @@
|
|
|
3
3
|
AI-native SEO (AEO) for Next.js. One server component injects JSON-LD, OG tags, AI summary, and FAQ on every page so AI crawlers (ChatGPT, Perplexity, Google AI) can cite your content.
|
|
4
4
|
|
|
5
5
|
- **One server component** in your root layout — every URL gets its own AEO content automatically (`/products/nike-air`, `/products/adidas/red`, anything dynamic, no codemod needed).
|
|
6
|
-
- **Fail-fast, fail-open.** 2-second timeout + Next.js ISR — if
|
|
6
|
+
- **Fail-fast, fail-open.** 2-second timeout + Next.js ISR — if Page Zero is down, your page renders without injection. Never blocks.
|
|
7
7
|
- **Push updates** — webhook handler invalidates only the changed paths via `revalidateTag`.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
-
**Don't follow this README to install.** Your
|
|
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.
|
|
12
12
|
|
|
13
13
|
Two ways to get it:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
# Option 1 — one-shot wizard (installs deps + writes env + runs doctor)
|
|
17
|
-
npx @soloworks/smking-wizard
|
|
17
|
+
npx @soloworks/smking-wizard@latest
|
|
18
18
|
|
|
19
|
-
# Option 2 — copy the prompt manually from your
|
|
19
|
+
# Option 2 — copy the prompt manually from your Page Zero dashboard's
|
|
20
20
|
# install panel into your editor / coding agent.
|
|
21
21
|
```
|
|
22
22
|
|
|
@@ -26,10 +26,10 @@ The wizard owns: `pnpm add @soloworks/smking-next`, `<SmkingAEO />` mount in `ap
|
|
|
26
26
|
|
|
27
27
|
Both `<SmkingAEO />` and your own `generateMetadata` emit head tags. Next.js + React 19 head dedup applies last-write-wins:
|
|
28
28
|
|
|
29
|
-
- **No `generateMetadata`** →
|
|
30
|
-
- **You write `generateMetadata` in a layout / page** → your tags override
|
|
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
31
|
|
|
32
|
-
This is the pattern:
|
|
32
|
+
This is the pattern: Page Zero provides the AEO/SEO baseline, and you override per-page when needed. No HOF, no codemod.
|
|
33
33
|
|
|
34
34
|
For client pages (`'use client'`) that need dynamic metadata, write a sibling `layout.tsx` with `generateMetadata` — standard Next.js workflow, unrelated to smking.
|
|
35
35
|
|
|
@@ -38,8 +38,8 @@ For client pages (`'use client'`) that need dynamic metadata, write a sibling `l
|
|
|
38
38
|
`getAeoContent` wraps `fetch` with `AbortSignal.timeout(2000)` and Next.js ISR (`next: { revalidate: 3600, tags: ['smking:path:<path>'] }`):
|
|
39
39
|
|
|
40
40
|
- **Cache hit** (the common path): zero network. Tags allow webhook-driven invalidation.
|
|
41
|
-
- **Cache miss +
|
|
42
|
-
- **Cache miss +
|
|
41
|
+
- **Cache miss + Page Zero healthy**: one network roundtrip, response cached for 1h.
|
|
42
|
+
- **Cache miss + Page Zero down / hung**: returns null after at most 2s, page renders without injection. Next.js ISR retries on the next request after `revalidate`.
|
|
43
43
|
- **5xx / 4xx / parse error**: same fail-open path.
|
|
44
44
|
|
|
45
45
|
No circuit breaker, no retry, no status command — Next.js infrastructure already covers what those would do.
|
|
@@ -50,10 +50,10 @@ No circuit breaker, no retry, no status command — Next.js infrastructure alrea
|
|
|
50
50
|
|
|
51
51
|
```ts
|
|
52
52
|
interface SmkingAEOProps {
|
|
53
|
-
apiKey: string;
|
|
54
|
-
baseUrl?: string;
|
|
55
|
-
path?: string;
|
|
56
|
-
revalidate?: number;
|
|
53
|
+
apiKey: string; // required
|
|
54
|
+
baseUrl?: string; // override SMKING_BASE_URL env
|
|
55
|
+
path?: string; // explicit path; auto-resolved from headers() otherwise
|
|
56
|
+
revalidate?: number; // ISR seconds; default 3600 (1h)
|
|
57
57
|
}
|
|
58
58
|
```
|
|
59
59
|
|
|
@@ -92,11 +92,15 @@ Add `<SmkingRuntime />` once in your root layout — it emits a `<link>` to the
|
|
|
92
92
|
// app/layout.tsx
|
|
93
93
|
import { SmkingRuntime } from "@soloworks/smking-next";
|
|
94
94
|
|
|
95
|
-
export default function RootLayout({
|
|
95
|
+
export default function RootLayout({
|
|
96
|
+
children,
|
|
97
|
+
}: {
|
|
98
|
+
children: React.ReactNode;
|
|
99
|
+
}) {
|
|
96
100
|
return (
|
|
97
101
|
<html>
|
|
98
102
|
<body>
|
|
99
|
-
<SmkingRuntime />
|
|
103
|
+
<SmkingRuntime apiKey={process.env.SMKING_API_KEY!} />
|
|
100
104
|
{children}
|
|
101
105
|
</body>
|
|
102
106
|
</html>
|
|
@@ -104,42 +108,44 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
104
108
|
}
|
|
105
109
|
```
|
|
106
110
|
|
|
107
|
-
Optional `baseUrl` prop overrides `process.env.SMKING_BASE_URL`. Default: `https://
|
|
111
|
+
Optional `baseUrl` prop overrides `process.env.SMKING_BASE_URL`. Default: `https://getpagezero.com`.
|
|
108
112
|
|
|
109
113
|
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.
|
|
110
114
|
|
|
111
115
|
## CMS rendering (optional, v0.11.0+)
|
|
112
116
|
|
|
113
|
-
The base install only wires AEO. If you author content in the
|
|
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.
|
|
114
118
|
|
|
115
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.
|
|
116
120
|
|
|
117
|
-
###
|
|
121
|
+
### Optional catch-all route (handles the CMS root + nested slugs)
|
|
118
122
|
|
|
119
|
-
|
|
123
|
+
Page Zero CMS slugs can be nested, and the CMS root uses an empty slug. Use
|
|
124
|
+
Next.js optional catch-all `[[...slug]]` so one route handles `/blog`, flat
|
|
125
|
+
slugs, and every nested depth:
|
|
120
126
|
|
|
121
127
|
```tsx
|
|
122
|
-
// app/blog/[...slug]/page.tsx
|
|
128
|
+
// app/blog/[[...slug]]/page.tsx
|
|
123
129
|
import { SmkingCms } from "@soloworks/smking-next/cms";
|
|
124
130
|
|
|
125
131
|
export default async function Page({
|
|
126
132
|
params,
|
|
127
133
|
}: {
|
|
128
|
-
params: Promise<{ slug
|
|
134
|
+
params: Promise<{ slug?: string[] }>;
|
|
129
135
|
}) {
|
|
130
136
|
const { slug } = await params;
|
|
131
137
|
return (
|
|
132
138
|
<SmkingCms
|
|
133
139
|
apiKey={process.env.SMKING_API_KEY!}
|
|
134
|
-
slug={slug
|
|
140
|
+
slug={slug?.join("/") ?? ""}
|
|
135
141
|
/>
|
|
136
142
|
);
|
|
137
143
|
}
|
|
138
144
|
```
|
|
139
145
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
146
|
+
At `/blog`, `params.slug` is undefined and maps to the empty CMS slug. Flat
|
|
147
|
+
and nested paths map to `"hello"` and `"seo/intro"` respectively. A required
|
|
148
|
+
`[...slug]` would miss the CMS root.
|
|
143
149
|
|
|
144
150
|
### Markup contract for CSS
|
|
145
151
|
|
|
@@ -148,17 +154,21 @@ Single-bracket `[slug]` (without the three dots) **only matches one segment**
|
|
|
148
154
|
<h1 class="smk-cms__title">…</h1>
|
|
149
155
|
<p>standard prose</p>
|
|
150
156
|
<h2>headings</h2>
|
|
151
|
-
<ul
|
|
157
|
+
<ul>
|
|
158
|
+
<li>lists</li>
|
|
159
|
+
</ul>
|
|
152
160
|
<blockquote>…</blockquote>
|
|
153
161
|
<pre><code>code blocks</code></pre>
|
|
154
162
|
<a href="…">links</a>
|
|
155
|
-
<img src="…" alt="…"
|
|
156
|
-
<div
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
<img src="…" alt="…" />
|
|
164
|
+
<div
|
|
165
|
+
data-type="gallery"
|
|
166
|
+
data-layout="grid"
|
|
167
|
+
data-columns="3"
|
|
168
|
+
class="smk-gallery smk-gallery--grid"
|
|
169
|
+
>
|
|
160
170
|
<figure class="smk-gallery__item">
|
|
161
|
-
<img src="…" alt="…" loading="lazy"
|
|
171
|
+
<img src="…" alt="…" loading="lazy" />
|
|
162
172
|
<figcaption>optional</figcaption>
|
|
163
173
|
</figure>
|
|
164
174
|
</div>
|
package/bin/install.mjs
CHANGED
|
@@ -22,7 +22,13 @@
|
|
|
22
22
|
* Conventional Next.js layout assumed: `app/` at repo root (or under
|
|
23
23
|
* `src/app/` — detected). Both subcommands exit 1 on missing app/.
|
|
24
24
|
*/
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
existsSync,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
readFileSync,
|
|
29
|
+
readdirSync,
|
|
30
|
+
writeFileSync,
|
|
31
|
+
} from "node:fs";
|
|
26
32
|
import { join } from "node:path";
|
|
27
33
|
|
|
28
34
|
// ── install (file scaffold) ─────────────────────────────────────
|
|
@@ -124,7 +130,6 @@ function loadEnvFiles() {
|
|
|
124
130
|
}
|
|
125
131
|
}
|
|
126
132
|
|
|
127
|
-
|
|
128
133
|
/**
|
|
129
134
|
* @typedef {{ name: string, status: "pass" | "fail" | "info", detail: string }} DoctorCheck
|
|
130
135
|
*/
|
|
@@ -157,25 +162,54 @@ function checkEnv(name, prefix) {
|
|
|
157
162
|
};
|
|
158
163
|
}
|
|
159
164
|
|
|
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
|
+
function findRootLayout(appDir) {
|
|
173
|
+
const conventional = ["tsx", "jsx", "ts", "js"]
|
|
174
|
+
.map((extension) => join(appDir, `layout.${extension}`))
|
|
175
|
+
.find((path) => existsSync(path));
|
|
176
|
+
if (conventional) return conventional;
|
|
177
|
+
|
|
178
|
+
/** @type {Array<{ path: string, depth: number }>} */
|
|
179
|
+
const nested = [];
|
|
180
|
+
function visit(directory, depth) {
|
|
181
|
+
if (depth > 4) return;
|
|
182
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
183
|
+
if (entry.name.startsWith(".")) continue;
|
|
184
|
+
const path = join(directory, entry.name);
|
|
185
|
+
if (entry.isDirectory()) {
|
|
186
|
+
visit(path, depth + 1);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!/^layout\.(tsx|jsx|ts|js)$/.test(entry.name)) continue;
|
|
190
|
+
const content = readFileSync(path, "utf-8");
|
|
191
|
+
if (/<html(?:\s|>)/.test(content) && /<body(?:\s|>)/.test(content)) {
|
|
192
|
+
nested.push({ path, depth });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
visit(appDir, 0);
|
|
198
|
+
nested.sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path));
|
|
199
|
+
return nested[0]?.path ?? null;
|
|
200
|
+
}
|
|
201
|
+
|
|
160
202
|
/**
|
|
161
203
|
* @param {string} appDir
|
|
162
204
|
* @returns {DoctorCheck}
|
|
163
205
|
*/
|
|
164
206
|
function checkLayoutUsage(appDir) {
|
|
165
|
-
|
|
166
|
-
// match what Next.js itself would pick.
|
|
167
|
-
const candidates = [
|
|
168
|
-
join(appDir, "layout.tsx"),
|
|
169
|
-
join(appDir, "layout.jsx"),
|
|
170
|
-
join(appDir, "layout.ts"),
|
|
171
|
-
join(appDir, "layout.js"),
|
|
172
|
-
];
|
|
173
|
-
const layoutPath = candidates.find((p) => existsSync(p));
|
|
207
|
+
const layoutPath = findRootLayout(appDir);
|
|
174
208
|
if (!layoutPath) {
|
|
175
209
|
return {
|
|
176
210
|
name: "<SmkingAEO /> in root layout",
|
|
177
|
-
status: "
|
|
178
|
-
detail: `no layout
|
|
211
|
+
status: "fail",
|
|
212
|
+
detail: `no root layout owning <html> and <body> found under ${appDir}/`,
|
|
179
213
|
};
|
|
180
214
|
}
|
|
181
215
|
|
|
@@ -212,10 +246,10 @@ async function checkApiReachable() {
|
|
|
212
246
|
// would pass for any live host (a typo'd domain, google.com); the
|
|
213
247
|
// endpoint either returns the AEO payload or a known auth/validation
|
|
214
248
|
// status — both confirm the route exists.
|
|
215
|
-
const
|
|
249
|
+
const endpoint = `${baseUrl.replace(/\/$/, "")}/api/v1/public/aeo`;
|
|
250
|
+
const url = `${endpoint}?key=${encodeURIComponent(apiKey)}&path=%2F`;
|
|
216
251
|
try {
|
|
217
252
|
const res = await fetch(url, {
|
|
218
|
-
headers: { authorization: `Bearer ${apiKey}` },
|
|
219
253
|
signal: AbortSignal.timeout(3000),
|
|
220
254
|
});
|
|
221
255
|
|
|
@@ -223,35 +257,45 @@ async function checkApiReachable() {
|
|
|
223
257
|
return {
|
|
224
258
|
name: "API reachable",
|
|
225
259
|
status: "pass",
|
|
226
|
-
detail: `${
|
|
260
|
+
detail: `${endpoint} → HTTP ${res.status}; API key accepted`,
|
|
227
261
|
};
|
|
228
262
|
}
|
|
229
263
|
if (res.status === 401) {
|
|
230
264
|
return {
|
|
231
265
|
name: "API reachable",
|
|
232
266
|
status: "fail",
|
|
233
|
-
detail: `HTTP 401 from ${
|
|
267
|
+
detail: `HTTP 401 from ${endpoint} — SMKING_API_KEY rejected`,
|
|
234
268
|
};
|
|
235
269
|
}
|
|
236
270
|
if (res.status === 404) {
|
|
271
|
+
const payload = await res
|
|
272
|
+
.clone()
|
|
273
|
+
.json()
|
|
274
|
+
.catch(() => null);
|
|
275
|
+
if (payload?.status === "not_found") {
|
|
276
|
+
return {
|
|
277
|
+
name: "API reachable",
|
|
278
|
+
status: "pass",
|
|
279
|
+
detail: `${endpoint} → HTTP 404 not_found; API key accepted`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
237
282
|
return {
|
|
238
283
|
name: "API reachable",
|
|
239
284
|
status: "fail",
|
|
240
|
-
detail: `HTTP 404 from ${
|
|
285
|
+
detail: `HTTP 404 from ${endpoint} — SMKING_BASE_URL likely points at the wrong host`,
|
|
241
286
|
};
|
|
242
287
|
}
|
|
243
288
|
if (res.status >= 500) {
|
|
244
289
|
return {
|
|
245
290
|
name: "API reachable",
|
|
246
291
|
status: "fail",
|
|
247
|
-
detail: `upstream HTTP ${res.status} from ${
|
|
292
|
+
detail: `upstream HTTP ${res.status} from ${endpoint}`,
|
|
248
293
|
};
|
|
249
294
|
}
|
|
250
|
-
// 4xx (other than 401/404) — likely validation error, endpoint exists
|
|
251
295
|
return {
|
|
252
296
|
name: "API reachable",
|
|
253
|
-
status: "
|
|
254
|
-
detail: `${
|
|
297
|
+
status: "fail",
|
|
298
|
+
detail: `${endpoint} returned unexpected HTTP ${res.status}`,
|
|
255
299
|
};
|
|
256
300
|
} catch (err) {
|
|
257
301
|
return {
|
|
@@ -304,19 +348,15 @@ async function runDoctor(jsonOutput) {
|
|
|
304
348
|
|
|
305
349
|
for (const check of checks) {
|
|
306
350
|
const icon =
|
|
307
|
-
check.status === "pass"
|
|
308
|
-
? "✅"
|
|
309
|
-
: check.status === "fail"
|
|
310
|
-
? "❌"
|
|
311
|
-
: "ℹ️ ";
|
|
351
|
+
check.status === "pass" ? "✅" : check.status === "fail" ? "❌" : "ℹ️ ";
|
|
312
352
|
console.log(`${icon} ${check.name} — ${check.detail}`);
|
|
313
353
|
}
|
|
314
354
|
console.log();
|
|
315
355
|
if (hasFailure) {
|
|
316
|
-
console.log("❌
|
|
356
|
+
console.log("❌ Page Zero: install incomplete — fix the items above.");
|
|
317
357
|
return 1;
|
|
318
358
|
}
|
|
319
|
-
console.log("✅
|
|
359
|
+
console.log("✅ Page Zero: install OK.");
|
|
320
360
|
return 0;
|
|
321
361
|
}
|
|
322
362
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soloworks/smking-next",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.4",
|
|
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/cms-blocks.ts
CHANGED
|
@@ -27,6 +27,35 @@
|
|
|
27
27
|
* slug-prefix derived, tag mode targets `taxonomies.slug`.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
export interface YouTubeEmbedOptions {
|
|
31
|
+
autoplay?: boolean;
|
|
32
|
+
ccLangPref?: string;
|
|
33
|
+
ccLoadPolicy?: boolean;
|
|
34
|
+
color?: "red" | "white";
|
|
35
|
+
controls?: boolean;
|
|
36
|
+
disablekb?: boolean;
|
|
37
|
+
enablejsapi?: boolean;
|
|
38
|
+
end?: number;
|
|
39
|
+
fs?: boolean;
|
|
40
|
+
hl?: string;
|
|
41
|
+
ivLoadPolicy?: "show" | "hide";
|
|
42
|
+
list?: string;
|
|
43
|
+
listType?: "playlist" | "user_uploads";
|
|
44
|
+
loop?: boolean;
|
|
45
|
+
origin?: string;
|
|
46
|
+
playlist?: string;
|
|
47
|
+
playsinline?: boolean;
|
|
48
|
+
relatedMode?: "all" | "same-channel";
|
|
49
|
+
start?: number;
|
|
50
|
+
widgetReferrer?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface HeroYouTubeProps extends YouTubeEmbedOptions {
|
|
54
|
+
url: string;
|
|
55
|
+
videoId: string;
|
|
56
|
+
thumbnailUrl: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
30
59
|
export interface HeroProps {
|
|
31
60
|
title: string;
|
|
32
61
|
/** Generic visible meta lines. Legacy drafts may still also carry subtitle. */
|
|
@@ -39,6 +68,9 @@ export interface HeroProps {
|
|
|
39
68
|
/** Legacy single-line fallback for SDK Mode B consumers. */
|
|
40
69
|
subtitle?: string;
|
|
41
70
|
image?: { url: string; alt: string };
|
|
71
|
+
/** YouTube hero media. Article body renders the embed; cards / metadata use
|
|
72
|
+
* the thumbnail so list-like pages never mount a player. */
|
|
73
|
+
youtube?: HeroYouTubeProps;
|
|
42
74
|
cta?: { label: string; href: string };
|
|
43
75
|
/** Header layout — omitted = the original centred hero; "start" = the
|
|
44
76
|
* Apple-newsroom-style left-aligned header; "cover" = title-only banner over
|
|
@@ -90,12 +122,14 @@ export interface NavSnapshotEntry {
|
|
|
90
122
|
}
|
|
91
123
|
|
|
92
124
|
export type SearchQuickLinkSource = "category-by-path" | "tag" | "url";
|
|
125
|
+
export type LinkDataAttributes = Record<string, string>;
|
|
93
126
|
|
|
94
127
|
/**
|
|
95
|
-
* Optional links shown beside the search trigger on desktop and
|
|
96
|
-
*
|
|
97
|
-
* + path so publish-time materialization can bake the
|
|
98
|
-
* prefix into `href`; URL links carry the author's
|
|
128
|
+
* Optional links shown beside the search trigger on desktop and in a mobile
|
|
129
|
+
* hamburger dropdown outside the search panel. Category/tag links keep their
|
|
130
|
+
* semantic source + path so publish-time materialization can bake the
|
|
131
|
+
* customer's CMS mount prefix into `href`; URL links carry the author's
|
|
132
|
+
* literal `href`.
|
|
99
133
|
*/
|
|
100
134
|
export interface SearchQuickLink {
|
|
101
135
|
label: string;
|
|
@@ -104,6 +138,22 @@ export interface SearchQuickLink {
|
|
|
104
138
|
path?: string;
|
|
105
139
|
/** Materialized href for category/tag links, or the author-entered URL. */
|
|
106
140
|
href?: string;
|
|
141
|
+
/** Optional author-selected data-* attributes for analytics integrations. */
|
|
142
|
+
dataAttributes?: LinkDataAttributes;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface SearchSuggestionLink {
|
|
146
|
+
label: string;
|
|
147
|
+
source: Exclude<SearchQuickLinkSource, "url">;
|
|
148
|
+
/** Category slug path or flat tag slug. */
|
|
149
|
+
path: string;
|
|
150
|
+
/** Materialized customer-site href. */
|
|
151
|
+
href: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface SearchSuggestionsSnapshot {
|
|
155
|
+
categories: SearchSuggestionLink[];
|
|
156
|
+
tags: SearchSuggestionLink[];
|
|
107
157
|
}
|
|
108
158
|
|
|
109
159
|
/**
|
|
@@ -122,22 +172,18 @@ export interface SearchProps {
|
|
|
122
172
|
/** Content column vs wide breakout (default wide). Mirrors the media
|
|
123
173
|
* blocks (image/embed). */
|
|
124
174
|
widthMode?: "content" | "wide";
|
|
175
|
+
/** Desktop quick-link cluster alignment beside the search trigger. Defaults left. */
|
|
176
|
+
linkAlign?: "left" | "right";
|
|
177
|
+
/** Desktop quick-link visual treatment. Defaults badge. */
|
|
178
|
+
linkStyle?: "badge" | "plain";
|
|
125
179
|
/** Optional quick links adjacent to search. */
|
|
126
180
|
links?: SearchQuickLink[];
|
|
181
|
+
/** Publish-time default browse links shown before the visitor types. */
|
|
182
|
+
suggestions?: SearchSuggestionsSnapshot;
|
|
127
183
|
/** Publish-time materialized snapshot — every published page on the site. */
|
|
128
184
|
snapshot?: NavSnapshotEntry[];
|
|
129
185
|
}
|
|
130
186
|
|
|
131
|
-
export interface RecentPostsProps extends ModuleHeader {
|
|
132
|
-
/** Author-set number of latest posts to show (1..50). */
|
|
133
|
-
limit: number;
|
|
134
|
-
/** Content column vs wide breakout (default content). Mirrors the media
|
|
135
|
-
* blocks. */
|
|
136
|
-
widthMode?: "content" | "wide";
|
|
137
|
-
/** Publish-time materialized snapshot — whole-site newest-first. */
|
|
138
|
-
snapshot?: NavSnapshotEntry[];
|
|
139
|
-
}
|
|
140
|
-
|
|
141
187
|
/** One author-picked source for nav-related-posts. Same shape as
|
|
142
188
|
* `CategoryIndexItem` (a category path OR a tag), reused so the source picker
|
|
143
189
|
* UI stays identical across blocks. */
|
|
@@ -188,6 +234,28 @@ export interface ArticleTagsProps {
|
|
|
188
234
|
snapshot?: ArticleTagSnapshot[];
|
|
189
235
|
}
|
|
190
236
|
|
|
237
|
+
/** One tag in the site-level tag cloud. */
|
|
238
|
+
export interface TagCloudEntry {
|
|
239
|
+
slug: string;
|
|
240
|
+
name: string;
|
|
241
|
+
/** Virtual tag archive href, e.g. `/blog/tag/tofu-life`. */
|
|
242
|
+
href: string;
|
|
243
|
+
/** Number of published article pages carrying this tag. */
|
|
244
|
+
count: number;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* `tag-cloud` block — site-level tag browser. It is intentionally separate
|
|
249
|
+
* from `nav-category-index`: no cards, no latest-post preview, just tag chips.
|
|
250
|
+
*/
|
|
251
|
+
export interface TagCloudProps {
|
|
252
|
+
heading?: string;
|
|
253
|
+
/** Content column vs wide breakout. Omitted = content. */
|
|
254
|
+
widthMode?: "content" | "wide";
|
|
255
|
+
/** Publish-time materialized tags attached to published article pages. */
|
|
256
|
+
snapshot?: TagCloudEntry[];
|
|
257
|
+
}
|
|
258
|
+
|
|
191
259
|
/** One author-selected category OR tag in a nav-category-index block. */
|
|
192
260
|
export interface CategoryIndexItem {
|
|
193
261
|
/** Slug prefix (category-by-path) or taxonomy slug (tag), per `source`.
|
|
@@ -350,6 +418,8 @@ export interface ButtonItemProps {
|
|
|
350
418
|
/** Apple-style looks: `filled` = primary capsule, `tinted` = soft primary
|
|
351
419
|
* wash, `outline` = bordered. Omitted = filled. */
|
|
352
420
|
variant?: "filled" | "tinted" | "outline";
|
|
421
|
+
/** Optional author-selected data-* attributes for analytics integrations. */
|
|
422
|
+
dataAttributes?: LinkDataAttributes;
|
|
353
423
|
}
|
|
354
424
|
|
|
355
425
|
/**
|
|
@@ -369,7 +439,7 @@ export interface ButtonGroupProps {
|
|
|
369
439
|
* url = renders nothing on the customer side. `widthMode` matches the other
|
|
370
440
|
* media blocks (content column vs wide breakout).
|
|
371
441
|
*/
|
|
372
|
-
export interface EmbedProps {
|
|
442
|
+
export interface EmbedProps extends YouTubeEmbedOptions {
|
|
373
443
|
url?: string;
|
|
374
444
|
caption?: string;
|
|
375
445
|
widthMode?: "content" | "wide";
|
|
@@ -416,6 +486,20 @@ export type LatestNewsSlot =
|
|
|
416
486
|
source?: "category-by-path" | "tag";
|
|
417
487
|
};
|
|
418
488
|
|
|
489
|
+
/** Whole-block source mode for `latest-news`. Omitted = `manual` so old
|
|
490
|
+
* blocks keep their curated slots exactly. */
|
|
491
|
+
export type LatestNewsMode =
|
|
492
|
+
| "manual"
|
|
493
|
+
| "latest"
|
|
494
|
+
| "auto-category"
|
|
495
|
+
| "category"
|
|
496
|
+
| "tag";
|
|
497
|
+
|
|
498
|
+
/** First-card layout for `latest-news`. Omitted = `stacked`, the default
|
|
499
|
+
* image-above/text-below card. `split` preserves the original desktop
|
|
500
|
+
* image-left/text-right presentation. */
|
|
501
|
+
export type LatestNewsLayout = "stacked" | "split";
|
|
502
|
+
|
|
419
503
|
/**
|
|
420
504
|
* One materialized `latest-news` card: the resolved article entry (the
|
|
421
505
|
* article itself for an `article` slot, or the category's latest post for a
|
|
@@ -436,8 +520,20 @@ export interface LatestNewsCardSnapshot {
|
|
|
436
520
|
* feeds; reuses `NavSnapshotEntry` for each resolved card.
|
|
437
521
|
*/
|
|
438
522
|
export interface LatestNewsProps extends ModuleHeader {
|
|
523
|
+
/** First-card presentation. Omitted/default = image above text below. */
|
|
524
|
+
layout?: LatestNewsLayout;
|
|
525
|
+
/** Whole-block source mode. Omitted/manual uses `slots`; other modes resolve
|
|
526
|
+
* cards dynamically at publish time. */
|
|
527
|
+
mode?: LatestNewsMode;
|
|
439
528
|
/** Author-curated slots, 3–6, in display order (first = feature card). */
|
|
440
529
|
slots: LatestNewsSlot[];
|
|
530
|
+
/** Card count for automatic modes. The magazine grid supports 3–6 cards. */
|
|
531
|
+
limit?: number;
|
|
532
|
+
/** Selected category/tag path for `category` / `tag` modes. */
|
|
533
|
+
sourcePath?: string;
|
|
534
|
+
/** Editor-facing label for the selected category/tag; used as the card
|
|
535
|
+
* eyebrow in automatic source modes. */
|
|
536
|
+
sourceLabel?: string;
|
|
441
537
|
/** Content column vs wide breakout (default wide). Mirrors the media
|
|
442
538
|
* blocks. */
|
|
443
539
|
widthMode?: "content" | "wide";
|
|
@@ -454,9 +550,9 @@ export type Block =
|
|
|
454
550
|
props: NavTaxonomyListProps;
|
|
455
551
|
}
|
|
456
552
|
| { component: "search"; id: string; props: SearchProps }
|
|
457
|
-
| { component: "nav-recent-posts"; id: string; props: RecentPostsProps }
|
|
458
553
|
| { component: "nav-related-posts"; id: string; props: RelatedPostsProps }
|
|
459
554
|
| { component: "article-tags"; id: string; props: ArticleTagsProps }
|
|
555
|
+
| { component: "tag-cloud"; id: string; props: TagCloudProps }
|
|
460
556
|
| { component: "nav-category-index"; id: string; props: CategoryIndexProps }
|
|
461
557
|
| { component: "image"; id: string; props: ImageProps }
|
|
462
558
|
| { component: "slideshow"; id: string; props: SlideshowProps }
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { headers } from "next/headers";
|
|
2
|
+
|
|
1
3
|
import type { DiscoverParams } from "../types";
|
|
2
4
|
import { getAeoContent } from "../lib/client";
|
|
3
5
|
import { safeJson } from "../lib/safe-json";
|
|
@@ -16,6 +18,18 @@ const SR_ONLY_STYLE: React.CSSProperties = {
|
|
|
16
18
|
|
|
17
19
|
export interface SmkingAEOProps extends DiscoverParams {}
|
|
18
20
|
|
|
21
|
+
async function wantsOriginBypass(): Promise<boolean> {
|
|
22
|
+
try {
|
|
23
|
+
const requestHeaders = await headers();
|
|
24
|
+
return (
|
|
25
|
+
requestHeaders.get("x-smking-origin-mode")?.trim().toLowerCase() ===
|
|
26
|
+
"raw"
|
|
27
|
+
);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
/**
|
|
20
34
|
* Server Component that injects AEO + SEO content for the current
|
|
21
35
|
* request. Place once in the root layout, inside `<body>`:
|
|
@@ -57,6 +71,8 @@ export interface SmkingAEOProps extends DiscoverParams {}
|
|
|
57
71
|
* arbitrary content fetched anywhere.
|
|
58
72
|
*/
|
|
59
73
|
export async function SmkingAEO(props: SmkingAEOProps) {
|
|
74
|
+
if (await wantsOriginBypass()) return null;
|
|
75
|
+
|
|
60
76
|
const aeo = await getAeoContent(props);
|
|
61
77
|
if (!aeo || aeo.status !== "ready") return null;
|
|
62
78
|
|
|
@@ -21,13 +21,18 @@ export function SmkingRuntime({
|
|
|
21
21
|
apiKey?: string;
|
|
22
22
|
}) {
|
|
23
23
|
const url = (
|
|
24
|
-
baseUrl ??
|
|
24
|
+
baseUrl ??
|
|
25
|
+
process.env.SMKING_BASE_URL ??
|
|
26
|
+
"https://getpagezero.com"
|
|
25
27
|
).replace(/\/$/, "");
|
|
26
28
|
return (
|
|
27
29
|
<>
|
|
28
30
|
<link rel="stylesheet" href={`${url}/api/v1/public/runtime.css`} />
|
|
29
31
|
{apiKey && (
|
|
30
|
-
<link
|
|
32
|
+
<link
|
|
33
|
+
rel="stylesheet"
|
|
34
|
+
href={`${url}/api/v1/public/theme.css?key=${apiKey}`}
|
|
35
|
+
/>
|
|
31
36
|
)}
|
|
32
37
|
<script src={`${url}/api/v1/public/runtime.js`} async />
|
|
33
38
|
</>
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Drop-in `app/llms.txt/route.ts` GET handler. Proxies the canonical
|
|
3
|
-
* llms.txt served by
|
|
3
|
+
* llms.txt served by Page Zero at `/api/v1/public/llms-txt?key=...`.
|
|
4
4
|
*
|
|
5
5
|
* ```ts
|
|
6
6
|
* // app/llms.txt/route.ts
|
|
@@ -22,7 +22,7 @@ export async function GET(): Promise<Response> {
|
|
|
22
22
|
if (!apiKey) {
|
|
23
23
|
return new Response("Not found", { status: 404 });
|
|
24
24
|
}
|
|
25
|
-
const baseUrl = process.env.SMKING_BASE_URL ?? "https://
|
|
25
|
+
const baseUrl = process.env.SMKING_BASE_URL ?? "https://getpagezero.com";
|
|
26
26
|
|
|
27
27
|
try {
|
|
28
28
|
const res = await fetch(
|
package/src/lib/robots.ts
CHANGED
|
@@ -64,9 +64,7 @@ export const SMKING_DEFAULT_CONTENT_SIGNAL =
|
|
|
64
64
|
* customers who need that directive should switch to
|
|
65
65
|
* `smkingRobotsTxt()` and serve via a route handler.
|
|
66
66
|
*/
|
|
67
|
-
export function smkingRobotsRules(
|
|
68
|
-
config: SmkingRobotsConfig = {},
|
|
69
|
-
): Array<{
|
|
67
|
+
export function smkingRobotsRules(config: SmkingRobotsConfig = {}): Array<{
|
|
70
68
|
userAgent: string;
|
|
71
69
|
allow?: string;
|
|
72
70
|
disallow?: string;
|
|
@@ -169,7 +167,7 @@ function toArray<T>(value: T | T[] | undefined): T[] {
|
|
|
169
167
|
|
|
170
168
|
/**
|
|
171
169
|
* Drop-in `app/robots.ts` default export. Fetches the canonical robots.txt
|
|
172
|
-
* served by
|
|
170
|
+
* served by Page Zero at `/api/v1/public/robots.txt?key=...` and
|
|
173
171
|
* returns a `Response` with `text/plain`. Customer code stays one line:
|
|
174
172
|
*
|
|
175
173
|
* ```ts
|
|
@@ -201,7 +199,7 @@ export default async function smkingRobotsRoute(): Promise<Response> {
|
|
|
201
199
|
}
|
|
202
200
|
const apiKey = process.env.SMKING_API_KEY;
|
|
203
201
|
if (!apiKey) return permissiveFallback();
|
|
204
|
-
const baseUrl = process.env.SMKING_BASE_URL ?? "https://
|
|
202
|
+
const baseUrl = process.env.SMKING_BASE_URL ?? "https://getpagezero.com";
|
|
205
203
|
|
|
206
204
|
try {
|
|
207
205
|
const res = await fetch(
|
package/src/lib/sitemap.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { MetadataRoute } from "next";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Drop-in `app/sitemap.ts` default export. Fetches the canonical sitemap
|
|
5
|
-
* served by
|
|
5
|
+
* served by Page Zero at `/api/v1/public/sitemap.xml?key=...`, parses
|
|
6
6
|
* the XML, and returns Next.js's `MetadataRoute.Sitemap` shape so the
|
|
7
7
|
* framework can build the static sitemap at request time.
|
|
8
8
|
*
|
|
@@ -22,13 +22,13 @@ import type { MetadataRoute } from "next";
|
|
|
22
22
|
*
|
|
23
23
|
* Required env:
|
|
24
24
|
* - `SMKING_API_KEY` (publishable, the `pk_…` key)
|
|
25
|
-
* - `SMKING_BASE_URL` (defaults to https://
|
|
25
|
+
* - `SMKING_BASE_URL` (defaults to https://getpagezero.com)
|
|
26
26
|
*/
|
|
27
27
|
export default async function smkingSitemap(): Promise<MetadataRoute.Sitemap> {
|
|
28
28
|
if (process.env.SMKING_DISABLE_TAKEOVER_SITEMAP === "1") return [];
|
|
29
29
|
const apiKey = process.env.SMKING_API_KEY;
|
|
30
30
|
if (!apiKey) return [];
|
|
31
|
-
const baseUrl = process.env.SMKING_BASE_URL ?? "https://
|
|
31
|
+
const baseUrl = process.env.SMKING_BASE_URL ?? "https://getpagezero.com";
|
|
32
32
|
|
|
33
33
|
let body: string;
|
|
34
34
|
try {
|
package/src/lib/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = "0.
|
|
1
|
+
export const SDK_VERSION = "0.21.4";
|
package/src/types.ts
CHANGED
|
@@ -81,11 +81,11 @@ export type {
|
|
|
81
81
|
CategoryIndexProps,
|
|
82
82
|
HeroProps,
|
|
83
83
|
ImageProps,
|
|
84
|
+
LinkDataAttributes,
|
|
84
85
|
ModuleHeader,
|
|
85
86
|
NavLayout,
|
|
86
87
|
NavSnapshotEntry,
|
|
87
88
|
NavTaxonomyListProps,
|
|
88
|
-
RecentPostsProps,
|
|
89
89
|
RelatedPostsProps,
|
|
90
90
|
SearchQuickLink,
|
|
91
91
|
SearchQuickLinkSource,
|