@soloworks/smking-next 0.10.0 → 0.12.1
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 +77 -0
- package/README.md +65 -0
- package/bin/{install.ts → install.mjs} +47 -33
- package/package.json +10 -32
- package/src/components/smking-cms.tsx +96 -29
- package/src/lib/client.ts +6 -3
- package/src/lib/cms-client.ts +5 -4
- package/src/lib/crawlers.ts +136 -0
- package/src/lib/path.ts +1 -1
- package/src/lib/proxy.ts +147 -0
- package/src/lib/webhook-route.ts +126 -0
- package/src/types.ts +62 -5
- package/src/components/cms-nodes/gallery-extension.ts +0 -32
- package/src/components/cms-nodes/gallery-node.tsx +0 -68
- package/src/lib/cms-webhook-route.ts +0 -107
- package/src/route.ts +0 -112
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,82 @@
|
|
|
1
1
|
# @soloworks/smking-next
|
|
2
2
|
|
|
3
|
+
## 0.12.1 — 2026-05-21
|
|
4
|
+
|
|
5
|
+
**Fix: `npx @soloworks/smking-next doctor` failed for all customers.**
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `bin/install.ts` shipped as raw TypeScript on the assumption that Node 22+'s `--experimental-strip-types` would handle it. It doesn't — by design, Node excludes files under `node_modules/` from type-stripping (packages should ship compiled JS). Every `npx @soloworks/smking-next install|doctor` invocation failed with "Type-Stripping is not supported for files under node_modules" the moment a customer's install reached the doctor check. smking-wizard reproduced the bug 3× and filed ticket `dr_e0b7d8e924cd`.
|
|
10
|
+
- Renamed to `bin/install.mjs` with TypeScript syntax stripped (JSDoc type hints retained for editor / dev ergonomics). Pure ESM, no build step, no runtime type stripping needed.
|
|
11
|
+
- Source modules (`src/*.ts`) unchanged — Next.js's bundler handles them at customer build time. Only the standalone-executable bin script needed JS form.
|
|
12
|
+
|
|
13
|
+
## 0.12.0 — 2026-05-15
|
|
14
|
+
|
|
15
|
+
**AI crawler + AI referral telemetry — `smkingProxy` Next.js middleware.**
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`@soloworks/smking-next/proxy` subpath export.** New `smkingProxy({ apiKey, baseUrl? })` middleware factory + `composeProxy([...handlers])` helper. Detects AI bot UAs (17 patterns covering GPTBot / ClaudeBot / PerplexityBot / Google-Extended / Applebot / CCBot / Bytespider / meta-externalagent / Amazonbot / Cohere / Diffbot) and AI-referrer hostnames (chatgpt.com / perplexity.ai / claude.ai / gemini.google.com / copilot.microsoft.com / bing.com), then fire-and-forgets a `POST /api/v1/crawler-hit` ingest via Vercel `after()` so the customer's response is never delayed.
|
|
20
|
+
|
|
21
|
+
Install:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// proxy.ts
|
|
25
|
+
import { smkingProxy } from "@soloworks/smking-next/proxy";
|
|
26
|
+
|
|
27
|
+
export const proxy = smkingProxy({ apiKey: process.env.SMKING_API_KEY! });
|
|
28
|
+
|
|
29
|
+
export const config = {
|
|
30
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
31
|
+
};
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Composing with an existing proxy:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { composeProxy, smkingProxy } from "@soloworks/smking-next/proxy";
|
|
38
|
+
import { customerProxy } from "./their-proxy";
|
|
39
|
+
|
|
40
|
+
export const proxy = composeProxy([
|
|
41
|
+
smkingProxy({ apiKey: process.env.SMKING_API_KEY! }),
|
|
42
|
+
customerProxy,
|
|
43
|
+
]);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Fail-open: missing `SMKING_API_KEY` / `SMKING_BASE_URL` → no-op (matches `getAeoContent` convention). Ingestion endpoint down / 4xx / 5xx → silent skip. Customer site never breaks because of telemetry.
|
|
47
|
+
|
|
48
|
+
### Why
|
|
49
|
+
|
|
50
|
+
Crawler telemetry feeds the new 4-pillar AEO Scorecard (visibility / bot engagement / content readiness / traffic impact). Without per-customer-site detection, the scorecard's Bot Engagement and Traffic Impact pillars stay at zero forever. SDK ships the detection so customers don't need to roll their own — `pnpm update @soloworks/smking-next` + add 5-line `proxy.ts` = done.
|
|
51
|
+
|
|
52
|
+
### Customer migration
|
|
53
|
+
|
|
54
|
+
- **Caret rule reminder**: `^0.11` resolves to `>=0.11.0 <0.12.0`, so `pnpm update` will NOT pull 0.12 automatically. Bump constraint to `^0.12` in `package.json` to receive this release.
|
|
55
|
+
- No other breaking changes vs 0.11. AEO / CMS / webhook surfaces unchanged.
|
|
56
|
+
|
|
57
|
+
## 0.11.0 — 2026-05-15
|
|
58
|
+
|
|
59
|
+
**Substrate pivot: unified webhook channel.**
|
|
60
|
+
|
|
61
|
+
### BREAKING
|
|
62
|
+
|
|
63
|
+
- **Unified webhook endpoint.** `@soloworks/smking-next/webhook` replaces the previous split: `/route` (AEO Bearer-authed) AND `/cms-webhook` (CMS HMAC). Single HMAC-signed endpoint, payload `.kind` (`"aeo" | "cms_page" | ...`) dispatches which revalidate tag namespace to use.
|
|
64
|
+
|
|
65
|
+
Customer migration:
|
|
66
|
+
1. **Env**: `SMKING_WEBHOOK_TOKEN` → `SMKING_WEBHOOK_SECRET` (HMAC key — same secret you got from the install prompt for CMS, now used for everything).
|
|
67
|
+
2. **Route shim**: replace both `app/api/smking-revalidate/route.ts` (AEO) AND `app/api/smking/webhook/route.ts` (CMS) with a single `app/api/smking/webhook/route.ts` exporting `POST` from `@soloworks/smking-next/webhook`.
|
|
68
|
+
3. **Dashboard webhook URL** field should point at the single `/api/smking/webhook` path.
|
|
69
|
+
|
|
70
|
+
- **Revalidate tag namespaces changed.** Customer code calling `revalidateTag` directly (rare — most customers use `<SmkingAEO />` / `<SmkingCms />` which handle this internally) must update:
|
|
71
|
+
- `smking:path:*` → `smking:aeo:*`
|
|
72
|
+
- `smking:cms:*` → `smking:cms_page:*`
|
|
73
|
+
|
|
74
|
+
- **`/route` and `/cms-webhook` exports removed.** Importing either at v0.11 fails with a "Module not found" error pointing customers at the migration. Old source files deleted from the package (git history preserves them).
|
|
75
|
+
|
|
76
|
+
### Why
|
|
77
|
+
|
|
78
|
+
PostHog architectural pattern study (`docs/cms-tech-suggestions-posthog.md`) flagged the split webhook as substrate-discipline violation. One channel for the substrate; payload `kind` distinguishes projection. Future product surfaces (widget config, crawler analytics, Shopify connector) extend the discriminator without growing the SDK.
|
|
79
|
+
|
|
3
80
|
## 0.10.0 — 2026-05-14
|
|
4
81
|
|
|
5
82
|
**`SmkingCms` typing fix + CMS revalidate default aligned to 5 min.**
|
package/README.md
CHANGED
|
@@ -84,6 +84,71 @@ Content-Type: application/json
|
|
|
84
84
|
|
|
85
85
|
Response: `{ revalidated: number, errors: number }`. `errors > 0` means some tags couldn't be revalidated (others still succeeded — partial-success delivery).
|
|
86
86
|
|
|
87
|
+
## CMS rendering (optional, v0.11.0+)
|
|
88
|
+
|
|
89
|
+
The base install only wires AEO. If you author content in the smking dashboard's CMS and want to render it on your Next.js site, use the `<SmkingCms slug="…" />` Server Component.
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
### Catch-all route (handles flat + nested slugs)
|
|
94
|
+
|
|
95
|
+
smking CMS slugs can be nested — e.g. `blog/123`, `blog/seo/intro`. Use Next.js catch-all `[...slug]` (three dots, not single `[slug]`) so one route handles every depth:
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
// app/blog/[...slug]/page.tsx
|
|
99
|
+
import { SmkingCms } from "@soloworks/smking-next/cms";
|
|
100
|
+
|
|
101
|
+
export default async function Page({
|
|
102
|
+
params,
|
|
103
|
+
}: {
|
|
104
|
+
params: Promise<{ slug: string[] }>;
|
|
105
|
+
}) {
|
|
106
|
+
const { slug } = await params;
|
|
107
|
+
return (
|
|
108
|
+
<SmkingCms
|
|
109
|
+
apiKey={process.env.SMKING_API_KEY!}
|
|
110
|
+
slug={slug.join("/")} // ← array → "blog/seo/intro" matches dashboard slug format
|
|
111
|
+
/>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`[...slug]` accepts both flat (`/blog/hello` → `["hello"]`) and nested (`/blog/seo/intro` → `["seo", "intro"]`). The `.join("/")` reconstructs the dashboard slug string.
|
|
117
|
+
|
|
118
|
+
Single-bracket `[slug]` (without the three dots) **only matches one segment** — pick this if you know your slugs are always flat.
|
|
119
|
+
|
|
120
|
+
### Markup contract for CSS
|
|
121
|
+
|
|
122
|
+
```html
|
|
123
|
+
<article class="smk-cms" data-smking="cms">
|
|
124
|
+
<h1 class="smk-cms__title">…</h1>
|
|
125
|
+
<p>standard prose</p>
|
|
126
|
+
<h2>headings</h2>
|
|
127
|
+
<ul><li>lists</li></ul>
|
|
128
|
+
<blockquote>…</blockquote>
|
|
129
|
+
<pre><code>code blocks</code></pre>
|
|
130
|
+
<a href="…">links</a>
|
|
131
|
+
<img src="…" alt="…">
|
|
132
|
+
<div data-type="gallery"
|
|
133
|
+
data-layout="grid"
|
|
134
|
+
data-columns="3"
|
|
135
|
+
class="smk-gallery smk-gallery--grid">
|
|
136
|
+
<figure class="smk-gallery__item">
|
|
137
|
+
<img src="…" alt="…" loading="lazy">
|
|
138
|
+
<figcaption>optional</figcaption>
|
|
139
|
+
</figure>
|
|
140
|
+
</div>
|
|
141
|
+
</article>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Standard nodes inherit your site's prose styling. Gallery is the only opinionated structure — provide CSS for `.smk-gallery` (typically a grid with `grid-template-columns: repeat(var(--smk-gallery-cols, 3), 1fr)` since the SDK injects `--smk-gallery-cols` inline).
|
|
145
|
+
|
|
146
|
+
### Cache invalidation
|
|
147
|
+
|
|
148
|
+
CMS responses cache for 5 minutes by default via Next.js data cache (`tags: ["smking:cms_page:<slug>"]`). When you publish, rename, or archive a page in the dashboard, smking SaaS POSTs a signed webhook to `https://<your-site>/api/smking/webhook` (mount with one-line re-export — see Install). The handler verifies HMAC against `SMKING_WEBHOOK_SECRET` and calls `revalidateTag` so the next visitor reads fresh content.
|
|
149
|
+
|
|
150
|
+
If `SMKING_WEBHOOK_SECRET` is unset, the webhook route returns 503 and cache invalidation falls back to TTL-based expiry.
|
|
151
|
+
|
|
87
152
|
## Versions
|
|
88
153
|
|
|
89
154
|
See [CHANGELOG.md](./CHANGELOG.md). Aligned with `smking/laravel` for SaaS-side parity.
|
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* `npx @soloworks/smking-next [install|doctor] [--json]`
|
|
4
4
|
*
|
|
5
|
-
* Two subcommands share this entry point
|
|
6
|
-
*
|
|
5
|
+
* Two subcommands share this entry point. The file ships as plain ESM
|
|
6
|
+
* (`.mjs`) since Node's `--experimental-strip-types` is excluded from
|
|
7
|
+
* files under `node_modules` by design — so `bin/install.ts` (the
|
|
8
|
+
* previous v0.12.0 layout) fails with "Type-Stripping is not supported
|
|
9
|
+
* for files under node_modules" the moment a customer runs `npx
|
|
10
|
+
* @soloworks/smking-next`. Source modules (`src/*.ts`) are still TS —
|
|
11
|
+
* Next.js's bundler handles them at customer build time. Only this
|
|
12
|
+
* standalone-executable bin script needed to drop TS.
|
|
7
13
|
*
|
|
8
14
|
* - **install** (default) — One-shot scaffold for the three takeover
|
|
9
15
|
* drop-in files (sitemap.ts / robots.ts / llms.txt route). Idempotent:
|
|
@@ -19,36 +25,32 @@
|
|
|
19
25
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
26
|
import { join } from "node:path";
|
|
21
27
|
|
|
22
|
-
// ── install (
|
|
28
|
+
// ── install (file scaffold) ─────────────────────────────────────
|
|
23
29
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
label: string;
|
|
28
|
-
}
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {{ path: string, content: string, label: string }} FileSpec
|
|
32
|
+
*/
|
|
29
33
|
|
|
30
|
-
|
|
34
|
+
/** @type {FileSpec[]} */
|
|
35
|
+
const FILES = [
|
|
31
36
|
{
|
|
32
37
|
path: "sitemap.ts",
|
|
33
38
|
label: "sitemap.ts → /sitemap.xml",
|
|
34
|
-
content:
|
|
35
|
-
'export { default } from "@soloworks/smking-next/sitemap";\n',
|
|
39
|
+
content: 'export { default } from "@soloworks/smking-next/sitemap";\n',
|
|
36
40
|
},
|
|
37
41
|
{
|
|
38
42
|
path: "robots.ts",
|
|
39
43
|
label: "robots.ts → /robots.txt",
|
|
40
|
-
content:
|
|
41
|
-
'export { default } from "@soloworks/smking-next/robots";\n',
|
|
44
|
+
content: 'export { default } from "@soloworks/smking-next/robots";\n',
|
|
42
45
|
},
|
|
43
46
|
{
|
|
44
47
|
path: "llms.txt/route.ts",
|
|
45
48
|
label: "llms.txt/route.ts → /llms.txt",
|
|
46
|
-
content:
|
|
47
|
-
'export { GET } from "@soloworks/smking-next/llms-txt";\n',
|
|
49
|
+
content: 'export { GET } from "@soloworks/smking-next/llms-txt";\n',
|
|
48
50
|
},
|
|
49
51
|
];
|
|
50
52
|
|
|
51
|
-
function detectAppDir()
|
|
53
|
+
function detectAppDir() {
|
|
52
54
|
const candidates = ["app", "src/app"];
|
|
53
55
|
for (const c of candidates) {
|
|
54
56
|
if (existsSync(c)) return c;
|
|
@@ -56,7 +58,7 @@ function detectAppDir(): string | null {
|
|
|
56
58
|
return null;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
function runInstall()
|
|
61
|
+
function runInstall() {
|
|
60
62
|
const appDir = detectAppDir();
|
|
61
63
|
if (!appDir) {
|
|
62
64
|
console.error(
|
|
@@ -90,15 +92,18 @@ function runInstall(): number {
|
|
|
90
92
|
return 0;
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
// ── doctor (
|
|
95
|
+
// ── doctor (self-check) ─────────────────────────────────────────
|
|
94
96
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
detail: string;
|
|
99
|
-
}
|
|
97
|
+
/**
|
|
98
|
+
* @typedef {{ name: string, status: "pass" | "fail" | "info", detail: string }} DoctorCheck
|
|
99
|
+
*/
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} name
|
|
103
|
+
* @param {string} [prefix]
|
|
104
|
+
* @returns {DoctorCheck}
|
|
105
|
+
*/
|
|
106
|
+
function checkEnv(name, prefix) {
|
|
102
107
|
const value = process.env[name];
|
|
103
108
|
if (!value) {
|
|
104
109
|
return {
|
|
@@ -121,7 +126,11 @@ function checkEnv(name: string, prefix?: string): DoctorCheck {
|
|
|
121
126
|
};
|
|
122
127
|
}
|
|
123
128
|
|
|
124
|
-
|
|
129
|
+
/**
|
|
130
|
+
* @param {string} appDir
|
|
131
|
+
* @returns {DoctorCheck}
|
|
132
|
+
*/
|
|
133
|
+
function checkLayoutUsage(appDir) {
|
|
125
134
|
// Mirror Next.js's layout file resolution. Order matters — TS > JS to
|
|
126
135
|
// match what Next.js itself would pick.
|
|
127
136
|
const candidates = [
|
|
@@ -140,9 +149,6 @@ function checkLayoutUsage(appDir: string): DoctorCheck {
|
|
|
140
149
|
}
|
|
141
150
|
|
|
142
151
|
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
152
|
if (!content.includes("SmkingAEO")) {
|
|
147
153
|
return {
|
|
148
154
|
name: "<SmkingAEO /> in root layout",
|
|
@@ -157,7 +163,10 @@ function checkLayoutUsage(appDir: string): DoctorCheck {
|
|
|
157
163
|
};
|
|
158
164
|
}
|
|
159
165
|
|
|
160
|
-
|
|
166
|
+
/**
|
|
167
|
+
* @returns {Promise<DoctorCheck>}
|
|
168
|
+
*/
|
|
169
|
+
async function checkApiReachable() {
|
|
161
170
|
const apiKey = process.env.SMKING_API_KEY;
|
|
162
171
|
const baseUrl = process.env.SMKING_BASE_URL;
|
|
163
172
|
if (!apiKey || !baseUrl) {
|
|
@@ -222,16 +231,21 @@ async function checkApiReachable(): Promise<DoctorCheck> {
|
|
|
222
231
|
}
|
|
223
232
|
}
|
|
224
233
|
|
|
225
|
-
|
|
234
|
+
/**
|
|
235
|
+
* @param {boolean} jsonOutput
|
|
236
|
+
* @returns {Promise<number>}
|
|
237
|
+
*/
|
|
238
|
+
async function runDoctor(jsonOutput) {
|
|
226
239
|
const appDir = detectAppDir();
|
|
227
|
-
|
|
240
|
+
/** @type {DoctorCheck[]} */
|
|
241
|
+
const checks = [
|
|
228
242
|
checkEnv("SMKING_API_KEY", "pk_"),
|
|
229
243
|
checkEnv("SMKING_BASE_URL", "http"),
|
|
230
244
|
appDir
|
|
231
245
|
? checkLayoutUsage(appDir)
|
|
232
246
|
: {
|
|
233
247
|
name: "<SmkingAEO /> in root layout",
|
|
234
|
-
status: "info"
|
|
248
|
+
status: "info",
|
|
235
249
|
detail: "no app/ or src/app/ directory found — skipped",
|
|
236
250
|
},
|
|
237
251
|
await checkApiReachable(),
|
|
@@ -272,7 +286,7 @@ async function runDoctor(jsonOutput: boolean): Promise<number> {
|
|
|
272
286
|
|
|
273
287
|
// ── Entry dispatcher ────────────────────────────────────────────
|
|
274
288
|
|
|
275
|
-
async function main()
|
|
289
|
+
async function main() {
|
|
276
290
|
const subcommand = process.argv[2];
|
|
277
291
|
const jsonOutput = process.argv.includes("--json");
|
|
278
292
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soloworks/smking-next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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",
|
|
@@ -19,13 +19,9 @@
|
|
|
19
19
|
"types": "./src/components/smking-cms.tsx",
|
|
20
20
|
"default": "./src/components/smking-cms.tsx"
|
|
21
21
|
},
|
|
22
|
-
"./
|
|
23
|
-
"types": "./src/lib/
|
|
24
|
-
"default": "./src/lib/
|
|
25
|
-
},
|
|
26
|
-
"./route": {
|
|
27
|
-
"types": "./src/route.ts",
|
|
28
|
-
"default": "./src/route.ts"
|
|
22
|
+
"./webhook": {
|
|
23
|
+
"types": "./src/lib/webhook-route.ts",
|
|
24
|
+
"default": "./src/lib/webhook-route.ts"
|
|
29
25
|
},
|
|
30
26
|
"./robots": {
|
|
31
27
|
"types": "./src/lib/robots.ts",
|
|
@@ -38,10 +34,14 @@
|
|
|
38
34
|
"./llms-txt": {
|
|
39
35
|
"types": "./src/lib/llms-txt-route.ts",
|
|
40
36
|
"default": "./src/lib/llms-txt-route.ts"
|
|
37
|
+
},
|
|
38
|
+
"./proxy": {
|
|
39
|
+
"types": "./src/lib/proxy.ts",
|
|
40
|
+
"default": "./src/lib/proxy.ts"
|
|
41
41
|
}
|
|
42
42
|
},
|
|
43
43
|
"bin": {
|
|
44
|
-
"smking-next": "./bin/install.
|
|
44
|
+
"smking-next": "./bin/install.mjs"
|
|
45
45
|
},
|
|
46
46
|
"files": [
|
|
47
47
|
"src",
|
|
@@ -63,29 +63,7 @@
|
|
|
63
63
|
],
|
|
64
64
|
"peerDependencies": {
|
|
65
65
|
"next": "^15.0.0 || ^16.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
|
-
}
|
|
66
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
89
67
|
},
|
|
90
68
|
"devDependencies": {
|
|
91
69
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -1,11 +1,5 @@
|
|
|
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
1
|
import { getCmsPage } from "../lib/cms-client";
|
|
8
|
-
import type { CmsParams } from "../types";
|
|
2
|
+
import type { Block, CmsParams } from "../types";
|
|
9
3
|
|
|
10
4
|
/**
|
|
11
5
|
* Server Component that renders a published smking CMS page.
|
|
@@ -24,19 +18,19 @@ import type { CmsParams } from "../types";
|
|
|
24
18
|
* }
|
|
25
19
|
* ```
|
|
26
20
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
21
|
+
* v0.11+ substrate pivot: fetches a Block[] array and dispatches each
|
|
22
|
+
* block by `component`. Article block carries pre-rendered HTML
|
|
23
|
+
* (server-rendered at write time via tiptap-html, so the customer SDK
|
|
24
|
+
* needs zero Tiptap dependencies). Hero / nav-* blocks render inline;
|
|
25
|
+
* full server-side data fetch for nav-recent-posts / nav-taxonomy-list
|
|
26
|
+
* / nav-search arrives in M2+ — they emit pending markers for now so
|
|
27
|
+
* customer pages don't 500 if the page contains them.
|
|
33
28
|
*
|
|
34
29
|
* Returns null when the response isn't ready (pending / not_found /
|
|
35
30
|
* unreachable / mis-configured) — fail-open by design.
|
|
36
31
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* @tiptap/extension-image @tiptap/extension-link
|
|
32
|
+
* Emits `<title>` / `<meta>` / `og:*` / canonical inline; React 19
|
|
33
|
+
* hoists them into `<head>` automatically.
|
|
40
34
|
*/
|
|
41
35
|
export async function SmkingCms(props: CmsParams) {
|
|
42
36
|
const data = await getCmsPage(props);
|
|
@@ -47,6 +41,7 @@ export async function SmkingCms(props: CmsParams) {
|
|
|
47
41
|
// (last write wins on duplicate tags), so a deeper layout / page that
|
|
48
42
|
// also sets these still overrides ours where present.
|
|
49
43
|
const seo = data.seo;
|
|
44
|
+
const blocks = data.page.blocks ?? [];
|
|
50
45
|
return (
|
|
51
46
|
<>
|
|
52
47
|
{seo?.title && <title data-smking="cms">{seo.title}</title>}
|
|
@@ -74,23 +69,95 @@ export async function SmkingCms(props: CmsParams) {
|
|
|
74
69
|
<link rel="canonical" href={seo.canonicalUrl} data-smking="cms" />
|
|
75
70
|
)}
|
|
76
71
|
|
|
77
|
-
<article
|
|
72
|
+
<article
|
|
73
|
+
className="smk-cms"
|
|
74
|
+
data-smking="cms"
|
|
75
|
+
data-content-type={data.page.contentType}
|
|
76
|
+
>
|
|
78
77
|
{data.page.title && (
|
|
79
78
|
<h1 className="smk-cms__title">{data.page.title}</h1>
|
|
80
79
|
)}
|
|
81
|
-
{
|
|
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
|
-
})}
|
|
80
|
+
{blocks.map((block) => renderBlock(block))}
|
|
93
81
|
</article>
|
|
94
82
|
</>
|
|
95
83
|
);
|
|
96
84
|
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Dispatch a single block to its rendered React element.
|
|
88
|
+
*
|
|
89
|
+
* Trust boundary for `article` blocks: HTML is rendered server-side at
|
|
90
|
+
* write time by `@tiptap/html/server` inside the SaaS (Tiptap's renderer
|
|
91
|
+
* only emits HTML for known nodes per the configured schema, so author
|
|
92
|
+
* input can't smuggle arbitrary tags through). Customer site renders
|
|
93
|
+
* that HTML as-is via React's raw-HTML escape hatch. If customer wants
|
|
94
|
+
* defence-in-depth beyond the SaaS sanitisation tier, layer CSP on
|
|
95
|
+
* their host page (smking SDK does not ship a customer-side sanitiser
|
|
96
|
+
* to keep zero-dep customer install).
|
|
97
|
+
*/
|
|
98
|
+
function renderBlock(block: Block) {
|
|
99
|
+
switch (block.component) {
|
|
100
|
+
case "article": {
|
|
101
|
+
const articleHtml: { __html: string } = { __html: block.props.html };
|
|
102
|
+
return (
|
|
103
|
+
<div
|
|
104
|
+
key={block.id}
|
|
105
|
+
className="smk-block smk-block--article"
|
|
106
|
+
data-block-id={block.id}
|
|
107
|
+
// eslint-disable-next-line react/no-danger -- HTML pre-rendered server-side via @tiptap/html/server; trust boundary = SaaS schema. See function-level doc-comment above.
|
|
108
|
+
dangerouslySetInnerHTML={articleHtml}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
case "hero":
|
|
113
|
+
return (
|
|
114
|
+
<section
|
|
115
|
+
key={block.id}
|
|
116
|
+
className="smk-block smk-block--hero smk-hero"
|
|
117
|
+
data-block-id={block.id}
|
|
118
|
+
>
|
|
119
|
+
{block.props.image && (
|
|
120
|
+
<img
|
|
121
|
+
src={block.props.image.url}
|
|
122
|
+
alt={block.props.image.alt}
|
|
123
|
+
className="smk-hero__image"
|
|
124
|
+
/>
|
|
125
|
+
)}
|
|
126
|
+
<h2 className="smk-hero__title">{block.props.title}</h2>
|
|
127
|
+
{block.props.subtitle && (
|
|
128
|
+
<p className="smk-hero__subtitle">{block.props.subtitle}</p>
|
|
129
|
+
)}
|
|
130
|
+
{block.props.cta && (
|
|
131
|
+
<a className="smk-hero__cta" href={block.props.cta.href}>
|
|
132
|
+
{block.props.cta.label}
|
|
133
|
+
</a>
|
|
134
|
+
)}
|
|
135
|
+
</section>
|
|
136
|
+
);
|
|
137
|
+
case "nav-recent-posts":
|
|
138
|
+
case "nav-taxonomy-list":
|
|
139
|
+
case "nav-search":
|
|
140
|
+
// Server-side data fetch + render lands in M2+ alongside the
|
|
141
|
+
// public posts / taxonomy / search endpoints. Emit a marker so
|
|
142
|
+
// customer pages don't 500 if an editor publishes a page with
|
|
143
|
+
// one of these blocks before M2 ships.
|
|
144
|
+
return (
|
|
145
|
+
<section
|
|
146
|
+
key={block.id}
|
|
147
|
+
className={`smk-block smk-block--${block.component} smk-nav-pending`}
|
|
148
|
+
data-block-id={block.id}
|
|
149
|
+
data-block-component={block.component}
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
default:
|
|
153
|
+
// Forward-compat: unknown block component — emit empty marker
|
|
154
|
+
// so the page renders. Better than throwing.
|
|
155
|
+
return (
|
|
156
|
+
<section
|
|
157
|
+
key={(block as { id: string }).id}
|
|
158
|
+
className="smk-block smk-block--unknown"
|
|
159
|
+
data-block-component={(block as { component: string }).component}
|
|
160
|
+
/>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/lib/client.ts
CHANGED
|
@@ -26,8 +26,11 @@ function warnOnce(key: string, message: string): void {
|
|
|
26
26
|
* - JSON parse failure
|
|
27
27
|
*
|
|
28
28
|
* On success, cached by Next.js data cache for 1h (ISR backstop). Tagged
|
|
29
|
-
* with `smking:
|
|
30
|
-
* can `revalidateTag` to invalidate
|
|
29
|
+
* with `smking:aeo:<path>` so the unified webhook handler at
|
|
30
|
+
* `@soloworks/smking-next/webhook` can `revalidateTag` to invalidate
|
|
31
|
+
* instantly when SaaS pushes an update. Namespace changed from
|
|
32
|
+
* `smking:path:*` in v0.11 — unified channel uses payload.kind to
|
|
33
|
+
* scope tag prefix.
|
|
31
34
|
*
|
|
32
35
|
* Sends `{ key, path, url }` — `url` is required for first-sight
|
|
33
36
|
* registration: when the SaaS hasn't seen this path before it queues a
|
|
@@ -82,7 +85,7 @@ export async function getAeoContent(
|
|
|
82
85
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
83
86
|
next: {
|
|
84
87
|
revalidate: params.revalidate ?? DEFAULT_REVALIDATE_SECONDS,
|
|
85
|
-
tags: [`smking:
|
|
88
|
+
tags: [`smking:aeo:${path}`],
|
|
86
89
|
},
|
|
87
90
|
});
|
|
88
91
|
if (!res.ok) return null;
|
package/src/lib/cms-client.ts
CHANGED
|
@@ -33,9 +33,10 @@ function warnOnce(key: string, message: string): void {
|
|
|
33
33
|
*
|
|
34
34
|
* On success, cached by Next.js data cache for 5min by default
|
|
35
35
|
* (ISR backstop — see DEFAULT_REVALIDATE_SECONDS rationale).
|
|
36
|
-
* Tagged with `smking:
|
|
37
|
-
* `revalidateTag` to invalidate instantly when SaaS publishes an
|
|
38
|
-
* update.
|
|
36
|
+
* Tagged with `smking:cms_page:<slug>` so the unified `/webhook` handler
|
|
37
|
+
* can `revalidateTag` to invalidate instantly when SaaS publishes an
|
|
38
|
+
* update. Namespace changed from `smking:cms:*` in v0.11 — unified
|
|
39
|
+
* channel uses payload.kind to scope tag prefix.
|
|
39
40
|
*/
|
|
40
41
|
export async function getCmsPage(
|
|
41
42
|
params: CmsParams,
|
|
@@ -76,7 +77,7 @@ export async function getCmsPage(
|
|
|
76
77
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
77
78
|
next: {
|
|
78
79
|
revalidate: params.revalidate ?? DEFAULT_REVALIDATE_SECONDS,
|
|
79
|
-
tags: [`smking:
|
|
80
|
+
tags: [`smking:cms_page:${params.slug}`],
|
|
80
81
|
},
|
|
81
82
|
});
|
|
82
83
|
if (!res.ok) return null;
|