@soloworks/smking-next 0.15.0 → 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 CHANGED
@@ -1,5 +1,28 @@
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
+
3
26
  ## 0.15.0 — 2026-05-24
4
27
 
5
28
  **Customer Mode A now consumes Plate-serialized HTML directly — Preview tab = customer site, byte-identical.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soloworks/smking-next",
3
- "version": "0.15.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,13 +1,5 @@
1
1
  import { getCmsPage } from "../lib/cms-client";
2
- import { SMKING_CMS_STYLES } from "../lib/smking-cms-styles";
3
- import { SMKING_ELEMENTS_IIFE } from "../lib/smking-elements-iife";
4
- import type {
5
- Block,
6
- CmsParams,
7
- ModuleHeader,
8
- NavSearchIndexEntry,
9
- NavSnapshotEntry,
10
- } from "../types";
2
+ import type { CmsParams } from "../types";
11
3
 
12
4
  /**
13
5
  * Server Component that renders a published smking CMS page.
@@ -20,21 +12,22 @@ import type {
20
12
  * return (
21
13
  * <SmkingCms
22
14
  * apiKey={process.env.SMKING_API_KEY!}
23
- * slug="hello"
15
+ * slug="" // root → /blog landing
24
16
  * />
25
17
  * );
26
18
  * }
27
19
  * ```
28
20
  *
29
- * v0.11+ substrate pivot: fetches a Block[] array and dispatches each
30
- * block by `component`. Article block carries pre-rendered HTML
31
- * (server-rendered at write time via Plate's `serializeHtml`, so the
32
- * customer SDK needs zero editor dependencies). Hero / carousel render
33
- * inline. Dynamic nav-* blocks (`nav-recent-posts` /
34
- * `nav-taxonomy-list` / `nav-search`) carry a publish-time materialized
35
- * `snapshot` (or `index`) embedded in their props — SDK echoes that
36
- * directly, zero round-trip back to smking. If smking is down, the
37
- * customer's page still renders the last published snapshot.
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.
38
31
  *
39
32
  * Returns null when the response isn't ready (pending / not_found /
40
33
  * unreachable / mis-configured) — fail-open by design.
@@ -46,40 +39,14 @@ export async function SmkingCms(props: CmsParams) {
46
39
  const data = await getCmsPage(props);
47
40
  if (!data || data.status !== "ready" || !data.page) return null;
48
41
 
49
- // v0.11+ — emit SEO head tags inline. React 19 hoists `<title>` and
50
- // `<meta>` tags found anywhere in the tree into `<head>` automatically
51
- // (last write wins on duplicate tags), so a deeper layout / page that
52
- // 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).
53
45
  const seo = data.seo;
54
- const blocks = data.page.blocks ?? [];
55
- const bodyHtml = data.page.bodyHtml ?? null;
56
- // Emit the Web Component registration `<script>` once when the page
57
- // contains a `<smking-carousel>`. With bodyHtml we'd have to parse
58
- // the HTML to detect; conservatively check blocks (still emitted for
59
- // back-compat) or fall back to always emitting when bodyHtml is
60
- // present (cheap — the IIFE is small + self-guarded).
61
- const needsSmkingElements =
62
- blocks.some((b) => b.component === "carousel") ||
63
- (bodyHtml !== null && /<smking-carousel/i.test(bodyHtml));
64
- // IIFE / CSS sources are hand-authored package constants (not user
65
- // data) — passing them through __html here is the standard React
66
- // server-side inline-script / inline-style emit pattern.
67
- const elementsScript: { __html: string } = { __html: SMKING_ELEMENTS_IIFE };
68
- const cmsStylesPayload: { __html: string } = { __html: SMKING_CMS_STYLES };
46
+ const bodyHtml = data.page.bodyHtml ?? "";
47
+
69
48
  return (
70
49
  <>
71
- <style
72
- data-smking="cms-styles"
73
- // eslint-disable-next-line react/no-danger -- CSS source is a package constant generated from packages/shared/src/styles/cms-styles.css.
74
- dangerouslySetInnerHTML={cmsStylesPayload}
75
- />
76
- {needsSmkingElements && (
77
- <script
78
- data-smking="elements"
79
- // eslint-disable-next-line react/no-danger -- IIFE source is a package constant.
80
- dangerouslySetInnerHTML={elementsScript}
81
- />
82
- )}
83
50
  {seo?.title && <title data-smking="cms">{seo.title}</title>}
84
51
  {seo?.metaDescription && (
85
52
  <meta
@@ -105,362 +72,20 @@ export async function SmkingCms(props: CmsParams) {
105
72
  <link rel="canonical" href={seo.canonicalUrl} data-smking="cms" />
106
73
  )}
107
74
 
108
- {bodyHtml !== null ? (
109
- // v0.15+ canonical path — SaaS publish handler ran Plate's
110
- // serializeHtml on the entire page (with nav-* snapshots
111
- // injected) and ships the result. SDK echoes it inside the
112
- // canonical `<article class="smk-cms">` wrapper. Trust boundary:
113
- // HTML emitted server-side by Plate from the schema the SaaS
114
- // controls — same trust boundary as the legacy article block
115
- // path.
116
- <article
117
- 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
118
83
  data-smking="cms"
119
84
  data-content-type={data.page.contentType}
120
- // eslint-disable-next-line react/no-danger -- bodyHtml is SaaS-emitted, schema-bounded Plate output. See trust note above.
85
+ // eslint-disable-next-line react/no-danger -- see trust note above.
121
86
  dangerouslySetInnerHTML={{ __html: bodyHtml }}
122
87
  />
123
- ) : (
124
- // Legacy fallback — SaaS hasn't republished the page since the
125
- // v0.16 upgrade, so bodyHtml is null. Fall back to the per-block
126
- // dispatch path so customer pages keep rendering.
127
- <article
128
- className="smk-cms"
129
- data-smking="cms"
130
- data-content-type={data.page.contentType}
131
- >
132
- {data.page.title && (
133
- <h1 className="smk-cms__title">{data.page.title}</h1>
134
- )}
135
- {blocks.map((block) => renderBlock(block))}
136
- </article>
137
- )}
88
+ ) : null}
138
89
  </>
139
90
  );
140
91
  }
141
-
142
- /**
143
- * Dispatch a single block to its rendered React element.
144
- *
145
- * Trust boundary for `article` blocks: HTML is rendered server-side at
146
- * write time by `@tiptap/html/server` inside the SaaS (Tiptap's renderer
147
- * only emits HTML for known nodes per the configured schema, so author
148
- * input can't smuggle arbitrary tags through). Customer site renders
149
- * that HTML as-is via React's raw-HTML escape hatch. If customer wants
150
- * defence-in-depth beyond the SaaS sanitisation tier, layer CSP on
151
- * their host page (smking SDK does not ship a customer-side sanitiser
152
- * to keep zero-dep customer install).
153
- */
154
- function renderBlock(block: Block) {
155
- switch (block.component) {
156
- case "article": {
157
- const articleHtml: { __html: string } = { __html: block.props.html };
158
- return (
159
- <div
160
- key={block.id}
161
- className="smk-block smk-block--article"
162
- data-block-id={block.id}
163
- // 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.
164
- dangerouslySetInnerHTML={articleHtml}
165
- />
166
- );
167
- }
168
- case "hero":
169
- return (
170
- <section
171
- key={block.id}
172
- className="smk-block smk-block--hero smk-hero"
173
- data-block-id={block.id}
174
- >
175
- {block.props.image && (
176
- <img
177
- src={block.props.image.url}
178
- alt={block.props.image.alt}
179
- className="smk-hero__image"
180
- />
181
- )}
182
- <h2 className="smk-hero__title">{block.props.title}</h2>
183
- {block.props.subtitle && (
184
- <p className="smk-hero__subtitle">{block.props.subtitle}</p>
185
- )}
186
- {block.props.cta && (
187
- <a className="smk-hero__cta" href={block.props.cta.href}>
188
- {block.props.cta.label}
189
- </a>
190
- )}
191
- </section>
192
- );
193
- case "carousel": {
194
- // Canonical `<smking-carousel>` markup — the Web Component
195
- // registered by `SMKING_ELEMENTS_IIFE` upgrades this into a
196
- // swipe + nav + dots carousel on the client. Custom-element
197
- // tags aren't in React's JSX intrinsic types by default; cast
198
- // through a string ref so we don't need a global d.ts in the
199
- // customer's package.
200
- const CustomCarousel = "smking-carousel" as unknown as "section";
201
- const CustomSlide = "smking-slide" as unknown as "div";
202
- const aspect = block.props.aspectRatio ?? "16:9";
203
- return (
204
- <CustomCarousel
205
- key={block.id}
206
- className="smk-block smk-block--carousel smk-carousel"
207
- data-block-id={block.id}
208
- data-aspect={aspect}
209
- >
210
- <div className="smk-carousel__viewport">
211
- {block.props.slides.map((slide, i) => (
212
- <CustomSlide key={i}>
213
- <div className="smk-carousel__image-frame">
214
- {slide.imageUrl ? (
215
- <img
216
- src={slide.imageUrl}
217
- alt=""
218
- className="smk-carousel__image"
219
- />
220
- ) : (
221
- <div className="smk-carousel__image smk-carousel__image--placeholder" />
222
- )}
223
- </div>
224
- <div className="smk-carousel__caption-row">
225
- <div className="smk-carousel__caption">{slide.caption}</div>
226
- {slide.imageUrl ? (
227
- <a
228
- className="smk-carousel__download"
229
- href={slide.imageUrl}
230
- download
231
- aria-label="Download image"
232
- >
233
- <svg
234
- width="14"
235
- height="14"
236
- viewBox="0 0 24 24"
237
- fill="none"
238
- stroke="currentColor"
239
- strokeWidth={2.4}
240
- strokeLinecap="round"
241
- strokeLinejoin="round"
242
- aria-hidden
243
- >
244
- <path d="M12 5v14M5 12l7 7 7-7" />
245
- </svg>
246
- </a>
247
- ) : null}
248
- </div>
249
- </CustomSlide>
250
- ))}
251
- </div>
252
- <button
253
- type="button"
254
- className="smk-carousel__nav-prev"
255
- aria-label="Previous slide"
256
- >
257
- <svg
258
- width="16"
259
- height="16"
260
- viewBox="0 0 24 24"
261
- fill="none"
262
- stroke="currentColor"
263
- strokeWidth={2.4}
264
- strokeLinecap="round"
265
- strokeLinejoin="round"
266
- aria-hidden
267
- >
268
- <path d="M15 18l-6-6 6-6" />
269
- </svg>
270
- </button>
271
- <button
272
- type="button"
273
- className="smk-carousel__nav-next"
274
- aria-label="Next slide"
275
- >
276
- <svg
277
- width="16"
278
- height="16"
279
- viewBox="0 0 24 24"
280
- fill="none"
281
- stroke="currentColor"
282
- strokeWidth={2.4}
283
- strokeLinecap="round"
284
- strokeLinejoin="round"
285
- aria-hidden
286
- >
287
- <path d="M9 6l6 6-6 6" />
288
- </svg>
289
- </button>
290
- <div className="smk-carousel__dots" />
291
- </CustomCarousel>
292
- );
293
- }
294
- case "nav-recent-posts":
295
- return renderNavList(block, "smk-nav-recent-posts");
296
- case "nav-taxonomy-list":
297
- return renderNavList(block, "smk-nav-taxonomy-list");
298
- case "nav-search":
299
- return renderNavSearch(block);
300
- default:
301
- // Forward-compat: unknown block component — emit empty marker
302
- // so the page renders. Better than throwing.
303
- return (
304
- <section
305
- key={(block as { id: string }).id}
306
- className="smk-block smk-block--unknown"
307
- data-block-component={(block as { component: string }).component}
308
- />
309
- );
310
- }
311
- }
312
-
313
- // ── nav-* render helpers ─────────────────────────────────────────────
314
-
315
- /**
316
- * Card-list render for `nav-recent-posts` + `nav-taxonomy-list`. Both
317
- * blocks expose the same `{heading, viewAll, layout, snapshot}` shape;
318
- * only the source of the snapshot differs (resolver-side concern, not
319
- * SDK-side). The `layout` prop drives the wrapper's `data-layout` attr
320
- * so customer CSS can style list / grid / carousel variants. Default
321
- * styling ships sufficient base CSS for the list variant to be usable
322
- * without customer overrides.
323
- *
324
- * Slugs in the snapshot are stored without leading slash (matching the
325
- * `productContent.slug` DB column); we prepend one when forming hrefs.
326
- * Customer's catch-all route at `/[...slug]` resolves them.
327
- */
328
- function renderNavList(
329
- block: Extract<
330
- Block,
331
- { component: "nav-recent-posts" | "nav-taxonomy-list" }
332
- >,
333
- baseClass: string,
334
- ) {
335
- const snapshot: NavSnapshotEntry[] = block.props.snapshot ?? [];
336
- const layout = block.props.layout ?? "list";
337
- const heading = (block.props as ModuleHeader).heading;
338
- const viewAll = (block.props as ModuleHeader).viewAll;
339
-
340
- return (
341
- <section
342
- key={block.id}
343
- className={`smk-block smk-block--${block.component} smk-nav ${baseClass}`}
344
- data-block-id={block.id}
345
- data-block-component={block.component}
346
- data-layout={layout}
347
- >
348
- {heading && <h2 className="smk-nav__heading">{heading}</h2>}
349
- {snapshot.length === 0 ? (
350
- <p className="smk-nav__empty">No posts yet.</p>
351
- ) : (
352
- <ul className="smk-nav__list">
353
- {snapshot.map((entry) => (
354
- <li key={entry.slug} className="smk-nav__item">
355
- <a href={`/${entry.slug}`} className="smk-nav__link">
356
- {entry.featuredImageUrl && (
357
- <img
358
- src={entry.featuredImageUrl}
359
- alt=""
360
- className="smk-nav__thumb"
361
- loading="lazy"
362
- />
363
- )}
364
- <div className="smk-nav__meta">
365
- <h3 className="smk-nav__title">
366
- {entry.title || entry.slug}
367
- </h3>
368
- {entry.excerpt && (
369
- <p className="smk-nav__excerpt">{entry.excerpt}</p>
370
- )}
371
- {entry.publishedAt && (
372
- <time
373
- className="smk-nav__date"
374
- dateTime={entry.publishedAt}
375
- >
376
- {formatPublishedAt(entry.publishedAt)}
377
- </time>
378
- )}
379
- </div>
380
- </a>
381
- </li>
382
- ))}
383
- </ul>
384
- )}
385
- {viewAll && (
386
- <a className="smk-nav__view-all" href={viewAll.href}>
387
- {viewAll.label}
388
- </a>
389
- )}
390
- </section>
391
- );
392
- }
393
-
394
- /**
395
- * `nav-search` ships a server-rendered `<input>` + result `<ul>`, then
396
- * an inline IIFE that wires keystroke → filter → list mutate. Zero
397
- * client-component dependency on the customer side (no React hydration,
398
- * no use client boundary). Index lives in DOM via `JSON.stringify` with
399
- * `</` escape — prevents `</script>` smuggling.
400
- *
401
- * Match algorithm: case-insensitive substring against `title`, `excerpt`,
402
- * and `slug`. Top 20 results shown. Customer can override the script by
403
- * shipping their own `<SmkingSearch>` component if they want fancier
404
- * matching (fuzzy, stemming, weighting).
405
- */
406
- function renderNavSearch(
407
- block: Extract<Block, { component: "nav-search" }>,
408
- ) {
409
- const index: NavSearchIndexEntry[] = block.props.index ?? [];
410
- const placeholder = block.props.placeholder || "Search…";
411
-
412
- // Escape `</` so the JSON payload can't terminate the surrounding
413
- // <script> tag — standard React-docs-recommended JSON-in-script
414
- // hardening, since we serialize titles / excerpts that came from
415
- // arbitrary author input.
416
- const indexJson = JSON.stringify(index).replace(/</g, "\\u003c");
417
- const scriptSrc =
418
- "(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=" +
419
- indexJson +
420
- ";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)})();";
421
-
422
- return (
423
- <section
424
- key={block.id}
425
- className="smk-block smk-block--nav-search smk-nav-search"
426
- data-block-id={block.id}
427
- >
428
- <div className="smk-nav-search__shell">
429
- <input
430
- type="search"
431
- data-smk-search-input
432
- placeholder={placeholder}
433
- className="smk-nav-search__input"
434
- />
435
- <ul
436
- data-smk-search-results
437
- hidden
438
- className="smk-nav-search__list"
439
- />
440
- <p
441
- data-smk-search-empty
442
- hidden
443
- className="smk-nav-search__empty"
444
- >
445
- No matches.
446
- </p>
447
- </div>
448
- {/* eslint-disable-next-line react/no-danger -- inline IIFE wires search filter; index is JSON-escaped against </script> smuggling. */}
449
- <script dangerouslySetInnerHTML={{ __html: scriptSrc }} />
450
- </section>
451
- );
452
- }
453
-
454
- /**
455
- * Best-effort locale-naive date render. SDK doesn't ship a date library;
456
- * customer can override via CSS / their own `<SmkingCms>` wrapper if
457
- * they need a specific format / timezone.
458
- */
459
- function formatPublishedAt(iso: string): string {
460
- try {
461
- return new Date(iso).toLocaleDateString();
462
- } catch {
463
- return iso.slice(0, 10);
464
- }
465
- }
466
-
@@ -61,10 +61,13 @@ export async function getCmsPage(
61
61
  return null;
62
62
  }
63
63
 
64
- if (!params.slug) {
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
  }