@soloworks/smking-next 0.20.0 → 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,40 @@
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
+
27
+ ## 0.20.1 — 2026-06-03
28
+
29
+ **Chore: removed dead carousel / nav-recent-posts / nav-search block types.**
30
+
31
+ Dropped `NavRecentPostsProps`, `NavSearchIndexEntry`, `NavSearchProps`,
32
+ `CarouselSlideProps`, `CarouselAspectRatio`, and `CarouselProps` from
33
+ `types.ts`. Narrowed the `Block` union to the three active block components
34
+ (`hero`, `article`, `nav-taxonomy-list`). `NavLayout` (layout enum including
35
+ `"carousel"`) is unchanged — that value controls nav rendering style, not a
36
+ separate block type.
37
+
3
38
  ## 0.20.0 — 2026-06-02
4
39
 
5
40
  **`<SmkingCms>` now emits the structured-data engine's JSON-LD `@graph` inline.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soloworks/smking-next",
3
- "version": "0.20.0",
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
 
@@ -102,13 +102,6 @@ export interface NavSnapshotEntry {
102
102
  contentType: "article" | "landing" | "listing" | null;
103
103
  }
104
104
 
105
- export interface NavRecentPostsProps extends ModuleHeader {
106
- limit: number;
107
- layout: NavLayout;
108
- contentType?: "article" | "all";
109
- /** Publish-time materialized — never fetched live. */
110
- snapshot?: NavSnapshotEntry[];
111
- }
112
105
  export interface NavTaxonomyListProps extends ModuleHeader {
113
106
  /** category-by-path: slug prefix; tag: taxonomies.slug */
114
107
  source: "category-by-path" | "tag";
@@ -119,56 +112,10 @@ export interface NavTaxonomyListProps extends ModuleHeader {
119
112
  snapshot?: NavSnapshotEntry[];
120
113
  }
121
114
 
122
- /**
123
- * Search index entry. Customer SDK does client-side fuzzy filter
124
- * against this list; clicking a result navigates to the slug.
125
- */
126
- export interface NavSearchIndexEntry {
127
- slug: string;
128
- title: string | null;
129
- excerpt: string | null;
130
- }
131
-
132
- export interface NavSearchProps {
133
- placeholder: string;
134
- contentType?: "article" | "all";
135
- /** Publish-time materialized full-site search index. */
136
- index?: NavSearchIndexEntry[];
137
- }
138
-
139
- export interface CarouselSlideProps {
140
- imageUrl: string;
141
- caption: string;
142
- }
143
-
144
- export type CarouselAspectRatio =
145
- | "16:9"
146
- | "4:3"
147
- | "1:1"
148
- | "3:4"
149
- | "9:16";
150
-
151
- export interface CarouselProps {
152
- slides: CarouselSlideProps[];
153
- /** Fixed aspect ratio for all slides — image fills via `object-fit: cover`. Default `16:9`. */
154
- aspectRatio?: CarouselAspectRatio;
155
- }
156
-
157
115
  export type Block =
158
116
  | { component: "hero"; id: string; props: HeroProps }
159
117
  | { component: "article"; id: string; props: ArticleProps }
160
- | { component: "carousel"; id: string; props: CarouselProps }
161
- | {
162
- component: "nav-recent-posts";
163
- id: string;
164
- props: NavRecentPostsProps;
165
- }
166
- | {
167
- component: "nav-taxonomy-list";
168
- id: string;
169
- props: NavTaxonomyListProps;
170
- }
171
- | { component: "nav-search"; id: string; props: NavSearchProps };
118
+ | { component: "nav-taxonomy-list"; id: string; props: NavTaxonomyListProps };
172
119
 
173
120
  /**
174
121
  * A single published CMS page returned by the smking public API
@@ -194,7 +141,9 @@ export interface CmsPage {
194
141
  /**
195
142
  * v0.M1+ engine-precomputed schema.org @graph. `<SmkingCms>` inlines it
196
143
  * as `<script type="application/ld+json">`. Null until the page is
197
- * 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.
198
147
  */
199
148
  jsonLd?: Record<string, unknown> | null;
200
149
  }
@@ -237,6 +186,14 @@ export interface CmsParams {
237
186
  path?: string;
238
187
  /** Optional app environment (e.g. 'development', 'production'). */
239
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;
240
197
  }
241
198
 
242
199
  export interface DiscoverParams {