@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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI bot + referral detection. Shared by `smkingProxy` (Next.js middleware)
|
|
3
|
+
* and any caller that wants to classify a request before forwarding it to
|
|
4
|
+
* the smking ingestion endpoint.
|
|
5
|
+
*
|
|
6
|
+
* Patterns are 2026-04 active list — see
|
|
7
|
+
* docs/ai-tracking-implementation.md A.2 for sourcing notes. Quarterly
|
|
8
|
+
* refresh from Cloudflare Radar / Dark Visitors / each vendor's docs.
|
|
9
|
+
*
|
|
10
|
+
* Detection precedence:
|
|
11
|
+
* 1. Bot UA match → `purpose` derived from bot category
|
|
12
|
+
* 2. No bot, but referer is an AI search surface → `purpose = ai_referral`
|
|
13
|
+
* 3. Otherwise → no record (we don't store noise)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type AiBotCategory = "training" | "search" | "user_triggered";
|
|
17
|
+
|
|
18
|
+
export type AiHitPurpose =
|
|
19
|
+
| "training"
|
|
20
|
+
| "realtime_citation"
|
|
21
|
+
| "ai_referral"
|
|
22
|
+
| "unknown";
|
|
23
|
+
|
|
24
|
+
interface CrawlerPattern {
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly regex: RegExp;
|
|
27
|
+
readonly category: AiBotCategory;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PATTERNS: readonly CrawlerPattern[] = [
|
|
31
|
+
// OpenAI
|
|
32
|
+
{ name: "GPTBot", regex: /GPTBot\/[\d.]+/, category: "training" },
|
|
33
|
+
{ name: "OAI-SearchBot", regex: /OAI-SearchBot\/[\d.]+/, category: "search" },
|
|
34
|
+
{ name: "ChatGPT-User", regex: /ChatGPT-User\/[\d.]+/, category: "user_triggered" },
|
|
35
|
+
// Anthropic
|
|
36
|
+
{ name: "ClaudeBot", regex: /ClaudeBot\/[\d.]+/, category: "training" },
|
|
37
|
+
{ name: "Claude-Web", regex: /Claude-Web\/[\d.]+/, category: "user_triggered" },
|
|
38
|
+
{ name: "anthropic-ai", regex: /anthropic-ai/, category: "training" },
|
|
39
|
+
// Perplexity
|
|
40
|
+
{ name: "PerplexityBot", regex: /PerplexityBot\/[\d.]+/, category: "training" },
|
|
41
|
+
{ name: "Perplexity-User", regex: /Perplexity-User\/[\d.]+/, category: "user_triggered" },
|
|
42
|
+
// Google
|
|
43
|
+
{ name: "Google-Extended", regex: /Google-Extended/, category: "training" },
|
|
44
|
+
{ name: "GoogleOther", regex: /GoogleOther/, category: "training" },
|
|
45
|
+
// Apple
|
|
46
|
+
{ name: "Applebot-Extended", regex: /Applebot-Extended\/[\d.]+/, category: "training" },
|
|
47
|
+
// Common Crawl (shared corpus for many LLMs)
|
|
48
|
+
{ name: "CCBot", regex: /CCBot\/[\d.]+/, category: "training" },
|
|
49
|
+
// ByteDance / Doubao
|
|
50
|
+
{ name: "Bytespider", regex: /Bytespider/, category: "training" },
|
|
51
|
+
// Meta AI
|
|
52
|
+
{ name: "meta-externalagent", regex: /meta-externalagent\/[\d.]+/, category: "training" },
|
|
53
|
+
// Amazon Alexa+
|
|
54
|
+
{ name: "Amazonbot", regex: /Amazonbot\/[\d.]+/, category: "training" },
|
|
55
|
+
// Cohere
|
|
56
|
+
{ name: "cohere-ai", regex: /cohere-ai/, category: "training" },
|
|
57
|
+
// Diffbot
|
|
58
|
+
{ name: "Diffbot", regex: /Diffbot\/[\d.]+/, category: "training" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Hostnames that indicate the user came from an AI answer surface. Used to
|
|
63
|
+
* tag `ai_referral` traffic (no bot UA, real human session bounced from
|
|
64
|
+
* ChatGPT et al.).
|
|
65
|
+
*/
|
|
66
|
+
const AI_REFERRER_HOSTS: ReadonlySet<string> = new Set([
|
|
67
|
+
"chatgpt.com",
|
|
68
|
+
"chat.openai.com",
|
|
69
|
+
"perplexity.ai",
|
|
70
|
+
"www.perplexity.ai",
|
|
71
|
+
"claude.ai",
|
|
72
|
+
"gemini.google.com",
|
|
73
|
+
"copilot.microsoft.com",
|
|
74
|
+
"bing.com",
|
|
75
|
+
"www.bing.com",
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
export interface AiBotInfo {
|
|
79
|
+
readonly name: string;
|
|
80
|
+
readonly category: AiBotCategory;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Returns bot info if the UA matches a known AI crawler pattern. Linear
|
|
85
|
+
* scan of ~17 regexes — sub-millisecond per call, fine for a hot path.
|
|
86
|
+
*/
|
|
87
|
+
export function detectAiBot(userAgent: string | null | undefined): AiBotInfo | null {
|
|
88
|
+
if (!userAgent) return null;
|
|
89
|
+
for (const p of PATTERNS) {
|
|
90
|
+
if (p.regex.test(userAgent)) {
|
|
91
|
+
return { name: p.name, category: p.category };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Returns true when the referer header points to an AI answer surface.
|
|
99
|
+
* NULL-safe and tolerant of malformed URLs.
|
|
100
|
+
*/
|
|
101
|
+
export function detectAiReferral(referer: string | null | undefined): boolean {
|
|
102
|
+
if (!referer) return false;
|
|
103
|
+
try {
|
|
104
|
+
const host = new URL(referer).hostname.toLowerCase();
|
|
105
|
+
return AI_REFERRER_HOSTS.has(host);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface AiHitClassification {
|
|
112
|
+
readonly purpose: AiHitPurpose;
|
|
113
|
+
readonly bot: string | null;
|
|
114
|
+
readonly botCategory: AiBotCategory | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Classify a request from its UA + referer. Returns `null` when it's
|
|
119
|
+
* neither a known AI bot nor an AI-referred human session — the caller
|
|
120
|
+
* should skip recording these to keep ingestion focused.
|
|
121
|
+
*/
|
|
122
|
+
export function classifyAiHit(
|
|
123
|
+
userAgent: string | null | undefined,
|
|
124
|
+
referer: string | null | undefined,
|
|
125
|
+
): AiHitClassification | null {
|
|
126
|
+
const bot = detectAiBot(userAgent);
|
|
127
|
+
if (bot) {
|
|
128
|
+
const purpose: AiHitPurpose =
|
|
129
|
+
bot.category === "training" ? "training" : "realtime_citation";
|
|
130
|
+
return { purpose, bot: bot.name, botCategory: bot.category };
|
|
131
|
+
}
|
|
132
|
+
if (detectAiReferral(referer)) {
|
|
133
|
+
return { purpose: "ai_referral", bot: null, botCategory: null };
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
package/src/lib/path.ts
CHANGED
|
@@ -43,7 +43,7 @@ export async function resolveRequestPath(): Promise<{
|
|
|
43
43
|
* Normalize a path so cache tags align across hand-written and
|
|
44
44
|
* auto-resolved callers. `<SmkingAEO path="products/abc" />` and
|
|
45
45
|
* `<SmkingAEO path="/products/abc" />` must hit the same Next.js cache
|
|
46
|
-
* entry — otherwise the webhook handler's `revalidateTag('smking:
|
|
46
|
+
* entry — otherwise the webhook handler's `revalidateTag('smking:aeo:/products/abc')`
|
|
47
47
|
* misses the version of the entry that lacks the leading slash.
|
|
48
48
|
*/
|
|
49
49
|
export function normalizePath(path: string): string {
|
package/src/lib/proxy.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest, after } from "next/server";
|
|
2
|
+
import { classifyAiHit } from "./crawlers";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SmKing AI traffic ingestion proxy (v0.12+, Next.js 16 `proxy.ts`).
|
|
6
|
+
*
|
|
7
|
+
* Drop-in install:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* // proxy.ts (project root)
|
|
11
|
+
* import { smkingProxy } from "@soloworks/smking-next/proxy";
|
|
12
|
+
*
|
|
13
|
+
* export const proxy = smkingProxy({
|
|
14
|
+
* apiKey: process.env.SMKING_API_KEY!,
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* export const config = {
|
|
18
|
+
* matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
19
|
+
* };
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* What it does:
|
|
23
|
+
* - Inspects each request's UA + referer
|
|
24
|
+
* - If it's an AI bot or AI-referred human session, batches one
|
|
25
|
+
* ingestion call via Vercel `after()` (post-response background)
|
|
26
|
+
* - Otherwise: passes through with zero overhead
|
|
27
|
+
*
|
|
28
|
+
* Composing with an existing proxy:
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { composeProxy, smkingProxy } from "@soloworks/smking-next/proxy";
|
|
32
|
+
* import { customerProxy } from "./their-proxy";
|
|
33
|
+
*
|
|
34
|
+
* export const proxy = composeProxy([
|
|
35
|
+
* smkingProxy({ apiKey: process.env.SMKING_API_KEY! }),
|
|
36
|
+
* customerProxy,
|
|
37
|
+
* ]);
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* Fail-open posture (matches `getAeoContent`):
|
|
41
|
+
* - No `apiKey` → no-op, customer site keeps running
|
|
42
|
+
* - No `baseUrl` / `SMKING_BASE_URL` → no-op
|
|
43
|
+
* - Ingestion endpoint down / 4xx / 5xx → silent skip, next request retries
|
|
44
|
+
*
|
|
45
|
+
* Why `after()` (not fire-and-forget `fetch`):
|
|
46
|
+
* - Vercel serverless kills the function once the response flushes,
|
|
47
|
+
* which can drop a naked fetch before it's sent
|
|
48
|
+
* - `after()` keeps the function alive within the existing lifetime
|
|
49
|
+
* for background work — designed exactly for this case
|
|
50
|
+
*/
|
|
51
|
+
export interface SmkingProxyConfig {
|
|
52
|
+
/** Site's public API key (`pk_*`). Falls back to `SMKING_API_KEY` env. */
|
|
53
|
+
apiKey?: string;
|
|
54
|
+
/** Ingestion base URL. Falls back to `SMKING_BASE_URL` env. */
|
|
55
|
+
baseUrl?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type ProxyMiddleware = (
|
|
59
|
+
request: NextRequest,
|
|
60
|
+
) => NextResponse | Promise<NextResponse>;
|
|
61
|
+
|
|
62
|
+
const INGEST_TIMEOUT_MS = 2000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the proxy middleware. Returns a no-op middleware when the
|
|
66
|
+
* required env / config is missing so dev / staging installs without
|
|
67
|
+
* env never crash.
|
|
68
|
+
*/
|
|
69
|
+
export function smkingProxy(config: SmkingProxyConfig = {}): ProxyMiddleware {
|
|
70
|
+
return (request: NextRequest) => {
|
|
71
|
+
const apiKey = config.apiKey ?? process.env.SMKING_API_KEY;
|
|
72
|
+
const baseUrl = (config.baseUrl ?? process.env.SMKING_BASE_URL)?.replace(
|
|
73
|
+
/\/$/,
|
|
74
|
+
"",
|
|
75
|
+
);
|
|
76
|
+
if (!apiKey || !baseUrl) {
|
|
77
|
+
return NextResponse.next();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ua = request.headers.get("user-agent");
|
|
81
|
+
const referer = request.headers.get("referer");
|
|
82
|
+
const hit = classifyAiHit(ua, referer);
|
|
83
|
+
if (!hit) return NextResponse.next();
|
|
84
|
+
|
|
85
|
+
const path = request.nextUrl.pathname;
|
|
86
|
+
const pageUrl = request.nextUrl.toString();
|
|
87
|
+
|
|
88
|
+
after(async () => {
|
|
89
|
+
try {
|
|
90
|
+
await fetch(`${baseUrl}/api/v1/crawler-hit`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
"content-type": "application/json",
|
|
94
|
+
"x-public-key": apiKey,
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
hits: [
|
|
98
|
+
{
|
|
99
|
+
bot: hit.bot,
|
|
100
|
+
bot_category: hit.botCategory,
|
|
101
|
+
purpose: hit.purpose,
|
|
102
|
+
path,
|
|
103
|
+
page_url: pageUrl,
|
|
104
|
+
user_agent: ua,
|
|
105
|
+
referer,
|
|
106
|
+
timestamp: new Date().toISOString(),
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
}),
|
|
110
|
+
signal: AbortSignal.timeout(INGEST_TIMEOUT_MS),
|
|
111
|
+
});
|
|
112
|
+
} catch {
|
|
113
|
+
// Ingestion failure must NEVER bubble to the customer site.
|
|
114
|
+
// Next request will retry — losing a hit is acceptable.
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return NextResponse.next();
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run middlewares in sequence. The first one to return a non-`next()`
|
|
124
|
+
* response (redirect / rewrite / abort) short-circuits the chain.
|
|
125
|
+
*
|
|
126
|
+
* NOTE: this assumes each middleware in the chain only adds headers or
|
|
127
|
+
* passes through — it does NOT compose response bodies. Use cases:
|
|
128
|
+
* - smkingProxy (telemetry only) + customer auth proxy
|
|
129
|
+
* - smkingProxy + customer feature flag proxy
|
|
130
|
+
*/
|
|
131
|
+
export function composeProxy(handlers: ProxyMiddleware[]): ProxyMiddleware {
|
|
132
|
+
return async (request: NextRequest) => {
|
|
133
|
+
for (const h of handlers) {
|
|
134
|
+
const result = await h(request);
|
|
135
|
+
if (!isPassThrough(result)) return result;
|
|
136
|
+
}
|
|
137
|
+
return NextResponse.next();
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isPassThrough(res: NextResponse): boolean {
|
|
142
|
+
// `NextResponse.next()` carries `x-middleware-next: 1` internally.
|
|
143
|
+
// Public surface: `res.headers.get('x-middleware-next')` returns "1"
|
|
144
|
+
// for a pass-through. Anything else (redirect, rewrite, body) is a
|
|
145
|
+
// terminal response.
|
|
146
|
+
return res.headers.get("x-middleware-next") === "1";
|
|
147
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { revalidateTag } from "next/cache";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
interface WebhookPayload {
|
|
6
|
+
kind?: string;
|
|
7
|
+
paths?: string[];
|
|
8
|
+
slugs?: string[];
|
|
9
|
+
deliveredAt?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* SmKing unified webhook handler (v0.11+).
|
|
14
|
+
*
|
|
15
|
+
* Drop-in install:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* // app/api/smking/webhook/route.ts
|
|
19
|
+
* export { POST } from "@soloworks/smking-next/webhook";
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Replaces v0.10's split handlers:
|
|
23
|
+
* - `/route` AEO, Bearer-authed (SMKING_WEBHOOK_TOKEN) — GONE
|
|
24
|
+
* - `/cms-webhook` CMS, HMAC-signed (SMKING_WEBHOOK_SECRET) — GONE
|
|
25
|
+
*
|
|
26
|
+
* Single HMAC-SHA256 endpoint. Payload `.kind` (`"aeo" | "cms_page" |
|
|
27
|
+
* future kinds`) dispatches which revalidate tag namespace to use:
|
|
28
|
+
*
|
|
29
|
+
* smking:{kind}:{identifier}
|
|
30
|
+
*
|
|
31
|
+
* Where identifier is either:
|
|
32
|
+
* - path (AEO: `["/products/foo"]`) — `smking:aeo:/products/foo`
|
|
33
|
+
* - slug (CMS: `["hello"]`) — `smking:cms_page:hello`
|
|
34
|
+
*
|
|
35
|
+
* Customer migration from v0.10:
|
|
36
|
+
* 1. env: SMKING_WEBHOOK_TOKEN → SMKING_WEBHOOK_SECRET (HMAC key)
|
|
37
|
+
* 2. route path: /api/smking-revalidate + /api/smking/webhook
|
|
38
|
+
* → /api/smking/webhook (single)
|
|
39
|
+
* 3. revalidateTag callers update tag prefix
|
|
40
|
+
*
|
|
41
|
+
* Auth: HMAC-SHA256 only. Bearer dropped — single auth model means one
|
|
42
|
+
* env var, one shim, fewer customer mis-config paths (the disambiguation
|
|
43
|
+
* problem the v0.10 dual-handler shipped with).
|
|
44
|
+
*/
|
|
45
|
+
export async function POST(request: Request): Promise<Response> {
|
|
46
|
+
const secret = process.env.SMKING_WEBHOOK_SECRET;
|
|
47
|
+
if (!secret) {
|
|
48
|
+
return Response.json(
|
|
49
|
+
{ error: "webhook_secret_missing" },
|
|
50
|
+
{ status: 503 },
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Raw body for HMAC verify — re-encoding via JSON.parse + stringify
|
|
55
|
+
// would change byte order / spacing and invalidate the signature.
|
|
56
|
+
const rawBody = await request.text();
|
|
57
|
+
const providedSig = request.headers.get("x-smking-signature");
|
|
58
|
+
if (!verifySignature(rawBody, providedSig, secret)) {
|
|
59
|
+
return Response.json({ error: "invalid_signature" }, { status: 401 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let payload: WebhookPayload;
|
|
63
|
+
try {
|
|
64
|
+
payload = JSON.parse(rawBody) as WebhookPayload;
|
|
65
|
+
} catch {
|
|
66
|
+
return Response.json({ error: "invalid_payload" }, { status: 400 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const kind = payload.kind;
|
|
70
|
+
if (typeof kind !== "string" || kind.length === 0) {
|
|
71
|
+
// Forward-compat: SaaS may emit kinds we haven't taught the SDK
|
|
72
|
+
// yet — acknowledge (200) without action so SaaS doesn't retry.
|
|
73
|
+
return Response.json({ ok: true, note: "no_action_taken" });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let revalidated = 0;
|
|
77
|
+
let errors = 0;
|
|
78
|
+
for (const path of payload.paths ?? []) {
|
|
79
|
+
try {
|
|
80
|
+
revalidateTag(`smking:${kind}:${path}`, "default");
|
|
81
|
+
revalidated++;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
errors++;
|
|
84
|
+
console.warn(
|
|
85
|
+
`[@soloworks/smking-next/webhook] revalidateTag failed for ${kind}/${path}:`,
|
|
86
|
+
err,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const slug of payload.slugs ?? []) {
|
|
91
|
+
try {
|
|
92
|
+
revalidateTag(`smking:${kind}:${slug}`, "default");
|
|
93
|
+
revalidated++;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
errors++;
|
|
96
|
+
console.warn(
|
|
97
|
+
`[@soloworks/smking-next/webhook] revalidateTag failed for ${kind}/${slug}:`,
|
|
98
|
+
err,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return Response.json({ ok: true, kind, revalidated, errors });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function verifySignature(
|
|
107
|
+
rawBody: string,
|
|
108
|
+
signatureHeader: string | null,
|
|
109
|
+
secret: string,
|
|
110
|
+
): boolean {
|
|
111
|
+
if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false;
|
|
112
|
+
const provided = signatureHeader.slice("sha256=".length);
|
|
113
|
+
const expected = crypto
|
|
114
|
+
.createHmac("sha256", secret)
|
|
115
|
+
.update(rawBody)
|
|
116
|
+
.digest("hex");
|
|
117
|
+
if (expected.length !== provided.length) return false;
|
|
118
|
+
try {
|
|
119
|
+
return crypto.timingSafeEqual(
|
|
120
|
+
Buffer.from(expected, "hex"),
|
|
121
|
+
Buffer.from(provided, "hex"),
|
|
122
|
+
);
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -56,16 +56,73 @@ export interface AeoResponse {
|
|
|
56
56
|
|
|
57
57
|
export type CmsStatus = "ready" | "pending" | "not_found";
|
|
58
58
|
|
|
59
|
+
export type CmsContentType = "article" | "landing" | "listing";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* v2 substrate block primitives. Mirrors `apps/web/src/features/cms/types.ts`
|
|
63
|
+
* on the SaaS side (kept in sync manually — when the v1 block catalog
|
|
64
|
+
* grows past 5 primitives, a shared workspace package replaces this
|
|
65
|
+
* duplication per `docs/superpowers/specs/2026-05-15-cms-dashboard-editor-design.md`
|
|
66
|
+
* section §11 + ai-cms-v2 §"擴充性").
|
|
67
|
+
*/
|
|
68
|
+
export interface HeroProps {
|
|
69
|
+
title: string;
|
|
70
|
+
subtitle?: string;
|
|
71
|
+
image?: { url: string; alt: string };
|
|
72
|
+
cta?: { label: string; href: string };
|
|
73
|
+
}
|
|
74
|
+
export interface ArticleProps {
|
|
75
|
+
html: string;
|
|
76
|
+
}
|
|
77
|
+
export interface ModuleHeader {
|
|
78
|
+
heading?: string;
|
|
79
|
+
viewAll?: { label: string; href: string };
|
|
80
|
+
}
|
|
81
|
+
export type NavLayout = "grid" | "list" | "carousel";
|
|
82
|
+
export interface NavRecentPostsProps extends ModuleHeader {
|
|
83
|
+
limit: number;
|
|
84
|
+
layout: NavLayout;
|
|
85
|
+
contentType?: "article" | "all";
|
|
86
|
+
}
|
|
87
|
+
export interface NavTaxonomyListProps extends ModuleHeader {
|
|
88
|
+
/** category-by-path: slug prefix; tag: taxonomies.slug */
|
|
89
|
+
source: "category-by-path" | "tag";
|
|
90
|
+
path: string;
|
|
91
|
+
limit: number;
|
|
92
|
+
layout: NavLayout;
|
|
93
|
+
}
|
|
94
|
+
export interface NavSearchProps {
|
|
95
|
+
placeholder: string;
|
|
96
|
+
contentType?: "article" | "all";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type Block =
|
|
100
|
+
| { component: "hero"; id: string; props: HeroProps }
|
|
101
|
+
| { component: "article"; id: string; props: ArticleProps }
|
|
102
|
+
| {
|
|
103
|
+
component: "nav-recent-posts";
|
|
104
|
+
id: string;
|
|
105
|
+
props: NavRecentPostsProps;
|
|
106
|
+
}
|
|
107
|
+
| {
|
|
108
|
+
component: "nav-taxonomy-list";
|
|
109
|
+
id: string;
|
|
110
|
+
props: NavTaxonomyListProps;
|
|
111
|
+
}
|
|
112
|
+
| { component: "nav-search"; id: string; props: NavSearchProps };
|
|
113
|
+
|
|
59
114
|
/**
|
|
60
|
-
* A single published CMS page returned by the smking public API
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* wraps @tiptap/static-renderer/pm/react with our extension list).
|
|
115
|
+
* A single published CMS page returned by the smking public API
|
|
116
|
+
* (substrate v2). Render via `<SmkingCms>` server component which
|
|
117
|
+
* dispatches each block by component type.
|
|
64
118
|
*/
|
|
65
119
|
export interface CmsPage {
|
|
66
120
|
slug: string;
|
|
67
121
|
title: string;
|
|
68
|
-
|
|
122
|
+
contentType: CmsContentType;
|
|
123
|
+
blocks: Block[];
|
|
124
|
+
excerpt?: string | null;
|
|
125
|
+
featuredImageUrl?: string | null;
|
|
69
126
|
publishedAt: string | null;
|
|
70
127
|
}
|
|
71
128
|
|
|
@@ -1,32 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
}
|