@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 +20 -0
- package/package.json +1 -1
- package/src/components/smking-cms.tsx +292 -17
- package/src/lib/smking-elements-iife.ts +46 -0
- package/src/types.ts +54 -0
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.
|
|
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
|
|
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
|
|
24
|
-
* needs zero
|
|
25
|
-
*
|
|
26
|
-
* / nav-search
|
|
27
|
-
*
|
|
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 "
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
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
|
-
<
|
|
177
|
+
<CustomCarousel
|
|
146
178
|
key={block.id}
|
|
147
|
-
className=
|
|
179
|
+
className="smk-block smk-block--carousel smk-carousel"
|
|
148
180
|
data-block-id={block.id}
|
|
149
|
-
data-
|
|
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;
|