@soloworks/smking-next 0.12.2 → 0.14.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 +40 -0
- package/package.json +1 -1
- package/src/components/smking-cms.tsx +607 -28
- package/src/lib/smking-cms-styles.ts +569 -0
- package/src/lib/smking-elements-iife.ts +46 -0
- package/src/types.ts +73 -0
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
import { getCmsPage } from "../lib/cms-client";
|
|
2
|
-
import
|
|
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
|
+
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
|
+
}
|
|
3
28
|
|
|
4
29
|
/**
|
|
5
30
|
* Server Component that renders a published smking CMS page.
|
|
@@ -20,11 +45,13 @@ import type { Block, CmsParams } from "../types";
|
|
|
20
45
|
*
|
|
21
46
|
* v0.11+ substrate pivot: fetches a Block[] array and dispatches each
|
|
22
47
|
* block by `component`. Article block carries pre-rendered HTML
|
|
23
|
-
* (server-rendered at write time via
|
|
24
|
-
* needs zero
|
|
25
|
-
*
|
|
26
|
-
* / nav-search
|
|
27
|
-
*
|
|
48
|
+
* (server-rendered at write time via Plate's `serializeHtml`, so the
|
|
49
|
+
* customer SDK needs zero editor dependencies). Hero / carousel render
|
|
50
|
+
* inline. Dynamic nav-* blocks (`nav-recent-posts` /
|
|
51
|
+
* `nav-taxonomy-list` / `nav-search`) carry a publish-time materialized
|
|
52
|
+
* `snapshot` (or `index`) embedded in their props — SDK echoes that
|
|
53
|
+
* directly, zero round-trip back to smking. If smking is down, the
|
|
54
|
+
* customer's page still renders the last published snapshot.
|
|
28
55
|
*
|
|
29
56
|
* Returns null when the response isn't ready (pending / not_found /
|
|
30
57
|
* unreachable / mis-configured) — fail-open by design.
|
|
@@ -32,7 +59,7 @@ import type { Block, CmsParams } from "../types";
|
|
|
32
59
|
* Emits `<title>` / `<meta>` / `og:*` / canonical inline; React 19
|
|
33
60
|
* hoists them into `<head>` automatically.
|
|
34
61
|
*/
|
|
35
|
-
export async function SmkingCms(props: CmsParams) {
|
|
62
|
+
export async function SmkingCms(props: CmsParams & { mode?: CmsThemeMode }) {
|
|
36
63
|
const data = await getCmsPage(props);
|
|
37
64
|
if (!data || data.status !== "ready" || !data.page) return null;
|
|
38
65
|
|
|
@@ -42,8 +69,41 @@ export async function SmkingCms(props: CmsParams) {
|
|
|
42
69
|
// also sets these still overrides ours where present.
|
|
43
70
|
const seo = data.seo;
|
|
44
71
|
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");
|
|
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";
|
|
45
91
|
return (
|
|
46
92
|
<>
|
|
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
|
+
)}
|
|
47
107
|
{seo?.title && <title data-smking="cms">{seo.title}</title>}
|
|
48
108
|
{seo?.metaDescription && (
|
|
49
109
|
<meta
|
|
@@ -69,16 +129,27 @@ export async function SmkingCms(props: CmsParams) {
|
|
|
69
129
|
<link rel="canonical" href={seo.canonicalUrl} data-smking="cms" />
|
|
70
130
|
)}
|
|
71
131
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
<h1
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
132
|
+
{mode === "tailwind-prose" ? (
|
|
133
|
+
<article
|
|
134
|
+
className="prose lg:prose-xl dark:prose-invert mx-auto"
|
|
135
|
+
data-smking="cms"
|
|
136
|
+
data-content-type={data.page.contentType}
|
|
137
|
+
>
|
|
138
|
+
{data.page.title && <h1>{data.page.title}</h1>}
|
|
139
|
+
{blocks.map((block) => renderBlockProse(block))}
|
|
140
|
+
</article>
|
|
141
|
+
) : (
|
|
142
|
+
<article
|
|
143
|
+
className="smk-cms"
|
|
144
|
+
data-smking="cms"
|
|
145
|
+
data-content-type={data.page.contentType}
|
|
146
|
+
>
|
|
147
|
+
{data.page.title && (
|
|
148
|
+
<h1 className="smk-cms__title">{data.page.title}</h1>
|
|
149
|
+
)}
|
|
150
|
+
{blocks.map((block) => renderBlock(block))}
|
|
151
|
+
</article>
|
|
152
|
+
)}
|
|
82
153
|
</>
|
|
83
154
|
);
|
|
84
155
|
}
|
|
@@ -134,21 +205,113 @@ function renderBlock(block: Block) {
|
|
|
134
205
|
)}
|
|
135
206
|
</section>
|
|
136
207
|
);
|
|
137
|
-
case "
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
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";
|
|
144
218
|
return (
|
|
145
|
-
<
|
|
219
|
+
<CustomCarousel
|
|
146
220
|
key={block.id}
|
|
147
|
-
className=
|
|
221
|
+
className="smk-block smk-block--carousel smk-carousel"
|
|
148
222
|
data-block-id={block.id}
|
|
149
|
-
data-
|
|
150
|
-
|
|
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>
|
|
151
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);
|
|
152
315
|
default:
|
|
153
316
|
// Forward-compat: unknown block component — emit empty marker
|
|
154
317
|
// so the page renders. Better than throwing.
|
|
@@ -161,3 +324,419 @@ function renderBlock(block: Block) {
|
|
|
161
324
|
);
|
|
162
325
|
}
|
|
163
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>
|
|
741
|
+
);
|
|
742
|
+
}
|