@soloworks/smking-next 0.9.0 → 0.10.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,55 @@
1
1
  # @soloworks/smking-next
2
2
 
3
+ ## 0.10.0 — 2026-05-14
4
+
5
+ **`SmkingCms` typing fix + CMS revalidate default aligned to 5 min.**
6
+
7
+ ### Fixed
8
+
9
+ - `SeoMeta.metaDescription` was missing from the TypeScript interface, but the SaaS public-page endpoint and `smking/laravel` both emit this field inside the seo block. Customers pulling v0.9.1 with `<SmkingCms />` hit a `TS2551` compile error on `seo.metaDescription`. Added as optional since the AEO endpoint emits `metaDescription` at `AeoResponse` top level instead — AEO callers still see `undefined`, CMS callers get the value.
10
+
11
+ ### Behavioral change
12
+
13
+ - `getCmsPage` / `<SmkingCms />` `revalidate` default reduced from `3600` (1h) → `300` (5min). CMS content is hand-edited (frequent updates) vs AEO which is SaaS-generated (more stable), so a shorter ISR backstop is the correct fallback. Matches `smking/laravel`'s `cms_ttl` default. Override via the `revalidate` prop if you need the previous 1h behaviour. Customers with the CMS publish webhook wired (`SMKING_WEBHOOK_SECRET` + `@soloworks/smking-next/cms-webhook` handler) are unaffected — push invalidation bypasses ISR entirely.
14
+
15
+ ### README
16
+
17
+ - Install section trimmed to a pointer at the dashboard install prompt / `npx @soloworks/smking-wizard`. Reduces drift between the SDK README and the per-site install prompt that's the source of truth.
18
+
19
+ ## 0.9.1 — 2026-05-13
20
+
21
+ **`smking-next doctor` subcommand for self-check + machine-readable output.** The CLI bin now accepts two subcommands: `install` (existing one-shot scaffold) and the new `doctor` (self-check). With `--json`, doctor emits structured output for the new `@smking/wizard` install agent's `run_doctor` tool.
22
+
23
+ ### What's new
24
+
25
+ `npx @soloworks/smking-next doctor` runs four checks:
26
+
27
+ - `SMKING_API_KEY` set (and starts with `pk_`)
28
+ - `SMKING_BASE_URL` set (and starts with `http`)
29
+ - `<SmkingAEO />` imported in the root `app/layout.{tsx,jsx,ts,js}`
30
+ - Live probe of `${SMKING_BASE_URL}/api/v1/public/aeo` (3s timeout)
31
+
32
+ Exit code is `0` when every required check passes, `1` otherwise. Identical between pretty and JSON modes.
33
+
34
+ ### `--json` shape
35
+
36
+ ```json
37
+ {
38
+ "checks": [
39
+ { "name": "...", "status": "pass" | "fail" | "info", "detail": "..." }
40
+ ],
41
+ "summary": { "passed": N, "failed": N, "info": N, "ok": <bool> }
42
+ }
43
+ ```
44
+
45
+ `summary.ok` is the short-circuit boolean for agentic consumers. The shape is a stable wizard contract — schema changes will bump wizard version.
46
+
47
+ ### Why
48
+
49
+ Mirrors `smking/laravel`'s `php artisan smking:doctor --json` (v0.10.1). The `@smking/wizard` CLI now runs the same install verification step for both stacks without needing to scrape ANSI-coloured terminal output.
50
+
51
+ Pure addition — existing `npx @soloworks/smking-next install` calls are unchanged, default behaviour preserved.
52
+
3
53
  ## 0.9.0 — 2026-05-12
4
54
 
5
55
  **Drop-in takeover for `/sitemap.xml`, `/robots.txt`, and `/llms.txt` plus a one-shot install CLI.** Customers with no sitemap (or a broken one), no `robots.txt`, or no `llms.txt` for AI agents previously had to write all three themselves. With v0.9.0 the SDK serves them from the smking SaaS — one line of customer code per file, scaffolded automatically.
package/README.md CHANGED
@@ -8,59 +8,19 @@ AI-native SEO (AEO) for Next.js. One server component injects JSON-LD, OG tags,
8
8
 
9
9
  ## Install
10
10
 
11
- ```bash
12
- pnpm add @soloworks/smking-next
13
- ```
11
+ **Don't follow this README to install.** Your smking 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.
14
12
 
15
- Set two environment variables:
13
+ Two ways to get it:
16
14
 
