@soloworks/smking-next 0.12.2 → 0.13.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,25 @@
1
1
  # @soloworks/smking-next
2
2
 
3
+ ## 0.13.0 — 2026-05-22
4
+
5
+ **Dynamic CMS blocks — `nav-recent-posts`, `nav-taxonomy-list`, `nav-search` now render with real publish-time data.**
6
+
7
+ Customer pages that include any nav-* block on the smking dashboard editor now receive the resolved snapshot baked into `block.props.snapshot` (or `block.props.index` for search). The SDK echoes it directly — zero round-trips back to smking at request time. If smking is down, the customer's page still renders the last published snapshot.
8
+
9
+ ### Added
10
+
11
+ - **Server-side render of `nav-recent-posts` / `nav-taxonomy-list`** — `<SmkingCms>` now emits a real `<ul>` of cards (slug-linked title + excerpt + featured image + published date) instead of the prior `smk-nav-pending` placeholder. The wrapper carries `data-layout="list|grid|carousel"` so customer CSS can theme each variant.
12
+ - **Server-side render of `nav-search`** — emits an `<input type="search">` plus a results `<ul>`, wired by an inline IIFE that does case-insensitive substring filter against the publish-time index (top 20 matches). Zero React client-component dependency on the customer side; the IIFE is `</`-escaped against `</script>` smuggling.
13
+ - **`NavSnapshotEntry` + `NavSearchIndexEntry` types** added to the package's public type surface. Each nav block's `props` now exposes `snapshot?` (list/taxonomy) or `index?` (search) so customer code that introspects blocks gets full TS support.
14
+
15
+ ### Why this is a minor (0.13.0) not a patch
16
+
17
+ Customer pages that previously rendered the empty `smk-nav-pending` placeholder will now show real content. If a customer's stylesheet relied on the placeholder being invisible / empty, this is a visible behavioural change — bumped to minor per semver hygiene. No API surface removals.
18
+
19
+ ### Pairs with
20
+
21
+ - smking SaaS atomic publish — `publish-handler.ts` now resolves nav-* snapshots via `materializeBlocks` and cascade-refreshes sibling pages so a freshly published article appears in every other page's `nav-recent-posts` feed within the same webhook tick.
22
+
3
23
  ## 0.12.2 — 2026-05-21
4
24
 
5
25
  **Fix: `doctor` now auto-loads `.env.local` + `.env` matching Next.js convention.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soloworks/smking-next",
3
- "version": "0.12.2",
3
+ "version": "0.13.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,5 +1,12 @@
1
1
  import { getCmsPage } from "../lib/cms-client";
2
- import type { Block, CmsParams } from "../types";
2
+ import { SMKING_ELEMENTS_IIFE } from "../lib/smking-elements-iife";
3
+ import type {
4
+ Block,
5
+ CmsParams,
6
+ ModuleHeader,
7
+ NavSearchIndexEntry,
8
+ NavSnapshotEntry,
9
+ } from "../types";
3
10
 
