@soloworks/smking-next 0.20.1 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @soloworks/smking-next
2
2
 
3
+ ## 0.20.2 — 2026-06-08
4
+
5
+ **Feature (additive — shipped as a patch): CMS draft preview via Next.js Draft Mode.**
6
+
7
+ The dashboard 預覽 button can now show unpublished draft content on the real
8
+ customer page using a short-lived (15min) token. Everything here is additive,
9
+ so customers on `^0.20` get it automatically — no constraint change needed.
10
+
11
+ - New `./preview` export — a Draft Mode route handler. The install wizard
12
+ writes `app/smking-preview/route.ts` for you; to add it by hand:
13
+ ```ts
14
+ // app/smking-preview/route.ts
15
+ export { GET } from "@soloworks/smking-next/preview";
16
+ ```
17
+ It enables Draft Mode + stashes the token in an httpOnly cookie, then
18
+ redirects to the page.
19
+ - `<SmkingCms>` now renders draftBlocks (status `"preview"`) when Draft Mode
20
+ is on, fetched with `cache: "no-store"`. Published render is unchanged.
21
+ - New `CmsParams.previewToken` + `CmsStatus` gains `"preview"`.
22
+
23
+ **Install-only + fail-open:** pages without the preview route never enter
24
+ Draft Mode → always the published render. Existing installs are unaffected
25
+ until they add the route (re-run `npx @soloworks/smking-wizard` to get it).
26
+
3
27
  ## 0.20.1 — 2026-06-03
4
28
 
5
29
  **Chore: removed dead carousel / nav-recent-posts / nav-search block types.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soloworks/smking-next",
3
- "version": "0.20.1",
3
+ "version": "0.20.2",
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",
@@ -38,6 +38,10 @@
38
38
  "./proxy": {
39
39
  "types": "./src/lib/proxy.ts",
40
40
  "default": "./src/lib/proxy.ts"
41
+ },
42
+ "./preview": {
43
+ "types": "./src/lib/preview-route.ts",
44
+ "default": "./src/lib/preview-route.ts"
41
45
  }
42
46
  },
