@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 +50 -0
- package/README.md +7 -47
- package/bin/install.ts +215 -9
- package/package.json +37 -2
- package/src/components/cms-nodes/gallery-extension.ts +32 -0
- package/src/components/cms-nodes/gallery-node.tsx +68 -0
- package/src/components/smking-cms.tsx +96 -0
- package/src/index.ts +6 -0
- package/src/lib/cms-client.ts +87 -0
- package/src/lib/cms-webhook-route.ts +107 -0
- package/src/types.ts +61 -0
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
|
-
|
|
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
|
-
|
|
13
|
+
Two ways to get it:
|
|
16
14
|
|
|
17
15
|
```bash
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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).
|
|
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
|
|
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
|
-
|
|
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.
|
|
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;
|