17
15
  ```bash
18
- SMKING_API_KEY=pk_... # public key from smking dashboard
19
- SMKING_BASE_URL=https://... # smking deployment origin
20
- ```
21
-
22
- ### 1. Drop the component into your root layout
23
-
24
- ```tsx
25
- // app/layout.tsx
26
- import { SmkingAEO } from '@soloworks/smking-next';
27
-
28
- export default function RootLayout({
29
- children,
30
- }: {
31
- children: React.ReactNode;
32
- }) {
33
- return (
34
- <html lang="en">
35
- <body>
36
- <SmkingAEO apiKey={process.env.SMKING_API_KEY!} />
37
- {children}
38
- </body>
39
- </html>
40
- );
41
- }
42
- ```
43
-
44
- That's it for AEO injection. Every request to any URL fetches that URL's AEO content from smking and emits:
16
+ # Option 1 — one-shot wizard (installs deps + writes env + runs doctor)
17
+ npx @soloworks/smking-wizard
45
18
 
46
- - `<script type="application/ld+json">` for AI crawlers
47
- - `<title>`, `<meta name="description">`, `og:*` head tags
48
- - sr-only `<div>` containing FAQ, AI summary, and product image (visually hidden, in-DOM for crawlers)
49
-
50
- ### 2. Wire the webhook for instant cache invalidation
51
-
52
- ```ts
53
- // app/api/smking-revalidate/route.ts
54
- export { POST, GET } from '@soloworks/smking-next/route';
55
- ```
56
-
57
- Add to environment:
58
-
59
- ```bash
60
- SMKING_WEBHOOK_TOKEN=... # any random string; share with smking SaaS
19
+ # Option 2 — copy the prompt manually from your smking dashboard's
20
+ # install panel into your editor / coding agent.
61
21
  ```
62
22
 
63
- Configure smking SaaS to POST `{ paths: [...] }` to your `/api/smking-revalidate` endpoint with `Authorization: Bearer <token>`. The handler calls `revalidateTag('smking:path:<path>')` for each path.
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.
64
24
 
65
25
  ## How metadata wins / loses
66
26
 
package/bin/install.ts CHANGED
@@ -1,18 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * `npx @soloworks/smking-next install`
3
+ * `npx @soloworks/smking-next [install|doctor] [--json]`
4
4
  *
5
- * One-shot scaffold for the three takeover drop-in files. Idempotent: if a
6
- * file already exists, we leave it alone and tell the user — never
7
- * overwrite customer code. Each generated file is one line of re-export
8
- * pointing at the SDK helper that does the real work.
5
+ * Two subcommands share this entry point (Node 22+ strips TS at runtime, so
6
+ * the package ships .ts source directly — no build step):
7
+ *
8
+ * - **install** (default) — One-shot scaffold for the three takeover
9
+ * drop-in files (sitemap.ts / robots.ts / llms.txt route). Idempotent:
10
+ * already-existing files are skipped, never overwritten.
11
+ *
12
+ * - **doctor** — Self-check (env presence + <SmkingAEO /> usage + API
13
+ * reachable). `--json` flag emits structured output for the
14
+ * @smking/wizard install agent's `run_doctor` MCP tool.
9
15
  *
10
16
  * Conventional Next.js layout assumed: `app/` at repo root (or under
11
- * `src/app/` — detected). The CLI exits 1 if neither exists.
17
+ * `src/app/` — detected). Both subcommands exit 1 on missing app/.
12
18
  */
13
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
20
  import { join } from "node:path";
15
21
 