43
47
  "bin": {
@@ -1,3 +1,5 @@
1
+ import { cookies, draftMode } from "next/headers";
2
+
1
3
  import { getCmsPage } from "../lib/cms-client";
2
4
  import { safeJson } from "../lib/safe-json";
3
5
  import type { CmsParams } from "../types";
@@ -35,10 +37,39 @@ import type { CmsParams } from "../types";
35
37
  *
36
38
  * Emits `<title>` / `<meta>` / `og:*` / canonical inline; React 19
37
39
  * hoists them into `<head>` automatically.
40
+ *
41
+ * Do NOT also mount `<SmkingAEO>` on a page rendered by `<SmkingCms>`:
42
+ * both serve the same engine `@graph` (via `/public/page` and
43
+ * `/public/aeo` respectively), so stacking them emits a duplicate
44
+ * `<script type="application/ld+json">`. Pick one per page — `<SmkingCms>`
45
+ * for smking-hosted CMS pages, `<SmkingAEO>` for your own pages.
38
46
  */
39
47
  export async function SmkingCms(props: CmsParams) {
40
- const data = await getCmsPage(props);
41
- if (!data || data.status !== "ready" || !data.page) return null;
48
+ // Draft preview: when Next Draft Mode is on (set by the
49
+ // @soloworks/smking-next/preview route), carry the short-lived token from
50
+ // the cookie so getCmsPage hits the draft branch. Install-only + fail-open:
51
+ // pages without the preview route never enable draft mode → published render.
52
+ let previewToken: string | undefined;
53
+ try {
54
+ const { isEnabled } = await draftMode();
55
+ if (isEnabled) {
56
+ previewToken = (await cookies()).get("smking_preview_token")?.value;
57
+ }
58
+ } catch {
59
+ // draftMode/cookies unavailable (static export / outside request) →
60
+ // fall through to the published render.
61
+ }
62
+
63
+ const data = await getCmsPage(
64
+ previewToken ? { ...props, previewToken } : props,
65
+ );
66
+ if (
67
+ !data ||
68
+ (data.status !== "ready" && data.status !== "preview") ||
69
+ !data.page
70
+ ) {
71
+ return null;
72
+ }
42
73
 
43
74
  // SEO head tags emit inline. React 19 hoists `<title>` and `<meta>`
44
75
  // tags found anywhere in the tree into `<head>` automatically (last
@@ -103,13 +103,23 @@ export async function getCmsPage(
103
103
  if (appEnv) {
104
104
  apiUrl += `&app_env=${encodeURIComponent(appEnv)}`;
105
105
  }
106
+ if (params.previewToken) {
107
+ apiUrl += `&preview_token=${encodeURIComponent(params.previewToken)}`;
108
+ }
106
109
 
107
110
  const res = await fetch(apiUrl, {
108
111
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
109
- next: {
110
- revalidate: params.revalidate ?? DEFAULT_REVALIDATE_SECONDS,
111
- tags: [`smking:cms_page:${params.slug}`],
112
- },
112
+ // Preview (draft) reads must never hit the ISR data cache — always
113
+ // fresh, and they expire when the token does. Published reads keep
114
+ // the 5min ISR backstop + revalidate tags for webhook invalidation.
115
+ ...(params.previewToken
116
+ ? { cache: "no-store" as const }
117
+ : {
118
+ next: {
119
+ revalidate: params.revalidate ?? DEFAULT_REVALIDATE_SECONDS,
120
+ tags: [`smking:cms_page:${params.slug}`],
121
+ },
122
+ }),
113
123
  });
114
124
  if (!res.ok) return null;
115
125
  return (await res.json()) as CmsResponse;
@@ -0,0 +1,42 @@
1
+ import { cookies, draftMode } from "next/headers";
2
+ import { redirect } from "next/navigation";
3
+
4
+ /**
5
+ * Drop-in CMS draft preview entry. Customer adds:
6
+ *
7
+ * ```ts
8
+ * // app/smking-preview/route.ts
9
+ * export { GET } from "@soloworks/smking-next/preview";
10
+ * ```
11
+ *
12
+ * Flow: the dashboard 預覽鈕 opens `/smking-preview?token=…&path=/blog/x` →
13
+ * this enables Next.js Draft Mode + stashes the short-lived token in an
14
+ * httpOnly cookie → redirects to the page, which `<SmkingCms>` then renders
15
+ * from draftBlocks (status "preview").
16
+ *
17
+ * Fail-safe / install-only:
18
+ * - No token → plain redirect to the live page (Draft Mode NOT enabled).
19
+ * - Only same-origin relative paths are honored (open-redirect guard):
20
+ * anything not starting with a single "/" redirects to "/".
21
+ * - Sites without this route never enter draft mode → published render.
22
+ */
23
+ export async function GET(request: Request): Promise<Response> {
24
+ const url = new URL(request.url);
25
+ const token = url.searchParams.get("token");
26
+ const rawPath = url.searchParams.get("path") ?? "/";
27
+ const path =
28
+ rawPath.startsWith("/") && !rawPath.startsWith("//") ? rawPath : "/";
29
+
30
+ if (token) {
31
+ (await draftMode()).enable();
32
+ (await cookies()).set("smking_preview_token", token, {
33
+ httpOnly: true,
34
+ sameSite: "lax",
35
+ path: "/",
36
+ maxAge: 60 * 15,
37
+ });
38
+ }
39
+
40
+ // redirect() throws NEXT_REDIRECT — expected control flow in route handlers.
41
+ redirect(path);
42
+ }
package/src/types.ts CHANGED
@@ -54,7 +54,7 @@ export interface AeoResponse {
54
54
 
55
55
  // ── CMS body content (mirrors SaaS /api/v1/public/page) ───────────────
56
56
 
57
- export type CmsStatus = "ready" | "pending" | "not_found";
57
+ export type CmsStatus = "ready" | "preview" | "pending" | "not_found";
58
58
 
59
59
  export type CmsContentType = "article" | "landing" | "listing";
60
60
 
@@ -141,7 +141,9 @@ export interface CmsPage {
141
141
  /**
142
142
  * v0.M1+ engine-precomputed schema.org @graph. `<SmkingCms>` inlines it
143
143
  * as `<script type="application/ld+json">`. Null until the page is
144
- * republished under the structured-data engine.
144
+ * republished under the structured-data engine. Don't also render
145
+ * `<SmkingAEO>` on the same page — it serves the same @graph and would
146
+ * duplicate the ld+json script.
145
147
  */
146
148
  jsonLd?: Record<string, unknown> | null;
147
149
  }
@@ -184,6 +186,14 @@ export interface CmsParams {
184
186
  path?: string;
185
187
  /** Optional app environment (e.g. 'development', 'production'). */
186
188
  appEnv?: string;
189
+ /**
190
+ * Short-lived preview JWT (from the dashboard 預覽鈕, carried via the
191
+ * `smking_preview_token` cookie that `@soloworks/smking-next/preview`
192
+ * sets). When present, fetches draftBlocks (status "preview") with
193
+ * `cache: "no-store"`. Absent → normal published render. Install-only:
194
+ * pages without the preview route never see this.
195
+ */
196
+ previewToken?: string;
187
197
  }
188
198
 
189
199
  export interface DiscoverParams {