@soloworks/smking-next 0.14.1 → 0.15.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,49 @@
1
1
  # @soloworks/smking-next
2
2
 
3
+ ## 0.15.0 — 2026-05-24
4
+
5
+ **Customer Mode A now consumes Plate-serialized HTML directly — Preview tab = customer site, byte-identical.**
6
+
7
+ ### Architecture flip
8
+
9
+ 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).
10
+
11
+ ### Removed (breaking)
12
+
13
+ - **`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.
14
+ - **`renderBlockProse` / `renderNavListProse` / `renderNavSearchProse`** helper functions — internal, was the Mode B dispatcher.
15
+ - **`SMKING_CMS_THEME` env var** — ignored; was Mode B selection signal.
16
+
17
+ ### Changed
18
+
19
+ - `<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.
20
+ - 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.
21
+
22
+ ### Pairs with
23
+
24
+ - `smking/laravel` v0.16.0 — same Mode B removal + bodyHtml echo path on the Laravel side.
25
+ - `@soloworks/smking-wizard` v0.5.0 — theme-mode-select TUI screen removed alongside the Tailwind plugin install path.
26
+
27
+ ### Customer migration
28
+
29
+ ```bash
30
+ pnpm update @soloworks/smking-next # → 0.15.0
31
+ ```
32
+
33
+ If you were using Mode B (`SMKING_CMS_THEME=tailwind-prose`):
34
+
35
+ ```tsx
36
+ // Before (v0.14.x)
37
+ <SmkingCms apiKey={...} slug="..." mode="tailwind-prose" />
38
+
39
+ // After (v0.15.0) — wrap in your own .prose for typography plugin
40
+ <article className="prose dark:prose-invert mx-auto">
41
+ <SmkingCms apiKey={...} slug="..." />
42
+ </article>
43
+ ```
44
+
45
+ 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.
46
+
3
47
  ## 0.14.1 — 2026-05-23
4
48
 
5
49
  **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.14.1",
3
+ "version": "0.15.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",
@@ -4,28 +4,11 @@ import { SMKING_ELEMENTS_IIFE } from "../lib/smking-elements-iife";
4
4
  import type {
5
5
  Block,
6
6
  CmsParams,
7
- CmsThemeMode,
8
7
  ModuleHeader,
9
8
  NavSearchIndexEntry,
10
9
  NavSnapshotEntry,
11
10
  } from "../types";
12
11
 
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
- }
28
-
29
12
  /**
30
13
  * Server Component that renders a published smking CMS page.
31
14
  *
@@ -59,7 +42,7 @@ function resolveThemeMode(modeProp?: CmsThemeMode): CmsThemeMode {
59
42
  * Emits `<title>` / `<meta>` / `og:*` / canonical inline; React 19
60
43
  * hoists them into `<head>` automatically.
61
44
  */
62
- export async function SmkingCms(props: CmsParams & { mode?: CmsThemeMode }) {
45
+ export async function SmkingCms(props: CmsParams) {
63
46
  const data = await getCmsPage(props);
64
47
  if (!data || data.status !== "ready" || !data.page) return null;
65
48
 
@@ -69,34 +52,27 @@ export async function SmkingCms(props: CmsParams & { mode?: CmsThemeMode }) {
69
52
  // also sets these still overrides ours where present.
70
53
  const seo = data.seo;
71
54
  const blocks = data.page.blocks ?? [];
72
- const mode = resolveThemeMode(props.mode);
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");
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));
82
64
  // IIFE / CSS sources are hand-authored package constants (not user
83
65
  // data) — passing them through __html here is the standard React
84
66
  // server-side inline-script / inline-style emit pattern.
85
67
  const elementsScript: { __html: string } = { __html: SMKING_ELEMENTS_IIFE };
86
68
  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";
91
69
  return (
92
70
  <>
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
- )}
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
+ />
100
76
  {needsSmkingElements && (
101
77
  <script
102
78
  data-smking="elements"
@@ -129,16 +105,25 @@ export async function SmkingCms(props: CmsParams & { mode?: CmsThemeMode }) {
129
105
  <link rel="canonical" href={seo.canonicalUrl} data-smking="cms" />
130
106
  )}
131
107
 
132
- {mode === "tailwind-prose" ? (
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.
133
116
  <article
134
- className="prose lg:prose-xl dark:prose-invert mx-auto"
117
+ className="smk-cms"
135
118
  data-smking="cms"
136
119
  data-content-type={data.page.contentType}
137
- >
138
- {data.page.title && <h1>{data.page.title}</h1>}
139
- {blocks.map((block) => renderBlockProse(block))}
140
- </article>
120
+ // eslint-disable-next-line react/no-danger -- bodyHtml is SaaS-emitted, schema-bounded Plate output. See trust note above.
121
+ dangerouslySetInnerHTML={{ __html: bodyHtml }}
122
+ />
141
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.
142
127
  <article
143
128
  className="smk-cms"
144
129
  data-smking="cms"
@@ -479,264 +464,3 @@ function formatPublishedAt(iso: string): string {
479
464
  }
480
465
  }
481
466
 
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>
741
- );
742
- }
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
- * Theme mode (v0.14+) — decides how `<SmkingCms>` ships styling.
63
- *
64
- * - `'css'` (Mode A, default) — SDK inlines the canonical
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;