@soloworks/smking-next 0.14.1 → 0.16.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 +67 -0
- package/package.json +1 -1
- package/src/components/smking-cms.tsx +30 -681
- package/src/lib/cms-client.ts +5 -2
- package/src/types.ts +12 -18
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# @soloworks/smking-next
|
|
2
2
|
|
|
3
|
+
## 0.16.0 — 2026-05-24
|
|
4
|
+
|
|
5
|
+
**Raw passthrough render — `<SmkingCms>` now echoes the full Plate-serialized bodyHtml exactly, no `<article class="smk-cms">` wrapper and no inline CSS injection.**
|
|
6
|
+
|
|
7
|
+
### What changed
|
|
8
|
+
|
|
9
|
+
Companion release to a SaaS-side simplification: the publish pipeline now calls Plate's stock `serializeHtml(editor)` with no options — preserving the full class chain (Tailwind utility from the dashboard cva variants + `slate-*` hook classes + `data-slate-*` attrs) instead of running an in-house `stripPlateNoise()` post-process. `<SmkingCms>` mirrors that posture: drop the wrapper, drop the canonical CSS inject, let bodyHtml render exactly as the dashboard Edit / Preview tabs render it.
|
|
10
|
+
|
|
11
|
+
### Removed (breaking)
|
|
12
|
+
|
|
13
|
+
- **`<article class="smk-cms">` shell wrapper** — replaced with a plain `<div data-smking="cms">`. Customer sites that depended on `.smk-cms` for layout (max-width / padding) need to reapply that themselves.
|
|
14
|
+
- **Inline canonical CSS injection** — gone. Customer sites that rely on the canonical block CSS must now ship it themselves (the constant `SMKING_CMS_STYLES` is still exported from `lib/smking-cms-styles` for sites that want to opt in).
|
|
15
|
+
- **Block[] dispatch path** — `renderBlock` + `renderNavList` + `renderNavSearch` + `formatPublishedAt` helpers (~325 lines) removed. Pages still on the legacy `page.blocks` shape will not render until the SaaS re-publishes them (every page since v0.15 has a bodyHtml field, so this only affects truly ancient drafts that have not been touched in months).
|
|
16
|
+
- **Smking Web Component IIFE injection** — `<smking-carousel>` registration script is no longer auto-emitted. Customer sites with carousel blocks need to ship their own registration; the IIFE constant remains exported from `lib/smking-elements-iife` for sites that want to inline it manually.
|
|
17
|
+
|
|
18
|
+
### Why
|
|
19
|
+
|
|
20
|
+
Dashboard now publishes the same Plate output the Edit canvas renders. Wrapping that markup in a separate "customer article shell" with its own typography tokens broke the WYSIWYG promise — author saw one thing in the editor, customer saw another. Raw passthrough makes the contract unambiguous: bodyHtml is the source of truth, customer site owns layout.
|
|
21
|
+
|
|
22
|
+
### Migration
|
|
23
|
+
|
|
24
|
+
If your customer site relied on the `.smk-cms` shell or the canonical CSS inject, ship them at the customer side before bumping — import `SMKING_CMS_STYLES` from `@soloworks/smking-next/lib/smking-cms-styles` and emit it via a `<style>` tag, then wrap `<SmkingCms>` in your own `<article className="smk-cms">`. For Tailwind sites: utility classes in bodyHtml (`text-4xl`, `font-bold`, etc.) resolve natively — no extra step needed.
|
|
25
|
+
|
|
26
|
+
## 0.15.0 — 2026-05-24
|
|
27
|
+
|
|
28
|
+
**Customer Mode A now consumes Plate-serialized HTML directly — Preview tab = customer site, byte-identical.**
|
|
29
|
+
|
|
30
|
+
### Architecture flip
|
|
31
|
+
|
|
32
|
+
SaaS publish handler now runs Plate's `serializeHtml` over the full page (with nav-* snapshots injected) and ships the result as `page.bodyHtml`. `<SmkingCms>` echoes that HTML inside the canonical `.smk-cms` wrapper — no per-block React dispatch when bodyHtml is present. Dashboard Preview tab runs the same Plate plugin chain client-side via PlateStatic, so what the editor sees = what the customer site renders, byte-for-byte (modulo data-* attrs that the publish handler strips post-process).
|
|
33
|
+
|
|
34
|
+
### Removed (breaking)
|
|
35
|
+
|
|
36
|
+
- **`CmsThemeMode` type + `mode` prop on `<SmkingCms>`** — Mode B (`tailwind-prose`) dropped. Customers still wanting Tailwind Typography wrap can put a `<article class="prose dark:prose-invert">` around `<SmkingCms>` themselves; typography plugin will style the emitted `<h2>`/`<p>`/`<ul>` etc as normal.
|
|
37
|
+
- **`renderBlockProse` / `renderNavListProse` / `renderNavSearchProse`** helper functions — internal, was the Mode B dispatcher.
|
|
38
|
+
- **`SMKING_CMS_THEME` env var** — ignored; was Mode B selection signal.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- `<SmkingCms>` outer wrapper always emits `<article class="smk-cms">`. When `bodyHtml` is null (SaaS deployment that hasn't republished pages since v0.16), falls back to the legacy `renderBlock` switch path so customer pages keep rendering during the rollout window.
|
|
43
|
+
- Always emit canonical `<style data-smking="cms-styles">` (previously gated on Mode A vs Mode B). Conditionally emit `<smking-carousel>` IIFE — now also detected by `<smking-carousel` substring match in bodyHtml.
|
|
44
|
+
|
|
45
|
+
### Pairs with
|
|
46
|
+
|
|
47
|
+
- `smking/laravel` v0.16.0 — same Mode B removal + bodyHtml echo path on the Laravel side.
|
|
48
|
+
- `@soloworks/smking-wizard` v0.5.0 — theme-mode-select TUI screen removed alongside the Tailwind plugin install path.
|
|
49
|
+
|
|
50
|
+
### Customer migration
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pnpm update @soloworks/smking-next # → 0.15.0
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
If you were using Mode B (`SMKING_CMS_THEME=tailwind-prose`):
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
// Before (v0.14.x)
|
|
60
|
+
<SmkingCms apiKey={...} slug="..." mode="tailwind-prose" />
|
|
61
|
+
|
|
62
|
+
// After (v0.15.0) — wrap in your own .prose for typography plugin
|
|
63
|
+
<article className="prose dark:prose-invert mx-auto">
|
|
64
|
+
<SmkingCms apiKey={...} slug="..." />
|
|
65
|
+
</article>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The SDK canonical `<style data-smking="cms-styles">` will still emit; you can either keep both styling layers (typography wins specificity-wise inside `.prose`) or `config('smking.cms.inline_styles', false)` to disable the SDK stylesheet.
|
|
69
|
+
|
|
3
70
|
## 0.14.1 — 2026-05-23
|
|
4
71
|
|
|
5
72
|
**Carousel polish in canonical CSS — nav buttons, dots indicator, image-frame, caption-row + download icon now ship to customer Mode A pages.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soloworks/smking-next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -1,30 +1,5 @@
|
|
|
1
1
|
import { getCmsPage } from "../lib/cms-client";
|
|
2
|
-
import {
|
|
3
|
-
import { SMKING_ELEMENTS_IIFE } from "../lib/smking-elements-iife";
|
|
4
|
-
import type {
|
|
5
|
-
Block,
|
|
6
|
-
CmsParams,
|
|
7
|
-
CmsThemeMode,
|
|
8
|
-
ModuleHeader,
|
|
9
|
-
NavSearchIndexEntry,
|
|
10
|
-
NavSnapshotEntry,
|
|
11
|
-
} from "../types";
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Pick the active theme mode for this render. Precedence:
|
|
15
|
-
* 1. explicit `mode` prop (per-render override)
|
|
16
|
-
* 2. SMKING_CMS_THEME env var (per-deployment default)
|
|
17
|
-
* 3. 'css' (Mode A — the SDK ships its own styles)
|
|
18
|
-
*
|
|
19
|
-
* Wizard auto-detect writes the env var on install; customer can still
|
|
20
|
-
* flip per-page via the prop.
|
|
21
|
-
*/
|
|
22
|
-
function resolveThemeMode(modeProp?: CmsThemeMode): CmsThemeMode {
|
|
23
|
-
if (modeProp === "css" || modeProp === "tailwind-prose") return modeProp;
|
|
24
|
-
const env = process.env.SMKING_CMS_THEME;
|
|
25
|
-
if (env === "tailwind-prose") return "tailwind-prose";
|
|
26
|
-
return "css";
|
|
27
|
-
}
|
|
2
|
+
import type { CmsParams } from "../types";
|
|
28
3
|
|
|
29
4
|
/**
|
|
30
5
|
* Server Component that renders a published smking CMS page.
|
|
@@ -37,21 +12,22 @@ function resolveThemeMode(modeProp?: CmsThemeMode): CmsThemeMode {
|
|
|
37
12
|
* return (
|
|
38
13
|
* <SmkingCms
|
|
39
14
|
* apiKey={process.env.SMKING_API_KEY!}
|
|
40
|
-
* slug="
|
|
15
|
+
* slug="" // root → /blog landing
|
|
41
16
|
* />
|
|
42
17
|
* );
|
|
43
18
|
* }
|
|
44
19
|
* ```
|
|
45
20
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
21
|
+
* **Raw passthrough mode** — echoes the full Plate-serialized bodyHtml
|
|
22
|
+
* exactly as the SaaS publish handler emits it (Tailwind utility
|
|
23
|
+
* classes + `slate-*` hook classes + `data-slate-*` attrs all
|
|
24
|
+
* intact). The component itself does NOT wrap the markup in an
|
|
25
|
+
* `<article>` shell or inject canonical CSS — what the dashboard
|
|
26
|
+
* Edit / Preview tabs render is what reaches the customer page. If
|
|
27
|
+
* your customer site uses Tailwind, the utility classes resolve
|
|
28
|
+
* natively; otherwise you can ship `cms-styles.css` (still exported
|
|
29
|
+
* from this package via `lib/smking-cms-styles`) alongside, which
|
|
30
|
+
* targets `.slate-*` hooks in plain CSS.
|
|
55
31
|
*
|
|
56
32
|
* Returns null when the response isn't ready (pending / not_found /
|
|
57
33
|
* unreachable / mis-configured) — fail-open by design.
|
|
@@ -59,51 +35,18 @@ function resolveThemeMode(modeProp?: CmsThemeMode): CmsThemeMode {
|
|
|
59
35
|
* Emits `<title>` / `<meta>` / `og:*` / canonical inline; React 19
|
|
60
36
|
* hoists them into `<head>` automatically.
|
|
61
37
|
*/
|
|
62
|
-
export async function SmkingCms(props: CmsParams
|
|
38
|
+
export async function SmkingCms(props: CmsParams) {
|
|
63
39
|
const data = await getCmsPage(props);
|
|
64
40
|
if (!data || data.status !== "ready" || !data.page) return null;
|
|
65
41
|
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// also sets these still overrides ours where present.
|
|
42
|
+
// SEO head tags emit inline. React 19 hoists `<title>` and `<meta>`
|
|
43
|
+
// tags found anywhere in the tree into `<head>` automatically (last
|
|
44
|
+
// write wins on duplicates).
|
|
70
45
|
const seo = data.seo;
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
// Emit the Web Component registration `<script>` once if any block
|
|
74
|
-
// needs `<smking-carousel>` / `<smking-slide>`. The IIFE is
|
|
75
|
-
// self-guarded so duplicate emits are harmless; gating here keeps
|
|
76
|
-
// zero-dep pages truly zero-byte.
|
|
77
|
-
// Web Component IIFE is needed only when the page actually contains
|
|
78
|
-
// a `<smking-carousel>`. nav-search emits its own self-contained
|
|
79
|
-
// inline IIFE (no Web Component registration needed) so we don't gate
|
|
80
|
-
// on it here.
|
|
81
|
-
const needsSmkingElements = blocks.some((b) => b.component === "carousel");
|
|
82
|
-
// IIFE / CSS sources are hand-authored package constants (not user
|
|
83
|
-
// data) — passing them through __html here is the standard React
|
|
84
|
-
// server-side inline-script / inline-style emit pattern.
|
|
85
|
-
const elementsScript: { __html: string } = { __html: SMKING_ELEMENTS_IIFE };
|
|
86
|
-
const cmsStylesPayload: { __html: string } = { __html: SMKING_CMS_STYLES };
|
|
87
|
-
// Mode A (`css`) — emit the canonical SDK stylesheet once. Mode B
|
|
88
|
-
// (`tailwind-prose`) opts the customer's Tailwind + Typography
|
|
89
|
-
// plugin into rendering and skips this entirely.
|
|
90
|
-
const shouldEmitInlineStyles = mode === "css";
|
|
46
|
+
const bodyHtml = data.page.bodyHtml ?? "";
|
|
47
|
+
|
|
91
48
|
return (
|
|
92
49
|
<>
|
|
93
|
-
{shouldEmitInlineStyles && (
|
|
94
|
-
<style
|
|
95
|
-
data-smking="cms-styles"
|
|
96
|
-
// eslint-disable-next-line react/no-danger -- CSS source is a package constant generated from packages/shared/src/styles/cms-styles.css.
|
|
97
|
-
dangerouslySetInnerHTML={cmsStylesPayload}
|
|
98
|
-
/>
|
|
99
|
-
)}
|
|
100
|
-
{needsSmkingElements && (
|
|
101
|
-
<script
|
|
102
|
-
data-smking="elements"
|
|
103
|
-
// eslint-disable-next-line react/no-danger -- IIFE source is a package constant.
|
|
104
|
-
dangerouslySetInnerHTML={elementsScript}
|
|
105
|
-
/>
|
|
106
|
-
)}
|
|
107
50
|
{seo?.title && <title data-smking="cms">{seo.title}</title>}
|
|
108
51
|
{seo?.metaDescription && (
|
|
109
52
|
<meta
|
|
@@ -129,614 +72,20 @@ export async function SmkingCms(props: CmsParams & { mode?: CmsThemeMode }) {
|
|
|
129
72
|
<link rel="canonical" href={seo.canonicalUrl} data-smking="cms" />
|
|
130
73
|
)}
|
|
131
74
|
|
|
132
|
-
{
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
</article>
|
|
141
|
-
) : (
|
|
142
|
-
<article
|
|
143
|
-
className="smk-cms"
|
|
75
|
+
{/* Raw passthrough — bodyHtml is SaaS-emitted Plate output (Tailwind
|
|
76
|
+
utility + slate-* hooks + data-slate-* attrs all intact). No
|
|
77
|
+
article shell, no canonical CSS injection: what the dashboard
|
|
78
|
+
renders is what the customer page renders. Trust boundary:
|
|
79
|
+
schema-bounded Plate serialize on the SaaS side, same posture
|
|
80
|
+
as the previous wrapped version. */}
|
|
81
|
+
{bodyHtml ? (
|
|
82
|
+
<div
|
|
144
83
|
data-smking="cms"
|
|
145
84
|
data-content-type={data.page.contentType}
|
|
146
|
-
|
|
147
|
-
{
|
|
148
|
-
<h1 className="smk-cms__title">{data.page.title}</h1>
|
|
149
|
-
)}
|
|
150
|
-
{blocks.map((block) => renderBlock(block))}
|
|
151
|
-
</article>
|
|
152
|
-
)}
|
|
153
|
-
</>
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Dispatch a single block to its rendered React element.
|
|
159
|
-
*
|
|
160
|
-
* Trust boundary for `article` blocks: HTML is rendered server-side at
|
|
161
|
-
* write time by `@tiptap/html/server` inside the SaaS (Tiptap's renderer
|
|
162
|
-
* only emits HTML for known nodes per the configured schema, so author
|
|
163
|
-
* input can't smuggle arbitrary tags through). Customer site renders
|
|
164
|
-
* that HTML as-is via React's raw-HTML escape hatch. If customer wants
|
|
165
|
-
* defence-in-depth beyond the SaaS sanitisation tier, layer CSP on
|
|
166
|
-
* their host page (smking SDK does not ship a customer-side sanitiser
|
|
167
|
-
* to keep zero-dep customer install).
|
|
168
|
-
*/
|
|
169
|
-
function renderBlock(block: Block) {
|
|
170
|
-
switch (block.component) {
|
|
171
|
-
case "article": {
|
|
172
|
-
const articleHtml: { __html: string } = { __html: block.props.html };
|
|
173
|
-
return (
|
|
174
|
-
<div
|
|
175
|
-
key={block.id}
|
|
176
|
-
className="smk-block smk-block--article"
|
|
177
|
-
data-block-id={block.id}
|
|
178
|
-
// 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.
|
|
179
|
-
dangerouslySetInnerHTML={articleHtml}
|
|
180
|
-
/>
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
case "hero":
|
|
184
|
-
return (
|
|
185
|
-
<section
|
|
186
|
-
key={block.id}
|
|
187
|
-
className="smk-block smk-block--hero smk-hero"
|
|
188
|
-
data-block-id={block.id}
|
|
189
|
-
>
|
|
190
|
-
{block.props.image && (
|
|
191
|
-
<img
|
|
192
|
-
src={block.props.image.url}
|
|
193
|
-
alt={block.props.image.alt}
|
|
194
|
-
className="smk-hero__image"
|
|
195
|
-
/>
|
|
196
|
-
)}
|
|
197
|
-
<h2 className="smk-hero__title">{block.props.title}</h2>
|
|
198
|
-
{block.props.subtitle && (
|
|
199
|
-
<p className="smk-hero__subtitle">{block.props.subtitle}</p>
|
|
200
|
-
)}
|
|
201
|
-
{block.props.cta && (
|
|
202
|
-
<a className="smk-hero__cta" href={block.props.cta.href}>
|
|
203
|
-
{block.props.cta.label}
|
|
204
|
-
</a>
|
|
205
|
-
)}
|
|
206
|
-
</section>
|
|
207
|
-
);
|
|
208
|
-
case "carousel": {
|
|
209
|
-
// Canonical `<smking-carousel>` markup — the Web Component
|
|
210
|
-
// registered by `SMKING_ELEMENTS_IIFE` upgrades this into a
|
|
211
|
-
// swipe + nav + dots carousel on the client. Custom-element
|
|
212
|
-
// tags aren't in React's JSX intrinsic types by default; cast
|
|
213
|
-
// through a string ref so we don't need a global d.ts in the
|
|
214
|
-
// customer's package.
|
|
215
|
-
const CustomCarousel = "smking-carousel" as unknown as "section";
|
|
216
|
-
const CustomSlide = "smking-slide" as unknown as "div";
|
|
217
|
-
const aspect = block.props.aspectRatio ?? "16:9";
|
|
218
|
-
return (
|
|
219
|
-
<CustomCarousel
|
|
220
|
-
key={block.id}
|
|
221
|
-
className="smk-block smk-block--carousel smk-carousel"
|
|
222
|
-
data-block-id={block.id}
|
|
223
|
-
data-aspect={aspect}
|
|
224
|
-
>
|
|
225
|
-
<div className="smk-carousel__viewport">
|
|
226
|
-
{block.props.slides.map((slide, i) => (
|
|
227
|
-
<CustomSlide key={i}>
|
|
228
|
-
<div className="smk-carousel__image-frame">
|
|
229
|
-
{slide.imageUrl ? (
|
|
230
|
-
<img
|
|
231
|
-
src={slide.imageUrl}
|
|
232
|
-
alt=""
|
|
233
|
-
className="smk-carousel__image"
|
|
234
|
-
/>
|
|
235
|
-
) : (
|
|
236
|
-
<div className="smk-carousel__image smk-carousel__image--placeholder" />
|
|
237
|
-
)}
|
|
238
|
-
</div>
|
|
239
|
-
<div className="smk-carousel__caption-row">
|
|
240
|
-
<div className="smk-carousel__caption">{slide.caption}</div>
|
|
241
|
-
{slide.imageUrl ? (
|
|
242
|
-
<a
|
|
243
|
-
className="smk-carousel__download"
|
|
244
|
-
href={slide.imageUrl}
|
|
245
|
-
download
|
|
246
|
-
aria-label="Download image"
|
|
247
|
-
>
|
|
248
|
-
<svg
|
|
249
|
-
width="14"
|
|
250
|
-
height="14"
|
|
251
|
-
viewBox="0 0 24 24"
|
|
252
|
-
fill="none"
|
|
253
|
-
stroke="currentColor"
|
|
254
|
-
strokeWidth={2.4}
|
|
255
|
-
strokeLinecap="round"
|
|
256
|
-
strokeLinejoin="round"
|
|
257
|
-
aria-hidden
|
|
258
|
-
>
|
|
259
|
-
<path d="M12 5v14M5 12l7 7 7-7" />
|
|
260
|
-
</svg>
|
|
261
|
-
</a>
|
|
262
|
-
) : null}
|
|
263
|
-
</div>
|
|
264
|
-
</CustomSlide>
|
|
265
|
-
))}
|
|
266
|
-
</div>
|
|
267
|
-
<button
|
|
268
|
-
type="button"
|
|
269
|
-
className="smk-carousel__nav-prev"
|
|
270
|
-
aria-label="Previous slide"
|
|
271
|
-
>
|
|
272
|
-
<svg
|
|
273
|
-
width="16"
|
|
274
|
-
height="16"
|
|
275
|
-
viewBox="0 0 24 24"
|
|
276
|
-
fill="none"
|
|
277
|
-
stroke="currentColor"
|
|
278
|
-
strokeWidth={2.4}
|
|
279
|
-
strokeLinecap="round"
|
|
280
|
-
strokeLinejoin="round"
|
|
281
|
-
aria-hidden
|
|
282
|
-
>
|
|
283
|
-
<path d="M15 18l-6-6 6-6" />
|
|
284
|
-
</svg>
|
|
285
|
-
</button>
|
|
286
|
-
<button
|
|
287
|
-
type="button"
|
|
288
|
-
className="smk-carousel__nav-next"
|
|
289
|
-
aria-label="Next slide"
|
|
290
|
-
>
|
|
291
|
-
<svg
|
|
292
|
-
width="16"
|
|
293
|
-
height="16"
|
|
294
|
-
viewBox="0 0 24 24"
|
|
295
|
-
fill="none"
|
|
296
|
-
stroke="currentColor"
|
|
297
|
-
strokeWidth={2.4}
|
|
298
|
-
strokeLinecap="round"
|
|
299
|
-
strokeLinejoin="round"
|
|
300
|
-
aria-hidden
|
|
301
|
-
>
|
|
302
|
-
<path d="M9 6l6 6-6 6" />
|
|
303
|
-
</svg>
|
|
304
|
-
</button>
|
|
305
|
-
<div className="smk-carousel__dots" />
|
|
306
|
-
</CustomCarousel>
|
|
307
|
-
);
|
|
308
|
-
}
|
|
309
|
-
case "nav-recent-posts":
|
|
310
|
-
return renderNavList(block, "smk-nav-recent-posts");
|
|
311
|
-
case "nav-taxonomy-list":
|
|
312
|
-
return renderNavList(block, "smk-nav-taxonomy-list");
|
|
313
|
-
case "nav-search":
|
|
314
|
-
return renderNavSearch(block);
|
|
315
|
-
default:
|
|
316
|
-
// Forward-compat: unknown block component — emit empty marker
|
|
317
|
-
// so the page renders. Better than throwing.
|
|
318
|
-
return (
|
|
319
|
-
<section
|
|
320
|
-
key={(block as { id: string }).id}
|
|
321
|
-
className="smk-block smk-block--unknown"
|
|
322
|
-
data-block-component={(block as { component: string }).component}
|
|
85
|
+
// eslint-disable-next-line react/no-danger -- see trust note above.
|
|
86
|
+
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
|
323
87
|
/>
|
|
324
|
-
)
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// ── nav-* render helpers ─────────────────────────────────────────────
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Card-list render for `nav-recent-posts` + `nav-taxonomy-list`. Both
|
|
332
|
-
* blocks expose the same `{heading, viewAll, layout, snapshot}` shape;
|
|
333
|
-
* only the source of the snapshot differs (resolver-side concern, not
|
|
334
|
-
* SDK-side). The `layout` prop drives the wrapper's `data-layout` attr
|
|
335
|
-
* so customer CSS can style list / grid / carousel variants. Default
|
|
336
|
-
* styling ships sufficient base CSS for the list variant to be usable
|
|
337
|
-
* without customer overrides.
|
|
338
|
-
*
|
|
339
|
-
* Slugs in the snapshot are stored without leading slash (matching the
|
|
340
|
-
* `productContent.slug` DB column); we prepend one when forming hrefs.
|
|
341
|
-
* Customer's catch-all route at `/[...slug]` resolves them.
|
|
342
|
-
*/
|
|
343
|
-
function renderNavList(
|
|
344
|
-
block: Extract<
|
|
345
|
-
Block,
|
|
346
|
-
{ component: "nav-recent-posts" | "nav-taxonomy-list" }
|
|
347
|
-
>,
|
|
348
|
-
baseClass: string,
|
|
349
|
-
) {
|
|
350
|
-
const snapshot: NavSnapshotEntry[] = block.props.snapshot ?? [];
|
|
351
|
-
const layout = block.props.layout ?? "list";
|
|
352
|
-
const heading = (block.props as ModuleHeader).heading;
|
|
353
|
-
const viewAll = (block.props as ModuleHeader).viewAll;
|
|
354
|
-
|
|
355
|
-
return (
|
|
356
|
-
<section
|
|
357
|
-
key={block.id}
|
|
358
|
-
className={`smk-block smk-block--${block.component} smk-nav ${baseClass}`}
|
|
359
|
-
data-block-id={block.id}
|
|
360
|
-
data-block-component={block.component}
|
|
361
|
-
data-layout={layout}
|
|
362
|
-
>
|
|
363
|
-
{heading && <h2 className="smk-nav__heading">{heading}</h2>}
|
|
364
|
-
{snapshot.length === 0 ? (
|
|
365
|
-
<p className="smk-nav__empty">No posts yet.</p>
|
|
366
|
-
) : (
|
|
367
|
-
<ul className="smk-nav__list">
|
|
368
|
-
{snapshot.map((entry) => (
|
|
369
|
-
<li key={entry.slug} className="smk-nav__item">
|
|
370
|
-
<a href={`/${entry.slug}`} className="smk-nav__link">
|
|
371
|
-
{entry.featuredImageUrl && (
|
|
372
|
-
<img
|
|
373
|
-
src={entry.featuredImageUrl}
|
|
374
|
-
alt=""
|
|
375
|
-
className="smk-nav__thumb"
|
|
376
|
-
loading="lazy"
|
|
377
|
-
/>
|
|
378
|
-
)}
|
|
379
|
-
<div className="smk-nav__meta">
|
|
380
|
-
<h3 className="smk-nav__title">
|
|
381
|
-
{entry.title || entry.slug}
|
|
382
|
-
</h3>
|
|
383
|
-
{entry.excerpt && (
|
|
384
|
-
<p className="smk-nav__excerpt">{entry.excerpt}</p>
|
|
385
|
-
)}
|
|
386
|
-
{entry.publishedAt && (
|
|
387
|
-
<time
|
|
388
|
-
className="smk-nav__date"
|
|
389
|
-
dateTime={entry.publishedAt}
|
|
390
|
-
>
|
|
391
|
-
{formatPublishedAt(entry.publishedAt)}
|
|
392
|
-
</time>
|
|
393
|
-
)}
|
|
394
|
-
</div>
|
|
395
|
-
</a>
|
|
396
|
-
</li>
|
|
397
|
-
))}
|
|
398
|
-
</ul>
|
|
399
|
-
)}
|
|
400
|
-
{viewAll && (
|
|
401
|
-
<a className="smk-nav__view-all" href={viewAll.href}>
|
|
402
|
-
{viewAll.label}
|
|
403
|
-
</a>
|
|
404
|
-
)}
|
|
405
|
-
</section>
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* `nav-search` ships a server-rendered `<input>` + result `<ul>`, then
|
|
411
|
-
* an inline IIFE that wires keystroke → filter → list mutate. Zero
|
|
412
|
-
* client-component dependency on the customer side (no React hydration,
|
|
413
|
-
* no use client boundary). Index lives in DOM via `JSON.stringify` with
|
|
414
|
-
* `</` escape — prevents `</script>` smuggling.
|
|
415
|
-
*
|
|
416
|
-
* Match algorithm: case-insensitive substring against `title`, `excerpt`,
|
|
417
|
-
* and `slug`. Top 20 results shown. Customer can override the script by
|
|
418
|
-
* shipping their own `<SmkingSearch>` component if they want fancier
|
|
419
|
-
* matching (fuzzy, stemming, weighting).
|
|
420
|
-
*/
|
|
421
|
-
function renderNavSearch(
|
|
422
|
-
block: Extract<Block, { component: "nav-search" }>,
|
|
423
|
-
) {
|
|
424
|
-
const index: NavSearchIndexEntry[] = block.props.index ?? [];
|
|
425
|
-
const placeholder = block.props.placeholder || "Search…";
|
|
426
|
-
|
|
427
|
-
// Escape `</` so the JSON payload can't terminate the surrounding
|
|
428
|
-
// <script> tag — standard React-docs-recommended JSON-in-script
|
|
429
|
-
// hardening, since we serialize titles / excerpts that came from
|
|
430
|
-
// arbitrary author input.
|
|
431
|
-
const indexJson = JSON.stringify(index).replace(/</g, "\\u003c");
|
|
432
|
-
const scriptSrc =
|
|
433
|
-
"(function(){var s=document.currentScript;var r=s&&s.previousElementSibling;if(!r)return;var i=r.querySelector('[data-smk-search-input]');var u=r.querySelector('[data-smk-search-results]');var e=r.querySelector('[data-smk-search-empty]');var idx=" +
|
|
434
|
-
indexJson +
|
|
435
|
-
";function up(){var q=(i.value||'').trim().toLowerCase();u.textContent='';if(!q){u.hidden=true;e.hidden=true;return}var m=idx.filter(function(x){return((x.title||'')+' '+(x.excerpt||'')+' '+(x.slug||'')).toLowerCase().indexOf(q)>=0}).slice(0,20);if(m.length===0){u.hidden=true;e.hidden=false;return}e.hidden=true;u.hidden=false;m.forEach(function(x){var li=document.createElement('li');li.className='smk-nav-search__result';var a=document.createElement('a');a.href='/'+x.slug;a.textContent=x.title||x.slug;li.appendChild(a);u.appendChild(li)})}i.addEventListener('input',up)})();";
|
|
436
|
-
|
|
437
|
-
return (
|
|
438
|
-
<section
|
|
439
|
-
key={block.id}
|
|
440
|
-
className="smk-block smk-block--nav-search smk-nav-search"
|
|
441
|
-
data-block-id={block.id}
|
|
442
|
-
>
|
|
443
|
-
<div className="smk-nav-search__shell">
|
|
444
|
-
<input
|
|
445
|
-
type="search"
|
|
446
|
-
data-smk-search-input
|
|
447
|
-
placeholder={placeholder}
|
|
448
|
-
className="smk-nav-search__input"
|
|
449
|
-
/>
|
|
450
|
-
<ul
|
|
451
|
-
data-smk-search-results
|
|
452
|
-
hidden
|
|
453
|
-
className="smk-nav-search__list"
|
|
454
|
-
/>
|
|
455
|
-
<p
|
|
456
|
-
data-smk-search-empty
|
|
457
|
-
hidden
|
|
458
|
-
className="smk-nav-search__empty"
|
|
459
|
-
>
|
|
460
|
-
No matches.
|
|
461
|
-
</p>
|
|
462
|
-
</div>
|
|
463
|
-
{/* eslint-disable-next-line react/no-danger -- inline IIFE wires search filter; index is JSON-escaped against </script> smuggling. */}
|
|
464
|
-
<script dangerouslySetInnerHTML={{ __html: scriptSrc }} />
|
|
465
|
-
</section>
|
|
466
|
-
);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* Best-effort locale-naive date render. SDK doesn't ship a date library;
|
|
471
|
-
* customer can override via CSS / their own `<SmkingCms>` wrapper if
|
|
472
|
-
* they need a specific format / timezone.
|
|
473
|
-
*/
|
|
474
|
-
function formatPublishedAt(iso: string): string {
|
|
475
|
-
try {
|
|
476
|
-
return new Date(iso).toLocaleDateString();
|
|
477
|
-
} catch {
|
|
478
|
-
return iso.slice(0, 10);
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// ── Mode B (`tailwind-prose`) dispatcher ─────────────────────────────
|
|
483
|
-
//
|
|
484
|
-
// Mirror of `renderBlock` but emits Tailwind utility classes instead
|
|
485
|
-
// of `.smk-*` namespace. The outer `<article class="prose">` wrapper
|
|
486
|
-
// (set up in SmkingCms above) styles `article` blocks automatically;
|
|
487
|
-
// structured blocks (hero/nav/carousel/search) sit inside `not-prose`
|
|
488
|
-
// resets and re-apply Tailwind utility styling.
|
|
489
|
-
//
|
|
490
|
-
// Required Tailwind safelist (the wizard adds these on install):
|
|
491
|
-
// prose / prose-invert / lg:prose-xl / dark:prose-invert / not-prose
|
|
492
|
-
// mx-auto / my-* / mt-* / mb-* / px-* / py-* / pt-* / pb-* / p-*
|
|
493
|
-
// text-* / font-* / tracking-* / leading-*
|
|
494
|
-
// bg-* / border / border-* / rounded-*
|
|
495
|
-
// grid / flex / gap-* / items-* / justify-* / space-y-*
|
|
496
|
-
// w-* / h-* / aspect-*
|
|
497
|
-
function renderBlockProse(block: Block) {
|
|
498
|
-
switch (block.component) {
|
|
499
|
-
case "article": {
|
|
500
|
-
const articleHtml: { __html: string } = { __html: block.props.html };
|
|
501
|
-
// Article HTML inherits `prose` from the outer wrapper.
|
|
502
|
-
return (
|
|
503
|
-
<div
|
|
504
|
-
key={block.id}
|
|
505
|
-
data-block-id={block.id}
|
|
506
|
-
// eslint-disable-next-line react/no-danger -- same trust boundary as renderBlock article case.
|
|
507
|
-
dangerouslySetInnerHTML={articleHtml}
|
|
508
|
-
/>
|
|
509
|
-
);
|
|
510
|
-
}
|
|
511
|
-
case "hero":
|
|
512
|
-
return (
|
|
513
|
-
<section
|
|
514
|
-
key={block.id}
|
|
515
|
-
className="not-prose my-12 text-center bg-zinc-50 dark:bg-zinc-900 py-16 px-6 rounded-lg"
|
|
516
|
-
data-block-id={block.id}
|
|
517
|
-
>
|
|
518
|
-
{block.props.image && (
|
|
519
|
-
<img
|
|
520
|
-
src={block.props.image.url}
|
|
521
|
-
alt={block.props.image.alt}
|
|
522
|
-
className="mx-auto mb-8 rounded-lg max-w-full h-auto"
|
|
523
|
-
loading="lazy"
|
|
524
|
-
/>
|
|
525
|
-
)}
|
|
526
|
-
{block.props.title && (
|
|
527
|
-
<h2 className="text-4xl md:text-5xl font-serif font-extrabold tracking-tight leading-tight mb-4">
|
|
528
|
-
{block.props.title}
|
|
529
|
-
</h2>
|
|
530
|
-
)}
|
|
531
|
-
{block.props.subtitle && (
|
|
532
|
-
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-6">
|
|
533
|
-
{block.props.subtitle}
|
|
534
|
-
</p>
|
|
535
|
-
)}
|
|
536
|
-
{block.props.cta && (
|
|
537
|
-
<a
|
|
538
|
-
className="inline-block mt-8 px-6 py-3 bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 rounded font-medium"
|
|
539
|
-
href={block.props.cta.href}
|
|
540
|
-
>
|
|
541
|
-
{block.props.cta.label}
|
|
542
|
-
</a>
|
|
543
|
-
)}
|
|
544
|
-
</section>
|
|
545
|
-
);
|
|
546
|
-
case "nav-recent-posts":
|
|
547
|
-
case "nav-taxonomy-list":
|
|
548
|
-
return renderNavListProse(block);
|
|
549
|
-
case "nav-search":
|
|
550
|
-
return renderNavSearchProse(block);
|
|
551
|
-
case "carousel": {
|
|
552
|
-
const aspectClass = (() => {
|
|
553
|
-
switch (block.props.aspectRatio) {
|
|
554
|
-
case "4:3":
|
|
555
|
-
return "aspect-[4/3]";
|
|
556
|
-
case "1:1":
|
|
557
|
-
return "aspect-square";
|
|
558
|
-
case "3:4":
|
|
559
|
-
return "aspect-[3/4]";
|
|
560
|
-
case "9:16":
|
|
561
|
-
return "aspect-[9/16]";
|
|
562
|
-
default:
|
|
563
|
-
return "aspect-video";
|
|
564
|
-
}
|
|
565
|
-
})();
|
|
566
|
-
return (
|
|
567
|
-
<section
|
|
568
|
-
key={block.id}
|
|
569
|
-
className="not-prose my-8"
|
|
570
|
-
data-block-id={block.id}
|
|
571
|
-
>
|
|
572
|
-
<div className="grid grid-flow-col auto-cols-[80%] md:auto-cols-[60%] gap-4 overflow-x-auto snap-x snap-mandatory pb-2 -mx-6 px-6">
|
|
573
|
-
{block.props.slides.map((slide, i) => (
|
|
574
|
-
<div
|
|
575
|
-
key={i}
|
|
576
|
-
className="snap-start bg-zinc-100 dark:bg-zinc-900 rounded-lg overflow-hidden"
|
|
577
|
-
>
|
|
578
|
-
{slide.imageUrl && (
|
|
579
|
-
<img
|
|
580
|
-
src={slide.imageUrl}
|
|
581
|
-
alt=""
|
|
582
|
-
className={`w-full ${aspectClass} object-cover block`}
|
|
583
|
-
loading="lazy"
|
|
584
|
-
/>
|
|
585
|
-
)}
|
|
586
|
-
{slide.caption && (
|
|
587
|
-
<div className="px-4 py-3 text-sm text-zinc-500 dark:text-zinc-400">
|
|
588
|
-
{slide.caption}
|
|
589
|
-
</div>
|
|
590
|
-
)}
|
|
591
|
-
</div>
|
|
592
|
-
))}
|
|
593
|
-
</div>
|
|
594
|
-
</section>
|
|
595
|
-
);
|
|
596
|
-
}
|
|
597
|
-
default:
|
|
598
|
-
// Unknown component — render nothing in Mode B.
|
|
599
|
-
return null;
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
function renderNavListProse(
|
|
604
|
-
block: Extract<
|
|
605
|
-
Block,
|
|
606
|
-
{ component: "nav-recent-posts" | "nav-taxonomy-list" }
|
|
607
|
-
>,
|
|
608
|
-
) {
|
|
609
|
-
const snapshot: NavSnapshotEntry[] = block.props.snapshot ?? [];
|
|
610
|
-
const layout = block.props.layout ?? "list";
|
|
611
|
-
const heading = (block.props as ModuleHeader).heading;
|
|
612
|
-
const viewAll = (block.props as ModuleHeader).viewAll;
|
|
613
|
-
|
|
614
|
-
const listClass =
|
|
615
|
-
layout === "grid"
|
|
616
|
-
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 list-none p-0 m-0"
|
|
617
|
-
: layout === "carousel"
|
|
618
|
-
? "grid grid-flow-col auto-cols-[70%] md:auto-cols-[40%] gap-4 list-none p-0 m-0 overflow-x-auto snap-x snap-mandatory"
|
|
619
|
-
: "flex flex-col list-none p-0 m-0";
|
|
620
|
-
|
|
621
|
-
return (
|
|
622
|
-
<section
|
|
623
|
-
key={block.id}
|
|
624
|
-
className="not-prose my-10 border-t border-zinc-200 dark:border-zinc-800 pt-8"
|
|
625
|
-
data-block-id={block.id}
|
|
626
|
-
>
|
|
627
|
-
{heading && (
|
|
628
|
-
<h2 className="text-xs font-bold uppercase tracking-widest text-zinc-500 dark:text-zinc-400 mb-6">
|
|
629
|
-
{heading}
|
|
630
|
-
</h2>
|
|
631
|
-
)}
|
|
632
|
-
{snapshot.length === 0 ? (
|
|
633
|
-
<p className="text-zinc-400 italic py-4">No posts yet.</p>
|
|
634
|
-
) : (
|
|
635
|
-
<ul className={listClass}>
|
|
636
|
-
{snapshot.map((entry) => {
|
|
637
|
-
const itemClass =
|
|
638
|
-
layout === "grid"
|
|
639
|
-
? "group"
|
|
640
|
-
: layout === "carousel"
|
|
641
|
-
? "snap-start bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden"
|
|
642
|
-
: "border-t border-zinc-200 dark:border-zinc-800 first:border-t-0";
|
|
643
|
-
const linkClass =
|
|
644
|
-
layout === "grid"
|
|
645
|
-
? "flex flex-col bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden no-underline text-inherit hover:-translate-y-0.5 hover:shadow-lg transition"
|
|
646
|
-
: layout === "carousel"
|
|
647
|
-
? "flex flex-col no-underline text-inherit"
|
|
648
|
-
: "flex gap-5 items-center py-5 no-underline text-inherit hover:opacity-80 transition";
|
|
649
|
-
const thumbClass =
|
|
650
|
-
layout === "list"
|
|
651
|
-
? "w-24 h-24 object-cover rounded shrink-0 bg-zinc-100 dark:bg-zinc-900"
|
|
652
|
-
: "w-full aspect-[16/10] object-cover bg-zinc-100 dark:bg-zinc-900";
|
|
653
|
-
const metaClass = layout === "list" ? "flex-1 min-w-0" : "p-4";
|
|
654
|
-
return (
|
|
655
|
-
<li key={entry.slug} className={itemClass}>
|
|
656
|
-
<a href={`/${entry.slug}`} className={linkClass}>
|
|
657
|
-
{entry.featuredImageUrl && (
|
|
658
|
-
<img
|
|
659
|
-
src={entry.featuredImageUrl}
|
|
660
|
-
alt=""
|
|
661
|
-
className={thumbClass}
|
|
662
|
-
loading="lazy"
|
|
663
|
-
/>
|
|
664
|
-
)}
|
|
665
|
-
<div className={metaClass}>
|
|
666
|
-
<h3 className="text-lg font-serif font-bold leading-snug mb-2 tracking-tight">
|
|
667
|
-
{entry.title || entry.slug}
|
|
668
|
-
</h3>
|
|
669
|
-
{entry.excerpt && (
|
|
670
|
-
<p className="text-sm text-zinc-500 dark:text-zinc-400 line-clamp-2 leading-relaxed">
|
|
671
|
-
{entry.excerpt}
|
|
672
|
-
</p>
|
|
673
|
-
)}
|
|
674
|
-
{entry.publishedAt && (
|
|
675
|
-
<time
|
|
676
|
-
className="block text-xs text-zinc-400 mt-2 uppercase tracking-wider"
|
|
677
|
-
dateTime={entry.publishedAt}
|
|
678
|
-
>
|
|
679
|
-
{formatPublishedAt(entry.publishedAt)}
|
|
680
|
-
</time>
|
|
681
|
-
)}
|
|
682
|
-
</div>
|
|
683
|
-
</a>
|
|
684
|
-
</li>
|
|
685
|
-
);
|
|
686
|
-
})}
|
|
687
|
-
</ul>
|
|
688
|
-
)}
|
|
689
|
-
{viewAll && (
|
|
690
|
-
<a
|
|
691
|
-
className="inline-flex items-center gap-1 mt-6 text-sm font-semibold text-zinc-900 dark:text-zinc-100 no-underline hover:underline"
|
|
692
|
-
href={viewAll.href}
|
|
693
|
-
>
|
|
694
|
-
{viewAll.label} <span aria-hidden>→</span>
|
|
695
|
-
</a>
|
|
696
|
-
)}
|
|
697
|
-
</section>
|
|
698
|
-
);
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
function renderNavSearchProse(
|
|
702
|
-
block: Extract<Block, { component: "nav-search" }>,
|
|
703
|
-
) {
|
|
704
|
-
const index: NavSearchIndexEntry[] = block.props.index ?? [];
|
|
705
|
-
const placeholder = block.props.placeholder || "Search…";
|
|
706
|
-
const indexJson = JSON.stringify(index).replace(/</g, "\\u003c");
|
|
707
|
-
const scriptSrc =
|
|
708
|
-
"(function(){var s=document.currentScript;var r=s&&s.previousElementSibling;if(!r)return;var i=r.querySelector('[data-smk-search-input]');var u=r.querySelector('[data-smk-search-results]');var e=r.querySelector('[data-smk-search-empty]');var idx=" +
|
|
709
|
-
indexJson +
|
|
710
|
-
";function up(){var q=(i.value||'').trim().toLowerCase();u.textContent='';if(!q){u.hidden=true;e.hidden=true;return}var m=idx.filter(function(x){return((x.title||'')+' '+(x.excerpt||'')+' '+(x.slug||'')).toLowerCase().indexOf(q)>=0}).slice(0,20);if(m.length===0){u.hidden=true;e.hidden=false;return}e.hidden=true;u.hidden=false;m.forEach(function(x){var li=document.createElement('li');li.className='border-t border-zinc-200 dark:border-zinc-800 first:border-t-0';var a=document.createElement('a');a.href='/'+x.slug;a.className='block px-4 py-3 text-zinc-900 dark:text-zinc-100 no-underline hover:bg-zinc-50 dark:hover:bg-zinc-900';a.textContent=x.title||x.slug;li.appendChild(a);u.appendChild(li)})}i.addEventListener('input',up)})();";
|
|
711
|
-
|
|
712
|
-
return (
|
|
713
|
-
<section
|
|
714
|
-
key={block.id}
|
|
715
|
-
className="not-prose my-8 border-t border-zinc-200 dark:border-zinc-800 pt-6"
|
|
716
|
-
data-block-id={block.id}
|
|
717
|
-
>
|
|
718
|
-
<div className="relative">
|
|
719
|
-
<input
|
|
720
|
-
type="search"
|
|
721
|
-
data-smk-search-input
|
|
722
|
-
placeholder={placeholder}
|
|
723
|
-
className="w-full px-4 py-3 border border-zinc-200 dark:border-zinc-800 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 text-base focus:outline-none focus:border-zinc-900 dark:focus:border-zinc-100 focus:ring-4 focus:ring-zinc-900/10 dark:focus:ring-zinc-100/10 transition"
|
|
724
|
-
/>
|
|
725
|
-
<ul
|
|
726
|
-
data-smk-search-results
|
|
727
|
-
hidden
|
|
728
|
-
className="list-none p-0 mt-3 border border-zinc-200 dark:border-zinc-800 rounded-lg bg-white dark:bg-zinc-900 overflow-hidden shadow-lg"
|
|
729
|
-
/>
|
|
730
|
-
<p
|
|
731
|
-
data-smk-search-empty
|
|
732
|
-
hidden
|
|
733
|
-
className="text-zinc-400 pt-4 text-sm"
|
|
734
|
-
>
|
|
735
|
-
No matches.
|
|
736
|
-
</p>
|
|
737
|
-
</div>
|
|
738
|
-
{/* eslint-disable-next-line react/no-danger -- inline IIFE; index JSON-escaped against </script> smuggling. */}
|
|
739
|
-
<script dangerouslySetInnerHTML={{ __html: scriptSrc }} />
|
|
740
|
-
</section>
|
|
88
|
+
) : null}
|
|
89
|
+
</>
|
|
741
90
|
);
|
|
742
91
|
}
|
package/src/lib/cms-client.ts
CHANGED
|
@@ -61,10 +61,13 @@ export async function getCmsPage(
|
|
|
61
61
|
return null;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// Root page is `slug=""` (empty string), customer mounts it at e.g.
|
|
65
|
+
// `/blog`. Only reject `undefined` / `null` — empty string is valid
|
|
66
|
+
// and routes to the site's root page row in `productContent`.
|
|
67
|
+
if (params.slug === undefined || params.slug === null) {
|
|
65
68
|
warnOnce(
|
|
66
69
|
"missing-slug",
|
|
67
|
-
"[@soloworks/smking-next/cms] slug prop is required.",
|
|
70
|
+
"[@soloworks/smking-next/cms] slug prop is required (use \"\" for the root page).",
|
|
68
71
|
);
|
|
69
72
|
return null;
|
|
70
73
|
}
|
package/src/types.ts
CHANGED
|
@@ -58,24 +58,10 @@ export type CmsStatus = "ready" | "pending" | "not_found";
|
|
|
58
58
|
|
|
59
59
|
export type CmsContentType = "article" | "landing" | "listing";
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
* `<style data-smking="cms-styles">` once per render plus emits
|
|
66
|
-
* `.smk-*` class hooks on every block. Zero dependency on Tailwind.
|
|
67
|
-
* Customers theme via CSS variable overrides (`:root { --smk-accent: … }`).
|
|
68
|
-
*
|
|
69
|
-
* - `'tailwind-prose'` (Mode B) — SDK skips the inline stylesheet,
|
|
70
|
-
* wraps the article in `<article class="prose dark:prose-invert">`
|
|
71
|
-
* and emits Tailwind utility classes on each block. Customer must
|
|
72
|
-
* have `@tailwindcss/typography` plugin wired up and the required
|
|
73
|
-
* classes safelisted (the wizard does this on install).
|
|
74
|
-
*
|
|
75
|
-
* Resolution order: explicit `<SmkingCms mode>` prop → `SMKING_CMS_THEME`
|
|
76
|
-
* env var → 'css'.
|
|
77
|
-
*/
|
|
78
|
-
export type CmsThemeMode = "css" | "tailwind-prose";
|
|
61
|
+
// `CmsThemeMode` removed in v0.15.0 — Mode B (`tailwind-prose`) dropped
|
|
62
|
+
// in favour of the canonical Plate-serialized HTML render (Mode A only).
|
|
63
|
+
// Customers still wanting Tailwind Typography wrap can put a
|
|
64
|
+
// `<article class="prose">` around `<SmkingCms>` themselves.
|
|
79
65
|
|
|
80
66
|
/**
|
|
81
67
|
* v2 substrate block primitives. Mirrors `apps/web/src/features/cms/types.ts`
|
|
@@ -193,6 +179,14 @@ export interface CmsPage {
|
|
|
193
179
|
slug: string;
|
|
194
180
|
title: string;
|
|
195
181
|
contentType: CmsContentType;
|
|
182
|
+
/**
|
|
183
|
+
* v0.15+ canonical render — full Plate-serialized HTML for the
|
|
184
|
+
* published page. Customer SDK Mode A echoes this directly via
|
|
185
|
+
* raw-HTML escape hatch. Nullable when the page hasn't been
|
|
186
|
+
* republished since the SaaS publish-handler v0.16 upgrade
|
|
187
|
+
* (SDK falls back to `blocks` Block[] dispatch in that case).
|
|
188
|
+
*/
|
|
189
|
+
bodyHtml?: string | null;
|
|
196
190
|
blocks: Block[];
|
|
197
191
|
excerpt?: string | null;
|
|
198
192
|
featuredImageUrl?: string | null;
|