4
11
  /**
5
12
  * Server Component that renders a published smking CMS page.
@@ -20,11 +27,13 @@ import type { Block, CmsParams } from "../types";
20
27
  *
21
28
  * v0.11+ substrate pivot: fetches a Block[] array and dispatches each
22
29
  * block by `component`. Article block carries pre-rendered HTML
23
- * (server-rendered at write time via tiptap-html, so the customer SDK
24
- * needs zero Tiptap dependencies). Hero / nav-* blocks render inline;
25
- * full server-side data fetch for nav-recent-posts / nav-taxonomy-list
26
- * / nav-search arrives in M2+ — they emit pending markers for now so
27
- * customer pages don't 500 if the page contains them.
30
+ * (server-rendered at write time via Plate's `serializeHtml`, so the
31
+ * customer SDK needs zero editor dependencies). Hero / carousel render
32
+ * inline. Dynamic nav-* blocks (`nav-recent-posts` /
33
+ * `nav-taxonomy-list` / `nav-search`) carry a publish-time materialized
34
+ * `snapshot` (or `index`) embedded in their props — SDK echoes that
35
+ * directly, zero round-trip back to smking. If smking is down, the
36
+ * customer's page still renders the last published snapshot.
28
37
  *
29
38
  * Returns null when the response isn't ready (pending / not_found /
30
39
  * unreachable / mis-configured) — fail-open by design.
@@ -42,8 +51,28 @@ export async function SmkingCms(props: CmsParams) {
42
51
  // also sets these still overrides ours where present.
43
52
  const seo = data.seo;
44
53
  const blocks = data.page.blocks ?? [];
54
+ // Emit the Web Component registration `<script>` once if any block
55
+ // needs `<smking-carousel>` / `<smking-slide>`. The IIFE is
56
+ // self-guarded so duplicate emits are harmless; gating here keeps
57
+ // zero-dep pages truly zero-byte.
58
+ // Web Component IIFE is needed only when the page actually contains
59
+ // a `<smking-carousel>`. nav-search emits its own self-contained
60
+ // inline IIFE (no Web Component registration needed) so we don't gate
61
+ // on it here.
62
+ const needsSmkingElements = blocks.some((b) => b.component === "carousel");
63
+ // IIFE source is a hand-authored package constant (not user data) —
64
+ // wrapping in __html here is the standard React server-side script
65
+ // emit pattern used elsewhere in this SDK for the article block.
66
+ const elementsScript: { __html: string } = { __html: SMKING_ELEMENTS_IIFE };
45
67
  return (
46
68
  <>
69
+ {needsSmkingElements && (
70
+ <script
71
+ data-smking="elements"
72
+ // eslint-disable-next-line react/no-danger -- IIFE source is a package constant.
73
+ dangerouslySetInnerHTML={elementsScript}
74
+ />
75
+ )}
47
76
  {seo?.title && <title data-smking="cms">{seo.title}</title>}
48
77
  {seo?.metaDescription && (
49
78
  <meta
@@ -134,21 +163,113 @@ function renderBlock(block: Block) {
134
163
  )}
135
164
  </section>
136
165
  );
137
- case "nav-recent-posts":
138
- case "nav-taxonomy-list":
139
- case "nav-search":
140
- // Server-side data fetch + render lands in M2+ alongside the
141
- // public posts / taxonomy / search endpoints. Emit a marker so
142
- // customer pages don't 500 if an editor publishes a page with
143
- // one of these blocks before M2 ships.
166
+ case "carousel": {
167
+ // Canonical `<smking-carousel>` markup — the Web Component
168
+ // registered by `SMKING_ELEMENTS_IIFE` upgrades this into a
169
+ // swipe + nav + dots carousel on the client. Custom-element
170
+ // tags aren't in React's JSX intrinsic types by default; cast
171
+ // through a string ref so we don't need a global d.ts in the
172
+ // customer's package.
173
+ const CustomCarousel = "smking-carousel" as unknown as "section";
174
+ const CustomSlide = "smking-slide" as unknown as "div";
175
+ const aspect = block.props.aspectRatio ?? "16:9";
144
176
  return (
145
- <section
177
+ <CustomCarousel
146
178
  key={block.id}
147
- className={`smk-block smk-block--${block.component} smk-nav-pending`}
179
+ className="smk-block smk-block--carousel smk-carousel"
148
180
  data-block-id={block.id}
149
- data-block-component={block.component}
150
- />
181
+ data-aspect={aspect}
182
+ >
183
+ <div className="smk-carousel__viewport">
184
+ {block.props.slides.map((slide, i) => (
185
+ <CustomSlide key={i}>
186
+ <div className="smk-carousel__image-frame">
187
+ {slide.imageUrl ? (
188
+ <img
189
+ src={slide.imageUrl}
190
+ alt=""
191
+ className="smk-carousel__image"
192
+ />
193
+ ) : (
194
+ <div className="smk-carousel__image smk-carousel__image--placeholder" />
195
+ )}
196
+ </div>
197
+ <div className="smk-carousel__caption-row">
198
+ <div className="smk-carousel__caption">{slide.caption}</div>
199
+ {slide.imageUrl ? (
200
+ <a
201
+ className="smk-carousel__download"
202
+ href={slide.imageUrl}
203
+ download
204
+ aria-label="Download image"
205
+ >
206
+ <svg
207
+ width="14"
208
+ height="14"
209
+ viewBox="0 0 24 24"
210
+ fill="none"
211
+ stroke="currentColor"
212
+ strokeWidth={2.4}
213
+ strokeLinecap="round"
214
+ strokeLinejoin="round"
215
+ aria-hidden
216
+ >
217
+ <path d="M12 5v14M5 12l7 7 7-7" />
218
+ </svg>
219
+ </a>
220
+ ) : null}
221
+ </div>
222
+ </CustomSlide>
223
+ ))}
224
+ </div>
225
+ <button
226
+ type="button"
227
+ className="smk-carousel__nav-prev"
228
+ aria-label="Previous slide"
229
+ >
230
+ <svg
231
+ width="16"
232
+ height="16"
233
+ viewBox="0 0 24 24"
234
+ fill="none"
235
+ stroke="currentColor"
236
+ strokeWidth={2.4}
237
+ strokeLinecap="round"
238
+ strokeLinejoin="round"
239
+ aria-hidden
240
+ >
241
+ <path d="M15 18l-6-6 6-6" />
242
+ </svg>
243
+ </button>
244
+ <button
245
+ type="button"
246
+ className="smk-carousel__nav-next"
247
+ aria-label="Next slide"
248
+ >
249
+ <svg
250
+ width="16"
251
+ height="16"
252
+ viewBox="0 0 24 24"
253
+ fill="none"
254
+ stroke="currentColor"
255
+ strokeWidth={2.4}
256
+ strokeLinecap="round"
257
+ strokeLinejoin="round"
258
+ aria-hidden
259
+ >
260
+ <path d="M9 6l6 6-6 6" />
261
+ </svg>
262
+ </button>
263
+ <div className="smk-carousel__dots" />
264
+ </CustomCarousel>
151
265
  );
266
+ }
267
+ case "nav-recent-posts":
268
+ return renderNavList(block, "smk-nav-recent-posts");
269
+ case "nav-taxonomy-list":
270
+ return renderNavList(block, "smk-nav-taxonomy-list");
271
+ case "nav-search":
272
+ return renderNavSearch(block);
152
273
  default:
153
274
  // Forward-compat: unknown block component — emit empty marker
154
275
  // so the page renders. Better than throwing.
@@ -161,3 +282,157 @@ function renderBlock(block: Block) {
161
282
  );
162
283
  }
163
284
  }
285
+
286
+ // ── nav-* render helpers ─────────────────────────────────────────────
287
+
288
+ /**
289
+ * Card-list render for `nav-recent-posts` + `nav-taxonomy-list`. Both
290
+ * blocks expose the same `{heading, viewAll, layout, snapshot}` shape;
291
+ * only the source of the snapshot differs (resolver-side concern, not
292
+ * SDK-side). The `layout` prop drives the wrapper's `data-layout` attr
293
+ * so customer CSS can style list / grid / carousel variants. Default
294
+ * styling ships sufficient base CSS for the list variant to be usable
295
+ * without customer overrides.
296
+ *
297
+ * Slugs in the snapshot are stored without leading slash (matching the
298
+ * `productContent.slug` DB column); we prepend one when forming hrefs.
299
+ * Customer's catch-all route at `/[...slug]` resolves them.
300
+ */
301
+ function renderNavList(
302
+ block: Extract<
303
+ Block,
304
+ { component: "nav-recent-posts" | "nav-taxonomy-list" }
305
+ >,
306
+ baseClass: string,
307
+ ) {
308
+ const snapshot: NavSnapshotEntry[] = block.props.snapshot ?? [];
309
+ const layout = block.props.layout ?? "list";
310
+ const heading = (block.props as ModuleHeader).heading;
311
+ const viewAll = (block.props as ModuleHeader).viewAll;
312
+
313
+ return (
314
+ <section
315
+ key={block.id}
316
+ className={`smk-block smk-block--${block.component} smk-nav ${baseClass}`}
317
+ data-block-id={block.id}
318
+ data-block-component={block.component}
319
+ data-layout={layout}
320
+ >
321
+ {heading && <h2 className="smk-nav__heading">{heading}</h2>}
322
+ {snapshot.length === 0 ? (
323
+ <p className="smk-nav__empty">No posts yet.</p>
324
+ ) : (
325
+ <ul className="smk-nav__list">
326
+ {snapshot.map((entry) => (
327
+ <li key={entry.slug} className="smk-nav__item">
328
+ <a href={`/${entry.slug}`} className="smk-nav__link">
329
+ {entry.featuredImageUrl && (
330
+ <img
331
+ src={entry.featuredImageUrl}
332
+ alt=""
333
+ className="smk-nav__thumb"
334
+ loading="lazy"
335
+ />
336
+ )}
337
+ <div className="smk-nav__meta">
338
+ <h3 className="smk-nav__title">
339
+ {entry.title || entry.slug}
340
+ </h3>
341
+ {entry.excerpt && (
342
+ <p className="smk-nav__excerpt">{entry.excerpt}</p>
343
+ )}
344
+ {entry.publishedAt && (
345
+ <time
346
+ className="smk-nav__date"
347
+ dateTime={entry.publishedAt}
348
+ >
349
+ {formatPublishedAt(entry.publishedAt)}
350
+ </time>
351
+ )}
352
+ </div>
353
+ </a>
354
+ </li>
355
+ ))}
356
+ </ul>
357
+ )}
358
+ {viewAll && (
359
+ <a className="smk-nav__view-all" href={viewAll.href}>
360
+ {viewAll.label}
361
+ </a>
362
+ )}
363
+ </section>
364
+ );
365
+ }
366
+
367
+ /**
368
+ * `nav-search` ships a server-rendered `<input>` + result `<ul>`, then
369
+ * an inline IIFE that wires keystroke → filter → list mutate. Zero
370
+ * client-component dependency on the customer side (no React hydration,
371
+ * no use client boundary). Index lives in DOM via `JSON.stringify` with
372
+ * `</` escape — prevents `</script>` smuggling.
373
+ *
374
+ * Match algorithm: case-insensitive substring against `title`, `excerpt`,
375
+ * and `slug`. Top 20 results shown. Customer can override the script by
376
+ * shipping their own `<SmkingSearch>` component if they want fancier
377
+ * matching (fuzzy, stemming, weighting).
378
+ */
379
+ function renderNavSearch(
380
+ block: Extract<Block, { component: "nav-search" }>,
381
+ ) {
382
+ const index: NavSearchIndexEntry[] = block.props.index ?? [];
383
+ const placeholder = block.props.placeholder || "Search…";
384
+
385
+ // Escape `</` so the JSON payload can't terminate the surrounding
386
+ // <script> tag — standard React-docs-recommended JSON-in-script
387
+ // hardening, since we serialize titles / excerpts that came from
388
+ // arbitrary author input.
389
+ const indexJson = JSON.stringify(index).replace(/</g, "\\u003c");
390
+ const scriptSrc =
391
+ "(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=" +
392
+ indexJson +
393
+ ";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)})();";
394
+
395
+ return (
396
+ <section
397
+ key={block.id}
398
+ className="smk-block smk-block--nav-search smk-nav-search"
399
+ data-block-id={block.id}
400
+ >
401
+ <div className="smk-nav-search__shell">
402
+ <input
403
+ type="search"
404
+ data-smk-search-input
405
+ placeholder={placeholder}
406
+ className="smk-nav-search__input"
407
+ />
408
+ <ul
409
+ data-smk-search-results
410
+ hidden
411
+ className="smk-nav-search__list"
412
+ />
413
+ <p
414
+ data-smk-search-empty
415
+ hidden
416
+ className="smk-nav-search__empty"
417
+ >
418
+ No matches.
419
+ </p>
420
+ </div>
421
+ {/* eslint-disable-next-line react/no-danger -- inline IIFE wires search filter; index is JSON-escaped against </script> smuggling. */}
422
+ <script dangerouslySetInnerHTML={{ __html: scriptSrc }} />
423
+ </section>
424
+ );
425
+ }
426
+
427
+ /**
428
+ * Best-effort locale-naive date render. SDK doesn't ship a date library;
429
+ * customer can override via CSS / their own `<SmkingCms>` wrapper if
430
+ * they need a specific format / timezone.
431
+ */
432
+ function formatPublishedAt(iso: string): string {
433
+ try {
434
+ return new Date(iso).toLocaleDateString();
435
+ } catch {
436
+ return iso.slice(0, 10);
437
+ }
438
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * IIFE source for `<smking-carousel>` + `<smking-slide>` Web Component
3
+ * registration. The Next.js SDK inlines this as a `<script>` tag once
4
+ * per page so customer sites get the carousel interactivity without
5
+ * adding a `<script src>` to their layout.
6
+ *
7
+ * Idempotent: the IIFE guards each `customElements.define` call with a
8
+ * `customElements.get(...)` check, so multiple emits per page are
9
+ * harmless.
10
+ *
11
+ * Keep this string in sync with
12
+ * `apps/web/src/features/cms/sdk/smking-elements.ts` — the SaaS-side
13
+ * Plate editor imports the TS module directly, so both paths register
14
+ * the same runtime.
15
+ */
16
+ export const SMKING_ELEMENTS_IIFE = `(function(){if(typeof window==="undefined"||typeof customElements==="undefined")return;
17
+ var C="smking-carousel",S="smking-slide";
18
+ if(customElements.get(C))return;
19
+ function nextFrame(cb){if(typeof requestAnimationFrame!=="undefined")return requestAnimationFrame(cb);return setTimeout(cb,16);}
20
+ function cancelFrame(id){if(typeof cancelAnimationFrame!=="undefined")return cancelAnimationFrame(id);return clearTimeout(id);}
21
+ function CarouselEl(){return Reflect.construct(HTMLElement,[],CarouselEl);}
22
+ CarouselEl.prototype=Object.create(HTMLElement.prototype);
23
+ CarouselEl.prototype.constructor=CarouselEl;
24
+ CarouselEl.prototype.connectedCallback=function(){var self=this;
25
+ var viewport=self.querySelector(".smk-carousel__viewport");
26
+ if(!viewport)return;
27
+ var dotsContainer=self.querySelector(".smk-carousel__dots");
28
+ var prevBtn=self.querySelector(".smk-carousel__nav-prev");
29
+ var nextBtn=self.querySelector(".smk-carousel__nav-next");
30
+ var slides=Array.prototype.slice.call(viewport.querySelectorAll(":scope > "+S));
31
+ if(dotsContainer){var dotEls=slides.map(function(_,i){var d=document.createElement("button");d.type="button";d.className="smk-carousel__dot";d.dataset.index=String(i);d.setAttribute("aria-label","Go to slide "+(i+1));if(i===0)d.setAttribute("aria-current","true");d.addEventListener("click",function(e){e.preventDefault();viewport.scrollTo({left:i*viewport.clientWidth,behavior:"smooth"});});return d;});dotsContainer.replaceChildren.apply(dotsContainer,dotEls);}
32
+ function onPrev(e){e.preventDefault();viewport.scrollBy({left:-viewport.clientWidth,behavior:"smooth"});}
33
+ function onNext(e){e.preventDefault();viewport.scrollBy({left:viewport.clientWidth,behavior:"smooth"});}
34
+ if(prevBtn)prevBtn.addEventListener("click",onPrev);
35
+ if(nextBtn)nextBtn.addEventListener("click",onNext);
36
+ var raf=0;
37
+ function onScroll(){if(raf)return;raf=nextFrame(function(){raf=0;var ai=Math.round(viewport.scrollLeft/Math.max(1,viewport.clientWidth));if(!dotsContainer)return;var dots=dotsContainer.querySelectorAll(".smk-carousel__dot");dots.forEach(function(d,i){if(i===ai)d.setAttribute("aria-current","true");else d.removeAttribute("aria-current");});});}
38
+ viewport.addEventListener("scroll",onScroll,{passive:true});
39
+ self._smkCleanup=function(){if(prevBtn)prevBtn.removeEventListener("click",onPrev);if(nextBtn)nextBtn.removeEventListener("click",onNext);viewport.removeEventListener("scroll",onScroll);if(raf)cancelFrame(raf);};};
40
+ CarouselEl.prototype.disconnectedCallback=function(){if(this._smkCleanup)this._smkCleanup();};
41
+ function SlideEl(){return Reflect.construct(HTMLElement,[],SlideEl);}
42
+ SlideEl.prototype=Object.create(HTMLElement.prototype);
43
+ SlideEl.prototype.constructor=SlideEl;
44
+ customElements.define(C,CarouselEl);
45
+ customElements.define(S,SlideEl);
46
+ })();`;
package/src/types.ts CHANGED
@@ -79,10 +79,30 @@ export interface ModuleHeader {
79
79
  viewAll?: { label: string; href: string };
80
80
  }
81
81
  export type NavLayout = "grid" | "list" | "carousel";
82
+
83
+ /**
84
+ * Materialized snapshot entry — published pages on the smking SaaS
85
+ * pre-resolve their nav-* block data at publish time and bake it into
86
+ * `block.props.snapshot`. Customer SDK echoes this verbatim, never
87
+ * round-trips back to smking for nav rendering. If smking is down,
88
+ * customer pages still render whatever was last published.
89
+ */
90
+ export interface NavSnapshotEntry {
91
+ slug: string;
92
+ title: string | null;
93
+ excerpt: string | null;
94
+ featuredImageUrl: string | null;
95
+ /** ISO string. */
96
+ publishedAt: string | null;
97
+ contentType: "article" | "landing" | "listing" | null;
98
+ }
99
+
82
100
  export interface NavRecentPostsProps extends ModuleHeader {
83
101
  limit: number;
84
102
  layout: NavLayout;
85
103
  contentType?: "article" | "all";
104
+ /** Publish-time materialized — never fetched live. */
105
+ snapshot?: NavSnapshotEntry[];
86
106
  }
87
107
  export interface NavTaxonomyListProps extends ModuleHeader {
88
108
  /** category-by-path: slug prefix; tag: taxonomies.slug */
@@ -90,15 +110,49 @@ export interface NavTaxonomyListProps extends ModuleHeader {
90
110
  path: string;
91
111
  limit: number;
92
112
  layout: NavLayout;
113
+ /** Publish-time materialized — never fetched live. */
114
+ snapshot?: NavSnapshotEntry[];
115
+ }
116
+
117
+ /**
118
+ * Search index entry. Customer SDK does client-side fuzzy filter
119
+ * against this list; clicking a result navigates to the slug.
120
+ */
121
+ export interface NavSearchIndexEntry {
122
+ slug: string;
123
+ title: string | null;
124
+ excerpt: string | null;
93
125
  }
126
+
94
127
  export interface NavSearchProps {
95
128
  placeholder: string;
96
129
  contentType?: "article" | "all";
130
+ /** Publish-time materialized full-site search index. */
131
+ index?: NavSearchIndexEntry[];
132
+ }
133
+
134
+ export interface CarouselSlideProps {
135
+ imageUrl: string;
136
+ caption: string;
137
+ }
138
+
139
+ export type CarouselAspectRatio =
140
+ | "16:9"
141
+ | "4:3"
142
+ | "1:1"
143
+ | "3:4"
144
+ | "9:16";
145
+
146
+ export interface CarouselProps {
147
+ slides: CarouselSlideProps[];
148
+ /** Fixed aspect ratio for all slides — image fills via `object-fit: cover`. Default `16:9`. */
149
+ aspectRatio?: CarouselAspectRatio;
97
150
  }
98
151
 
99
152
  export type Block =
100
153
  | { component: "hero"; id: string; props: HeroProps }
101
154
  | { component: "article"; id: string; props: ArticleProps }
155
+ | { component: "carousel"; id: string; props: CarouselProps }
102
156
  | {
103
157
  component: "nav-recent-posts";
104
158
  id: string;