22
+ // ── install (既有功能) ──────────────────────────────────────────
23
+
16
24
  interface FileSpec {
17
25
  path: string; // relative to detected app root
18
26
  content: string;
@@ -48,7 +56,7 @@ function detectAppDir(): string | null {
48
56
  return null;
49
57
  }
50
58
 
51
- function main(): number {
59
+ function runInstall(): number {
52
60
  const appDir = detectAppDir();
53
61
  if (!appDir) {
54
62
  console.error(
@@ -82,4 +90,202 @@ function main(): number {
82
90
  return 0;
83
91
  }
84
92
 
85
- process.exit(main());
93
+ // ── doctor (新增) ──────────────────────────────────────────────
94
+
95
+ interface DoctorCheck {
96
+ name: string;
97
+ status: "pass" | "fail" | "info";
98
+ detail: string;
99
+ }
100
+
101
+ function checkEnv(name: string, prefix?: string): DoctorCheck {
102
+ const value = process.env[name];
103
+ if (!value) {
104
+ return {
105
+ name: `${name} set`,
106
+ status: "fail",
107
+ detail: `not set; add ${name}=... to .env.local`,
108
+ };
109
+ }
110
+ if (prefix && !value.startsWith(prefix)) {
111
+ return {
112
+ name: `${name} set`,
113
+ status: "fail",
114
+ detail: `value must start with \`${prefix}\``,
115
+ };
116
+ }
117
+ return {
118
+ name: `${name} set`,
119
+ status: "pass",
120
+ detail: `${value.slice(0, 8)}…`,
121
+ };
122
+ }
123
+
124
+ function checkLayoutUsage(appDir: string): DoctorCheck {
125
+ // Mirror Next.js's layout file resolution. Order matters — TS > JS to
126
+ // match what Next.js itself would pick.
127
+ const candidates = [
128
+ join(appDir, "layout.tsx"),
129
+ join(appDir, "layout.jsx"),
130
+ join(appDir, "layout.ts"),
131
+ join(appDir, "layout.js"),
132
+ ];
133
+ const layoutPath = candidates.find((p) => existsSync(p));
134
+ if (!layoutPath) {
135
+ return {
136
+ name: "<SmkingAEO /> in root layout",
137
+ status: "info",
138
+ detail: `no layout file found under ${appDir}/ — skipped`,
139
+ };
140
+ }
141
+
142
+ const content = readFileSync(layoutPath, "utf-8");
143
+ // Substring check is intentional rather than AST parsing. A customer
144
+ // commenting it out or aliasing the import shows up as fail/info either
145
+ // way, and AST adds a TypeScript parser dependency for trivial value.
146
+ if (!content.includes("SmkingAEO")) {
147
+ return {
148
+ name: "<SmkingAEO /> in root layout",
149
+ status: "fail",
150
+ 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
+ };
152
+ }
153
+ return {
154
+ name: "<SmkingAEO /> in root layout",
155
+ status: "pass",
156
+ detail: layoutPath,
157
+ };
158
+ }
159
+
160
+ async function checkApiReachable(): Promise<DoctorCheck> {
161
+ const apiKey = process.env.SMKING_API_KEY;
162
+ const baseUrl = process.env.SMKING_BASE_URL;
163
+ if (!apiKey || !baseUrl) {
164
+ return {
165
+ name: "API reachable",
166
+ status: "info",
167
+ detail: "skipped — SMKING_API_KEY or SMKING_BASE_URL not set",
168
+ };
169
+ }
170
+
171
+ // Probe the actual AEO endpoint, not the base URL root. Hitting root
172
+ // would pass for any live host (a typo'd domain, google.com); the
173
+ // endpoint either returns the AEO payload or a known auth/validation
174
+ // status — both confirm the route exists.
175
+ const url = `${baseUrl.replace(/\/$/, "")}/api/v1/public/aeo?path=%2F`;
176
+ try {
177
+ const res = await fetch(url, {
178
+ headers: { authorization: `Bearer ${apiKey}` },
179
+ signal: AbortSignal.timeout(3000),
180
+ });
181
+
182
+ if (res.ok) {
183
+ return {
184
+ name: "API reachable",
185
+ status: "pass",
186
+ detail: `${url} → HTTP ${res.status}`,
187
+ };
188
+ }
189
+ if (res.status === 401) {
190
+ return {
191
+ name: "API reachable",
192
+ status: "fail",
193
+ detail: `HTTP 401 from ${url} — SMKING_API_KEY rejected`,
194
+ };
195
+ }
196
+ if (res.status === 404) {
197
+ return {
198
+ name: "API reachable",
199
+ status: "fail",
200
+ detail: `HTTP 404 from ${url} — base_url likely points at the wrong host`,
201
+ };
202
+ }
203
+ if (res.status >= 500) {
204
+ return {
205
+ name: "API reachable",
206
+ status: "fail",
207
+ detail: `upstream HTTP ${res.status} from ${url}`,
208
+ };
209
+ }
210
+ // 4xx (other than 401/404) — likely validation error, endpoint exists
211
+ return {
212
+ name: "API reachable",
213
+ status: "info",
214
+ detail: `${url} → HTTP ${res.status} (endpoint exists)`,
215
+ };
216
+ } catch (err) {
217
+ return {
218
+ name: "API reachable",
219
+ status: "fail",
220
+ detail: `connection failed: ${err instanceof Error ? err.message : String(err)}`,
221
+ };
222
+ }
223
+ }
224
+
225
+ async function runDoctor(jsonOutput: boolean): Promise<number> {
226
+ const appDir = detectAppDir();
227
+ const checks: DoctorCheck[] = [
228
+ checkEnv("SMKING_API_KEY", "pk_"),
229
+ checkEnv("SMKING_BASE_URL", "http"),
230
+ appDir
231
+ ? checkLayoutUsage(appDir)
232
+ : {
233
+ name: "<SmkingAEO /> in root layout",
234
+ status: "info" as const,
235
+ detail: "no app/ or src/app/ directory found — skipped",
236
+ },
237
+ await checkApiReachable(),
238
+ ];
239
+
240
+ const hasFailure = checks.some((c) => c.status === "fail");
241
+
242
+ // JSON mode: parseable structured output for @smking/wizard's run_doctor
243
+ // tool. Stable shape — do not break without bumping wizard version.
244
+ if (jsonOutput) {
245
+ const summary = {
246
+ passed: checks.filter((c) => c.status === "pass").length,
247
+ failed: checks.filter((c) => c.status === "fail").length,
248
+ info: checks.filter((c) => c.status === "info").length,
249
+ ok: !hasFailure,
250
+ };
251
+ console.log(JSON.stringify({ checks, summary }));
252
+ return hasFailure ? 1 : 0;
253
+ }
254
+
255
+ for (const check of checks) {
256
+ const icon =
257
+ check.status === "pass"
258
+ ? "✅"
259
+ : check.status === "fail"
260
+ ? "❌"
261
+ : "ℹ️ ";
262
+ console.log(`${icon} ${check.name} — ${check.detail}`);
263
+ }
264
+ console.log();
265
+ if (hasFailure) {
266
+ console.log("❌ smking: install incomplete — fix the items above.");
267
+ return 1;
268
+ }
269
+ console.log("✅ smking: install OK.");
270
+ return 0;
271
+ }
272
+
273
+ // ── Entry dispatcher ────────────────────────────────────────────
274
+
275
+ async function main(): Promise<number> {
276
+ const subcommand = process.argv[2];
277
+ const jsonOutput = process.argv.includes("--json");
278
+
279
+ if (subcommand === "doctor") {
280
+ return runDoctor(jsonOutput);
281
+ }
282
+ if (subcommand === "install" || subcommand === undefined) {
283
+ return runInstall();
284
+ }
285
+ console.error(
286
+ `Unknown subcommand: ${subcommand}. Available: install (default), doctor.`,
287
+ );
288
+ return 1;
289
+ }
290
+
291
+ main().then((code) => process.exit(code));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soloworks/smking-next",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",
@@ -15,6 +15,14 @@
15
15
  "types": "./src/index.ts",
16
16
  "default": "./src/index.ts"
17
17
  },
18
+ "./cms": {
19
+ "types": "./src/components/smking-cms.tsx",
20
+ "default": "./src/components/smking-cms.tsx"
21
+ },
22
+ "./cms-webhook": {
23
+ "types": "./src/lib/cms-webhook-route.ts",
24
+ "default": "./src/lib/cms-webhook-route.ts"
25
+ },
18
26
  "./route": {
19
27
  "types": "./src/route.ts",
20
28
  "default": "./src/route.ts"
@@ -55,11 +63,38 @@
55
63
  ],
56
64
  "peerDependencies": {
57
65
  "next": "^15.0.0 || ^16.0.0",
58
- "react": "^18.0.0 || ^19.0.0"
66
+ "react": "^18.0.0 || ^19.0.0",
67
+ "@tiptap/static-renderer": "^3.0.0",
68
+ "@tiptap/core": "^3.0.0",
69
+ "@tiptap/starter-kit": "^3.0.0",
70
+ "@tiptap/extension-image": "^3.0.0",
71
+ "@tiptap/extension-link": "^3.0.0"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "@tiptap/static-renderer": {
75
+ "optional": true
76
+ },
77
+ "@tiptap/core": {
78
+ "optional": true
79
+ },
80
+ "@tiptap/starter-kit": {
81
+ "optional": true
82
+ },
83
+ "@tiptap/extension-image": {
84
+ "optional": true
85
+ },
86
+ "@tiptap/extension-link": {
87
+ "optional": true
88
+ }
59
89
  },
60
90
  "devDependencies": {
61
91
  "@testing-library/jest-dom": "^6.9.1",
62
92
  "@testing-library/react": "^16.3.2",
93
+ "@tiptap/core": "^3.23.4",
94
+ "@tiptap/extension-image": "^3.23.4",
95
+ "@tiptap/extension-link": "^3.23.4",
96
+ "@tiptap/starter-kit": "^3.23.4",
97
+ "@tiptap/static-renderer": "^3.23.4",
63
98
  "@types/react": "^19.0.0",
64
99
  "@vitejs/plugin-react": "^6.0.1",
65
100
  "jsdom": "^29.1.0",
@@ -0,0 +1,32 @@
1
+ import { Node } from "@tiptap/core";
2
+
3
+ /**
4
+ * Schema-only Gallery extension for the static renderer.
5
+ *
6
+ * The full SaaS-side `GalleryNode` (apps/web/src/components/tiptap-node/
7
+ * gallery-node/gallery-node-extension.ts) ships with addNodeView,
8
+ * addCommands, parseHTML — everything Tiptap needs in the editor. For
9
+ * read-only static rendering we only need the SCHEMA (name, group,
10
+ * atom, attrs) so `@tiptap/static-renderer` recognises the "gallery"
11
+ * node type when walking the JSON; the actual rendering is handled by
12
+ * the GalleryNodeView passed via `nodeMapping`.
13
+ *
14
+ * Keep attrs in sync with the SaaS schema and the Laravel PHP node
15
+ * (packages/smking-laravel/src/Tiptap/Nodes/Gallery.php). All three
16
+ * must agree on field names + defaults.
17
+ */
18
+ export const Gallery = Node.create({
19
+ name: "gallery",
20
+ group: "block",
21
+ atom: true,
22
+ draggable: true,
23
+ selectable: true,
24
+
25
+ addAttributes() {
26
+ return {
27
+ images: { default: [] },
28
+ layout: { default: "grid" },
29
+ columns: { default: 3 },
30
+ };
31
+ },
32
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * SmKing Gallery — read-only React render for static-renderer's
3
+ * nodeMapping.
4
+ *
5
+ * Markup MUST stay byte-equal to:
6
+ * - apps/web/src/components/tiptap-node/gallery-node/gallery-node-
7
+ * extension.ts (SaaS authoring → preview)
8
+ * - packages/smking-laravel/src/Tiptap/Nodes/Gallery.php (Laravel
9
+ * SDK PHP renderer)
10
+ *
11
+ * If you change ANY attribute / class / nesting here, change it in the
12
+ * other two and bump version on all three SDKs together. Customer site
13
+ * CSS targets `.smk-gallery`, `.smk-gallery--{layout}`,
14
+ * `.smk-gallery__item` — those names are public API.
15
+ */
16
+
17
+ interface GalleryImage {
18
+ url: string;
19
+ alt?: string;
20
+ caption?: string;
21
+ }
22
+
23
+ interface GalleryNodeAttrs {
24
+ images?: GalleryImage[];
25
+ layout?: string;
26
+ columns?: number;
27
+ }
28
+
29
+ /**
30
+ * Shape matches what `@tiptap/static-renderer/pm/react`'s nodeMapping
31
+ * passes: `{ node: ProseMirror Node }` whose `attrs` are typed via the
32
+ * Gallery extension. We narrow loosely here — the SaaS sometimes ships
33
+ * stringly-typed attrs (jsonb round-trips lose number type for columns).
34
+ */
35
+ export function GalleryNodeView({
36
+ node,
37
+ }: {
38
+ node: { attrs?: GalleryNodeAttrs };
39
+ }) {
40
+ const attrs = node.attrs ?? {};
41
+ const images = Array.isArray(attrs.images) ? attrs.images : [];
42
+ const layout = typeof attrs.layout === "string" ? attrs.layout : "grid";
43
+ const columnsRaw = attrs.columns;
44
+ const columns =
45
+ typeof columnsRaw === "number" && columnsRaw > 0
46
+ ? columnsRaw
47
+ : typeof columnsRaw === "string" && Number(columnsRaw) > 0
48
+ ? Number(columnsRaw)
49
+ : 3;
50
+
51
+ return (
52
+ <div
53
+ data-type="gallery"
54
+ data-layout={layout}
55
+ data-columns={String(columns)}
56
+ className={`smk-gallery smk-gallery--${layout}`}
57
+ style={{ "--smk-gallery-cols": columns } as React.CSSProperties}
58
+ >
59
+ {images.map((img, i) => (
60
+ <figure key={i} className="smk-gallery__item">
61
+ {/* eslint-disable-next-line @next/next/no-img-element */}
62
+ <img src={img.url} alt={img.alt ?? ""} loading="lazy" />
63
+ {img.caption && <figcaption>{img.caption}</figcaption>}
64
+ </figure>
65
+ ))}
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,96 @@
1
+ import { renderToReactElement } from "@tiptap/static-renderer/pm/react";
2
+ import StarterKit from "@tiptap/starter-kit";
3
+ import Image from "@tiptap/extension-image";
4
+ import Link from "@tiptap/extension-link";
5
+ import { Gallery } from "./cms-nodes/gallery-extension";
6
+ import { GalleryNodeView } from "./cms-nodes/gallery-node";
7
+ import { getCmsPage } from "../lib/cms-client";
8
+ import type { CmsParams } from "../types";
9
+
10
+ /**
11
+ * Server Component that renders a published smking CMS page.
12
+ *
13
+ * Usage:
14
+ * ```tsx
15
+ * import { SmkingCms } from '@soloworks/smking-next/cms';
16
+ *
17
+ * export default function Page() {
18
+ * return (
19
+ * <SmkingCms
20
+ * apiKey={process.env.SMKING_API_KEY!}
21
+ * slug="hello"
22
+ * />
23
+ * );
24
+ * }
25
+ * ```
26
+ *
27
+ * Fetches the page server-side, walks the Tiptap ProseMirror JSON via
28
+ * `@tiptap/static-renderer/pm/react`, and renders it as a React tree.
29
+ * Standard nodes (paragraph / heading / image / list / link / blockquote
30
+ * / code-block) come from StarterKit. The custom Gallery node is
31
+ * mapped to GalleryNodeView whose markup is byte-equal to the SaaS
32
+ * preview side and the Laravel SDK PHP renderer.
33
+ *
34
+ * Returns null when the response isn't ready (pending / not_found /
35
+ * unreachable / mis-configured) — fail-open by design.
36
+ *
37
+ * Customer install requires four Tiptap peer deps:
38
+ * pnpm add @tiptap/static-renderer @tiptap/starter-kit \\
39
+ * @tiptap/extension-image @tiptap/extension-link
40
+ */
41
+ export async function SmkingCms(props: CmsParams) {
42
+ const data = await getCmsPage(props);
43
+ if (!data || data.status !== "ready" || !data.page) return null;
44
+
45
+ // v0.11+ — emit SEO head tags inline. React 19 hoists `<title>` and
46
+ // `<meta>` tags found anywhere in the tree into `<head>` automatically
47
+ // (last write wins on duplicate tags), so a deeper layout / page that
48
+ // also sets these still overrides ours where present.
49
+ const seo = data.seo;
50
+ return (
51
+ <>
52
+ {seo?.title && <title data-smking="cms">{seo.title}</title>}
53
+ {seo?.metaDescription && (
54
+ <meta
55
+ name="description"
56
+ content={seo.metaDescription}
57
+ data-smking="cms"
58
+ />
59
+ )}
60
+ {seo?.ogTitle && (
61
+ <meta property="og:title" content={seo.ogTitle} data-smking="cms" />
62
+ )}
63
+ {seo?.ogDescription && (
64
+ <meta
65
+ property="og:description"
66
+ content={seo.ogDescription}
67
+ data-smking="cms"
68
+ />
69
+ )}
70
+ {seo?.ogImageUrl && (
71
+ <meta property="og:image" content={seo.ogImageUrl} data-smking="cms" />
72
+ )}
73
+ {seo?.canonicalUrl && (
74
+ <link rel="canonical" href={seo.canonicalUrl} data-smking="cms" />
75
+ )}
76
+
77
+ <article className="smk-cms" data-smking="cms">
78
+ {data.page.title && (
79
+ <h1 className="smk-cms__title">{data.page.title}</h1>
80
+ )}
81
+ {renderToReactElement({
82
+ extensions: [StarterKit, Image, Link, Gallery],
83
+ content: data.page.body,
84
+ options: {
85
+ nodeMapping: {
86
+ // Custom node renderer for our gallery; standard nodes
87
+ // (paragraph, heading, list, etc.) auto-render from
88
+ // StarterKit's schema.
89
+ gallery: GalleryNodeView,
90
+ },
91
+ },
92
+ })}
93
+ </article>
94
+ </>
95
+ );
96
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  export { SmkingAEO } from "./components/smking-aeo";
2
+ export { SmkingCms } from "./components/smking-cms";
2
3
  export { getAeoContent } from "./lib/client";
4
+ export { getCmsPage } from "./lib/cms-client";
3
5
  export type {
4
6
  AeoResponse,
5
7
  AeoStatus,
6
8
  ChatLinks,
9
+ CmsPage,
10
+ CmsParams,
11
+ CmsResponse,
12
+ CmsStatus,
7
13
  DiscoverParams,
8
14
  FaqItem,
9
15
  SeoMeta,
@@ -0,0 +1,87 @@
1
+ // Type-only import loads Next.js's RequestInit augmentation so the
2
+ // `next: { revalidate, tags }` property on fetch options typechecks.
3
+ import type {} from "next";
4
+
5
+ import type { CmsParams, CmsResponse } from "../types";
6
+
7
+ // CMS-specific TTL — shorter than AEO's 1h because CMS body is
8
+ // hand-edited (frequent updates) vs AEO which is SaaS-generated
9
+ // (more stable). Matches smking/laravel `cms_ttl` default for
10
+ // cross-SDK behavioral consistency. Customer can override via the
11
+ // `revalidate` prop. Webhook delivery (when SMKING_WEBHOOK_SECRET
12
+ // is wired) bypasses this entirely via revalidateTag.
13
+ const DEFAULT_REVALIDATE_SECONDS = 300;
14
+ const FETCH_TIMEOUT_MS = 2000;
15
+
16
+ const _warnedKeys = new Set<string>();
17
+ function warnOnce(key: string, message: string): void {
18
+ if (process.env.NODE_ENV === "production") return;
19
+ if (_warnedKeys.has(key)) return;
20
+ _warnedKeys.add(key);
21
+ console.warn(message);
22
+ }
23
+
24
+ /**
25
+ * Fetch a published CMS page from the smking public API.
26
+ *
27
+ * Mirrors `getAeoContent` shape — same fail-open posture, same Next.js
28
+ * data-cache integration. Returns null when:
29
+ * - apiKey or baseUrl missing (one-time dev warning)
30
+ * - network failure / 2s timeout
31
+ * - 4xx / 5xx response
32
+ * - JSON parse failure
33
+ *
34
+ * On success, cached by Next.js data cache for 5min by default
35
+ * (ISR backstop — see DEFAULT_REVALIDATE_SECONDS rationale).
36
+ * Tagged with `smking:cms:<slug>` so the `cms-webhook` handler can
37
+ * `revalidateTag` to invalidate instantly when SaaS publishes an
38
+ * update.
39
+ */
40
+ export async function getCmsPage(
41
+ params: CmsParams,
42
+ ): Promise<CmsResponse | null> {
43
+ if (!params.apiKey) {
44
+ warnOnce(
45
+ "missing-api-key",
46
+ "[@soloworks/smking-next/cms] apiKey is empty — skipping CMS render. Set SMKING_API_KEY or pass apiKey prop.",
47
+ );
48
+ return null;
49
+ }
50
+
51
+ const baseUrl = (params.baseUrl ?? process.env.SMKING_BASE_URL)?.replace(
52
+ /\/$/,
53
+ "",
54
+ );
55
+ if (!baseUrl) {
56
+ warnOnce(
57
+ "missing-base-url",
58
+ "[@soloworks/smking-next/cms] SMKING_BASE_URL is not configured — skipping CMS render. Set the env var or pass baseUrl prop.",
59
+ );
60
+ return null;
61
+ }
62
+
63
+ if (!params.slug) {
64
+ warnOnce(
65
+ "missing-slug",
66
+ "[@soloworks/smking-next/cms] slug prop is required.",
67
+ );
68
+ return null;
69
+ }
70
+
71
+ try {
72
+ const url = `${baseUrl}/api/v1/public/page?key=${encodeURIComponent(
73
+ params.apiKey,
74
+ )}&slug=${encodeURIComponent(params.slug)}`;
75
+ const res = await fetch(url, {
76
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
77
+ next: {
78
+ revalidate: params.revalidate ?? DEFAULT_REVALIDATE_SECONDS,
79
+ tags: [`smking:cms:${params.slug}`],
80
+ },
81
+ });
82
+ if (!res.ok) return null;
83
+ return (await res.json()) as CmsResponse;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
@@ -0,0 +1,107 @@
1
+ import { revalidateTag } from "next/cache";
2
+ import { Buffer } from "node:buffer";
3
+ import crypto from "node:crypto";
4
+
5
+ /**
6
+ * SmKing CMS publish webhook handler for Next.js.
7
+ *
8
+ * Drop-in install:
9
+ *
10
+ * ```ts
11
+ * // app/api/smking/webhook/route.ts
12
+ * export { POST } from "@soloworks/smking-next/cms-webhook";
13
+ * ```
14
+ *
15
+ * Then set `SMKING_WEBHOOK_SECRET` in your env and paste this route's
16
+ * full URL (e.g. `https://your-site.com/api/smking/webhook`) into the
17
+ * SmKing dashboard's site settings webhook field.
18
+ *
19
+ * On a verified `cms.page.published` event the handler calls
20
+ * `revalidateTag("smking:cms:<slug>", "default")` — invalidates the
21
+ * cached `getCmsPage` fetch tagged with that slug so the next page
22
+ * render reads fresh content from SaaS.
23
+ *
24
+ * Wire shape matches @smking-saas/features/cms/lib/webhook.ts and the
25
+ * Laravel SDK WebhookController:
26
+ *
27
+ * POST /api/smking/webhook
28
+ * X-Smking-Signature: sha256=<hex>
29
+ * X-Smking-Event: cms.page.published
30
+ * { event, siteId, slug, publishedAt, deliveredAt }
31
+ *
32
+ * HMAC-SHA256 sig verification runs constant-time via `timingSafeEqual`
33
+ * to prevent secret-extraction via response latency. Unknown event
34
+ * types accept (200) without action so SaaS doesn't retry — forward-
35
+ * compat for future event types (cms.page.unpublished / deleted).
36
+ */
37
+ export async function POST(request: Request): Promise<Response> {
38
+ const secret = process.env.SMKING_WEBHOOK_SECRET;
39
+ if (!secret) {
40
+ return Response.json(
41
+ { error: "webhook_secret_missing" },
42
+ { status: 503 },
43
+ );
44
+ }
45
+
46
+ // Raw body for HMAC verify — re-encoding via JSON.parse + stringify
47
+ // would change byte order / spacing and invalidate the signature.
48
+ const rawBody = await request.text();
49
+ const providedSig = request.headers.get("x-smking-signature");
50
+ if (!verifySignature(rawBody, providedSig, secret)) {
51
+ return Response.json({ error: "invalid_signature" }, { status: 401 });
52
+ }
53
+
54
+ let payload: { event?: string; slug?: string };
55
+ try {
56
+ payload = JSON.parse(rawBody) as { event?: string; slug?: string };
57
+ } catch {
58
+ return Response.json({ error: "invalid_payload" }, { status: 400 });
59
+ }
60
+
61
+ if (
62
+ payload.event !== "cms.page.published" ||
63
+ typeof payload.slug !== "string" ||
64
+ payload.slug.length === 0
65
+ ) {
66
+ // Forward-compat: unknown event types ack-without-action so SaaS
67
+ // doesn't retry. Future event types (cms.page.unpublished) branch
68
+ // here without breaking older customer SDKs.
69
+ return Response.json({ ok: true, note: "no_action_taken" });
70
+ }
71
+
72
+ try {
73
+ // Next.js 16 requires explicit cache profile; "default" matches the
74
+ // profile a normal `'use cache'` block uses.
75
+ revalidateTag(`smking:cms:${payload.slug}`, "default");
76
+ } catch (err) {
77
+ console.warn(
78
+ `[@soloworks/smking-next/cms-webhook] revalidateTag failed for slug "${payload.slug}":`,
79
+ err,
80
+ );
81
+ return Response.json({ error: "revalidate_failed" }, { status: 500 });
82
+ }
83
+
84
+ return Response.json({ ok: true, evicted: payload.slug });
85
+ }
86
+
87
+ function verifySignature(
88
+ rawBody: string,
89
+ signatureHeader: string | null,
90
+ secret: string,
91
+ ): boolean {
92
+ if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false;
93
+ const provided = signatureHeader.slice("sha256=".length);
94
+ const expected = crypto
95
+ .createHmac("sha256", secret)
96
+ .update(rawBody)
97
+ .digest("hex");
98
+ if (expected.length !== provided.length) return false;
99
+ try {
100
+ return crypto.timingSafeEqual(
101
+ Buffer.from(expected, "hex"),
102
+ Buffer.from(provided, "hex"),
103
+ );
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
package/src/types.ts CHANGED
@@ -21,9 +21,19 @@ export interface ChatLinks {
21
21
  * Server-resolved SEO metadata. Public API does fallback chains
22
22
  * server-side (ogTitle → title, ogDescription → metaDescription,
23
23
  * ogImageUrl → imageUrl, canonicalUrl → pageUrl).
24
+ *
25
+ * `metaDescription` is emitted by the CMS public endpoint (per-page
26
+ * search-snippet override). The AEO public endpoint emits its own
27
+ * metaDescription at the top level of `AeoResponse` instead — keep both
28
+ * paths since they cover different surfaces.
24
29
  */
25
30
  export interface SeoMeta {
26
31
  title: string | null;
32
+ /**
33
+ * Optional — only present on the CMS surface. AEO emits its own
34
+ * metaDescription at `AeoResponse.metaDescription` (top level).
35
+ */
36
+ metaDescription?: string | null;
27
37
  ogTitle: string | null;
28
38
  ogDescription: string | null;
29
39
  ogImageUrl: string | null;
@@ -42,6 +52,57 @@ export interface AeoResponse {
42
52
  seo?: SeoMeta | null;
43
53
  }
44
54
 
55
+ // ── CMS body content (mirrors SaaS /api/v1/public/page) ───────────────
56
+
57
+ export type CmsStatus = "ready" | "pending" | "not_found";
58
+
59
+ /**
60
+ * A single published CMS page returned by the smking public API.
61
+ * `body` is the raw Tiptap ProseMirror JSON document the user authored
62
+ * in /write — render it via the SmkingCms server component (which
63
+ * wraps @tiptap/static-renderer/pm/react with our extension list).
64
+ */
65
+ export interface CmsPage {
66
+ slug: string;
67
+ title: string;
68
+ body: Record<string, unknown>;
69
+ publishedAt: string | null;
70
+ }
71
+
72
+ /**
73
+ * Mirrors AeoResponse status taxonomy so existing fail-open patterns
74
+ * apply identically. `page` + `seo` only present when status === "ready".
75
+ *
76
+ * SEO meta (v0.11+) lets the Server Component emit `<title>` / `<meta>`
77
+ * head tags alongside the body, so customer Next.js pages get a
78
+ * search-friendly meta block for free without a separate fetch.
79
+ * Server-resolved with fallback chains (ogTitle→seoTitle→title etc) —
80
+ * consumer just renders whatever's present.
81
+ */
82
+ export interface CmsResponse {
83
+ status: CmsStatus;
84
+ page?: CmsPage;
85
+ seo?: SeoMeta | null;
86
+ }
87
+
88
+ export interface CmsParams {
89
+ /** Public API key (`pk_*`). Required. */
90
+ apiKey: string;
91
+ /** Page slug — required. PoC ships with hardcoded "hello" on the SaaS. */
92
+ slug: string;
93
+ /**
94
+ * smking deployment origin. Required — pass directly or set
95
+ * `SMKING_BASE_URL` env. Missing value short-circuits to fail-open.
96
+ */
97
+ baseUrl?: string;
98
+ /**
99
+ * Next.js `fetch` revalidate seconds. Defaults to 300 (5min ISR
100
+ * backstop — shorter than AEO's 1h because CMS content is hand-edited
101
+ * and changes more often; matches `smking/laravel`'s `cms_ttl`).
102
+ */
103
+ revalidate?: number;
104
+ }
105
+
45
106
  export interface DiscoverParams {
46
107
  /** Public API key (`pk_*`). Required. */
47
108
  apiKey: string;