@founderhq/next-blog 0.9.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.
@@ -0,0 +1,603 @@
1
+ "use client";
2
+
3
+ import {
4
+ type CSSProperties,
5
+ type FormEvent,
6
+ type ReactNode,
7
+ useCallback,
8
+ useEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from "react";
13
+ import {
14
+ articlePath,
15
+ normalizeRoutePrefix,
16
+ type BlogArticle,
17
+ type BlogAuthor,
18
+ type BlogCta,
19
+ type BlogNewsletter,
20
+ type BlogPagination,
21
+ type BlogSite,
22
+ type BlogTocItem,
23
+ } from "@founderhq/next-blog";
24
+
25
+ // Editable FounderHQ blog template. Change the markup/classes to match your site.
26
+ function cx(...values: Array<string | false | null | undefined>) {
27
+ return values.filter(Boolean).join(" ");
28
+ }
29
+
30
+ function joinPath(base: string, segment: string) {
31
+ return `${base.replace(/\/+$/, "")}/${segment.replace(/^\/+/, "")}`;
32
+ }
33
+
34
+ const TOC_RAIL = 8;
35
+
36
+ function getLineOffset(level: number) {
37
+ if (level <= 2) return TOC_RAIL;
38
+ if (level === 3) return TOC_RAIL + 8;
39
+ return TOC_RAIL + 16;
40
+ }
41
+
42
+ function getItemOffset(level: number) {
43
+ if (level <= 2) return TOC_RAIL + 12;
44
+ if (level === 3) return TOC_RAIL + 24;
45
+ return TOC_RAIL + 36;
46
+ }
47
+
48
+ function getScrollParent(element: HTMLElement | null): HTMLElement | null {
49
+ let node = element?.parentElement ?? null;
50
+ while (node) {
51
+ const overflowY = getComputedStyle(node).overflowY;
52
+ if (overflowY === "auto" || overflowY === "scroll") return node;
53
+ node = node.parentElement;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function escapeSelectorValue(value: string) {
59
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
60
+ return CSS.escape(value);
61
+ }
62
+ return value.replace(/["\\]/g, "\\$&");
63
+ }
64
+
65
+ function resolveHeadingElement(id: string): HTMLElement | null {
66
+ return (
67
+ document.getElementById(id) ??
68
+ document.querySelector<HTMLElement>(
69
+ `[data-toc-id="${escapeSelectorValue(id)}"]`,
70
+ )
71
+ );
72
+ }
73
+
74
+ type TocPosition = [top: number, bottom: number, x: number];
75
+
76
+ type TocRail = {
77
+ width: number;
78
+ height: number;
79
+ d: string;
80
+ positions: TocPosition[];
81
+ };
82
+
83
+ function defaultTocSelect(item: BlogTocItem) {
84
+ resolveHeadingElement(item.id)?.scrollIntoView({
85
+ block: "start",
86
+ behavior: "smooth",
87
+ });
88
+ }
89
+
90
+ export function BlogToc({
91
+ items,
92
+ onSelect = defaultTocSelect,
93
+ className,
94
+ }: {
95
+ items: BlogTocItem[];
96
+ onSelect?: (item: BlogTocItem) => void;
97
+ className?: string;
98
+ }) {
99
+ const containerRef = useRef<HTMLDivElement>(null);
100
+ const [rail, setRail] = useState<TocRail | null>(null);
101
+ const [activeIds, setActiveIds] = useState<ReadonlySet<string>>(
102
+ () => new Set(),
103
+ );
104
+
105
+ const measure = useCallback(() => {
106
+ const container = containerRef.current;
107
+ if (!container || container.clientHeight === 0) return;
108
+ if (items.length === 0) {
109
+ setRail(null);
110
+ return;
111
+ }
112
+
113
+ const rows = container.querySelectorAll<HTMLElement>("[data-toc-item]");
114
+ let width = 0;
115
+ let height = 0;
116
+ let d = "";
117
+ const positions: TocPosition[] = [];
118
+
119
+ items.forEach((item, index) => {
120
+ const row = rows[index];
121
+ if (!row) return;
122
+ const styles = getComputedStyle(row);
123
+ const x = getLineOffset(item.level) + 0.5;
124
+ const top = row.offsetTop + parseFloat(styles.paddingTop);
125
+ const bottom =
126
+ row.offsetTop + row.clientHeight - parseFloat(styles.paddingBottom);
127
+
128
+ width = Math.max(width, x + 8);
129
+ height = Math.max(height, bottom);
130
+
131
+ if (positions.length === 0) {
132
+ d += `M${x} ${top} L${x} ${bottom}`;
133
+ } else {
134
+ const [, previousBottom, previousX] = positions[positions.length - 1];
135
+ d += ` C ${previousX} ${top - 4} ${x} ${previousBottom + 4} ${x} ${top} L${x} ${bottom}`;
136
+ }
137
+
138
+ positions.push([top, bottom, x]);
139
+ });
140
+
141
+ setRail({ width, height, d, positions });
142
+ }, [items]);
143
+
144
+ const measureRef = useRef(measure);
145
+ useEffect(() => {
146
+ measureRef.current = measure;
147
+ }, [measure]);
148
+
149
+ const layoutSignature = useMemo(
150
+ () => items.map((item) => `${item.id}:${item.level}`).join("/"),
151
+ [items],
152
+ );
153
+
154
+ useEffect(() => {
155
+ const container = containerRef.current;
156
+ if (!container) return;
157
+ const run = () => measureRef.current();
158
+ if (typeof ResizeObserver === "undefined") {
159
+ run();
160
+ return;
161
+ }
162
+ const observer = new ResizeObserver(run);
163
+ observer.observe(container);
164
+ run();
165
+ return () => observer.disconnect();
166
+ }, []);
167
+
168
+ useEffect(() => {
169
+ measureRef.current();
170
+ }, [layoutSignature]);
171
+
172
+ useEffect(() => {
173
+ const headings = items
174
+ .map((item) => ({ id: item.id, el: resolveHeadingElement(item.id) }))
175
+ .filter(
176
+ (entry): entry is { id: string; el: HTMLElement } => entry.el !== null,
177
+ );
178
+ const scroller =
179
+ headings.length > 0 ? getScrollParent(headings[0].el) : null;
180
+ const idByElement = new Map<Element, string>(
181
+ headings.map((entry) => [entry.el, entry.id]),
182
+ );
183
+ const visible = new Set<string>();
184
+
185
+ const recompute = () => {
186
+ if (visible.size > 0) {
187
+ setActiveIds(new Set(visible));
188
+ return;
189
+ }
190
+ const top = scroller ? scroller.getBoundingClientRect().top : 0;
191
+ let nearestId: string | null = null;
192
+ let nearestGap = Number.POSITIVE_INFINITY;
193
+ for (const heading of headings) {
194
+ const gap = top - heading.el.getBoundingClientRect().top;
195
+ if (gap >= 0 && gap < nearestGap) {
196
+ nearestGap = gap;
197
+ nearestId = heading.id;
198
+ }
199
+ }
200
+ setActiveIds(nearestId ? new Set([nearestId]) : new Set());
201
+ };
202
+
203
+ if (headings.length === 0) {
204
+ recompute();
205
+ return;
206
+ }
207
+
208
+ const observer = new IntersectionObserver(
209
+ (entries) => {
210
+ for (const entry of entries) {
211
+ const id = idByElement.get(entry.target);
212
+ if (!id) continue;
213
+ if (entry.isIntersecting) visible.add(id);
214
+ else visible.delete(id);
215
+ }
216
+ recompute();
217
+ },
218
+ { root: scroller, threshold: 0 },
219
+ );
220
+ for (const heading of headings) observer.observe(heading.el);
221
+ recompute();
222
+ return () => observer.disconnect();
223
+ }, [items]);
224
+
225
+ const active = useMemo(() => {
226
+ const useFallback = activeIds.size === 0;
227
+ let start = -1;
228
+ let end = -1;
229
+ items.forEach((item, index) => {
230
+ const on = useFallback ? Boolean(item.isActive) : activeIds.has(item.id);
231
+ if (!on) return;
232
+ if (start === -1) start = index;
233
+ end = index;
234
+ });
235
+ return { start, end };
236
+ }, [items, activeIds]);
237
+
238
+ const activeStart = active.start;
239
+ const activeEnd = active.end;
240
+ useEffect(() => {
241
+ const container = containerRef.current;
242
+ if (!container || activeStart === -1) return;
243
+ const scroller = getScrollParent(container);
244
+ if (!scroller || scroller.scrollHeight <= scroller.clientHeight) return;
245
+
246
+ const rows = container.querySelectorAll<HTMLElement>("[data-toc-item]");
247
+ const startRow = rows[activeStart];
248
+ const endRow = rows[activeEnd] ?? startRow;
249
+ if (!startRow) return;
250
+
251
+ const margin = 36;
252
+ const view = scroller.getBoundingClientRect();
253
+ const top = startRow.getBoundingClientRect().top;
254
+ const bottom = endRow.getBoundingClientRect().bottom;
255
+
256
+ if (top < view.top + margin) {
257
+ scroller.scrollTop -= view.top + margin - top;
258
+ } else if (bottom > view.bottom - margin) {
259
+ scroller.scrollTop += bottom - (view.bottom - margin);
260
+ }
261
+ }, [activeStart, activeEnd]);
262
+
263
+ const hasThumb =
264
+ rail !== null && active.start !== -1 && rail.positions[active.start] != null;
265
+ const trackTop = hasThumb ? rail.positions[active.start][0] : 0;
266
+ const trackBottom = hasThumb ? rail.positions[active.end][1] : 0;
267
+
268
+ if (items.length === 0) return null;
269
+
270
+ return (
271
+ <div
272
+ ref={containerRef}
273
+ role="navigation"
274
+ className={cx("fhq-blog-toc", className)}
275
+ aria-label="Article outline"
276
+ >
277
+ {rail ? (
278
+ <div
279
+ aria-hidden
280
+ className="fhq-blog-toc__rail"
281
+ style={{ width: rail.width, height: rail.height }}
282
+ >
283
+ <svg
284
+ xmlns="http://www.w3.org/2000/svg"
285
+ viewBox={`0 0 ${rail.width} ${rail.height}`}
286
+ className="fhq-blog-toc__rail-svg"
287
+ style={{ width: rail.width, height: rail.height }}
288
+ >
289
+ <path d={rail.d} fill="none" strokeWidth="1" />
290
+ </svg>
291
+ <svg
292
+ xmlns="http://www.w3.org/2000/svg"
293
+ viewBox={`0 0 ${rail.width} ${rail.height}`}
294
+ className="fhq-blog-toc__rail-svg fhq-blog-toc__rail-svg--active"
295
+ style={{
296
+ width: rail.width,
297
+ height: rail.height,
298
+ opacity: hasThumb ? 1 : 0,
299
+ clipPath: `polygon(0 ${trackTop}px, 100% ${trackTop}px, 100% ${trackBottom}px, 0 ${trackBottom}px)`,
300
+ }}
301
+ >
302
+ <path d={rail.d} fill="none" strokeWidth="1" />
303
+ </svg>
304
+ </div>
305
+ ) : null}
306
+ {items.map((item, index) => (
307
+ <button
308
+ key={`${item.id}-${item.pos ?? index}`}
309
+ type="button"
310
+ data-toc-item
311
+ data-active={
312
+ index >= active.start && index <= active.end ? "true" : undefined
313
+ }
314
+ onClick={() => onSelect(item)}
315
+ style={{ paddingInlineStart: getItemOffset(item.level) }}
316
+ className="fhq-blog-toc__item"
317
+ >
318
+ <span>{item.text}</span>
319
+ </button>
320
+ ))}
321
+ </div>
322
+ );
323
+ }
324
+
325
+ export function BlogImage({
326
+ image,
327
+ priority = false,
328
+ className,
329
+ }: {
330
+ image: NonNullable<BlogArticle["featuredImage"]>;
331
+ priority?: boolean;
332
+ className?: string;
333
+ }) {
334
+ const style = image.aspectRatio
335
+ ? ({ aspectRatio: image.aspectRatio } as CSSProperties)
336
+ : undefined;
337
+ return (
338
+ <figure className={cx("fhq-blog-image", className)} style={style}>
339
+ <img
340
+ src={image.url}
341
+ alt={image.alt}
342
+ width={image.width ?? undefined}
343
+ height={image.height ?? undefined}
344
+ loading={priority ? "eager" : "lazy"}
345
+ />
346
+ {image.caption ? <figcaption>{image.caption}</figcaption> : null}
347
+ </figure>
348
+ );
349
+ }
350
+
351
+ export function AuthorByline({ article }: { article: BlogArticle }) {
352
+ if (!article.author) return null;
353
+ return (
354
+ <div className="fhq-blog-byline">
355
+ {article.author.avatar ? (
356
+ <img src={article.author.avatar.url} alt="" width="40" height="40" />
357
+ ) : null}
358
+ <div>
359
+ <div className="fhq-blog-byline__name">{article.author.name}</div>
360
+ {article.author.title ? (
361
+ <div className="fhq-blog-byline__title">{article.author.title}</div>
362
+ ) : null}
363
+ </div>
364
+ </div>
365
+ );
366
+ }
367
+
368
+ export function BlogCtaBlock({ cta }: { cta: BlogCta }) {
369
+ return (
370
+ <aside className={cx("fhq-blog-cta", cta.style && `is-${cta.style}`)}>
371
+ {cta.eyebrow ? <p className="fhq-blog-eyebrow">{cta.eyebrow}</p> : null}
372
+ <h2>{cta.heading}</h2>
373
+ {cta.body ? <p>{cta.body}</p> : null}
374
+ <a href={cta.buttonUrl}>{cta.buttonLabel}</a>
375
+ </aside>
376
+ );
377
+ }
378
+
379
+ export function BlogNewsletterForm({
380
+ newsletter,
381
+ onSubmit,
382
+ }: {
383
+ newsletter: BlogNewsletter;
384
+ onSubmit?: (email: string, sourceTag: string | null) => Promise<void> | void;
385
+ }) {
386
+ const [email, setEmail] = useState("");
387
+ const [status, setStatus] = useState<"idle" | "submitting" | "done" | "error">(
388
+ "idle",
389
+ );
390
+
391
+ async function submit(event: FormEvent<HTMLFormElement>) {
392
+ event.preventDefault();
393
+ if (!email.trim()) return;
394
+ setStatus("submitting");
395
+ try {
396
+ await onSubmit?.(email.trim(), newsletter.sourceTag ?? null);
397
+ setStatus("done");
398
+ setEmail("");
399
+ } catch {
400
+ setStatus("error");
401
+ }
402
+ }
403
+
404
+ return (
405
+ <form className="fhq-blog-newsletter" onSubmit={submit}>
406
+ <h2>{newsletter.heading}</h2>
407
+ {newsletter.body ? <p>{newsletter.body}</p> : null}
408
+ <div className="fhq-blog-newsletter__row">
409
+ <input
410
+ type="email"
411
+ value={email}
412
+ onChange={(event) => setEmail(event.target.value)}
413
+ placeholder="you@example.com"
414
+ required
415
+ />
416
+ <button type="submit" disabled={status === "submitting"}>
417
+ {newsletter.buttonLabel ?? "Subscribe"}
418
+ </button>
419
+ </div>
420
+ {status === "done" ? <p>You're subscribed.</p> : null}
421
+ {status === "error" ? <p>Something went wrong.</p> : null}
422
+ </form>
423
+ );
424
+ }
425
+
426
+ export function BlogArticleLayout({
427
+ article,
428
+ site,
429
+ children,
430
+ sidebar,
431
+ }: {
432
+ article: BlogArticle;
433
+ site: BlogSite;
434
+ children?: ReactNode;
435
+ sidebar?: ReactNode;
436
+ }) {
437
+ const toc = article.toc ?? [];
438
+ return (
439
+ <main className="fhq-blog-article">
440
+ <article>
441
+ <a className="fhq-blog-back" href={normalizeRoutePrefix(site.routePrefix)}>
442
+ Blog
443
+ </a>
444
+ <h1>{article.title}</h1>
445
+ <AuthorByline article={article} />
446
+ {article.featuredImage ? (
447
+ <BlogImage image={article.featuredImage} priority />
448
+ ) : null}
449
+ <div className="fhq-blog-layout">
450
+ <div className="fhq-blog-prose">
451
+ {children ?? (
452
+ <div
453
+ dangerouslySetInnerHTML={{ __html: article.contentHtml ?? "" }}
454
+ />
455
+ )}
456
+ </div>
457
+ <aside className="fhq-blog-sidebar">
458
+ {sidebar ?? <BlogToc items={toc} />}
459
+ </aside>
460
+ </div>
461
+ </article>
462
+ </main>
463
+ );
464
+ }
465
+
466
+ export function BlogIndex({
467
+ articles,
468
+ site,
469
+ className,
470
+ searchAction,
471
+ searchInitialQuery,
472
+ title,
473
+ description,
474
+ pagination,
475
+ }: {
476
+ articles: BlogArticle[];
477
+ site: BlogSite;
478
+ className?: string;
479
+ searchAction?: string;
480
+ searchInitialQuery?: string;
481
+ title?: string;
482
+ description?: string | null;
483
+ pagination?: BlogPagination | null;
484
+ }) {
485
+ return (
486
+ <main className={cx("fhq-blog-index", className)}>
487
+ <header>
488
+ <h1>{title ?? `${site.siteName} Blog`}</h1>
489
+ {description ? <p>{description}</p> : null}
490
+ <BlogSearchBox
491
+ initialQuery={searchInitialQuery}
492
+ action={searchAction ?? joinPath(normalizeRoutePrefix(site.routePrefix), "search")}
493
+ />
494
+ </header>
495
+ <div className="fhq-blog-grid">
496
+ {articles.map((article) => (
497
+ <article className="fhq-blog-card" key={article.slug}>
498
+ {article.featuredImage ? <BlogImage image={article.featuredImage} /> : null}
499
+ <h2>
500
+ <a href={articlePath(article, site)}>{article.title}</a>
501
+ </h2>
502
+ {article.excerpt ? <p>{article.excerpt}</p> : null}
503
+ </article>
504
+ ))}
505
+ </div>
506
+ <BlogPaginationLinks pagination={pagination} />
507
+ </main>
508
+ );
509
+ }
510
+
511
+ export function BlogPaginationLinks({
512
+ pagination,
513
+ }: {
514
+ pagination?: BlogPagination | null;
515
+ }) {
516
+ if (!pagination || pagination.totalPages <= 1) return null;
517
+ return (
518
+ <nav className="fhq-blog-pagination" aria-label="Blog pagination">
519
+ {pagination.prevHref ? <a href={pagination.prevHref}>Previous</a> : <span />}
520
+ <span>
521
+ Page {pagination.page} of {pagination.totalPages}
522
+ </span>
523
+ {pagination.nextHref ? <a href={pagination.nextHref}>Next</a> : <span />}
524
+ </nav>
525
+ );
526
+ }
527
+
528
+ export function BlogDirectory({
529
+ title,
530
+ description,
531
+ items,
532
+ }: {
533
+ title: string;
534
+ description?: string | null;
535
+ items: Array<{
536
+ title: string;
537
+ href: string;
538
+ description?: string | null;
539
+ image?: BlogAuthor["avatar"] | null;
540
+ }>;
541
+ }) {
542
+ return (
543
+ <main className="fhq-blog-index">
544
+ <header>
545
+ <h1>{title}</h1>
546
+ {description ? <p>{description}</p> : null}
547
+ </header>
548
+ <div className="fhq-blog-grid">
549
+ {items.map((item) => (
550
+ <article className="fhq-blog-card" key={item.href}>
551
+ {item.image ? <BlogImage image={item.image} /> : null}
552
+ <h2>
553
+ <a href={item.href}>{item.title}</a>
554
+ </h2>
555
+ {item.description ? <p>{item.description}</p> : null}
556
+ </article>
557
+ ))}
558
+ </div>
559
+ </main>
560
+ );
561
+ }
562
+
563
+ export function BlogSearchBox({
564
+ initialQuery = "",
565
+ action = "/blog/search",
566
+ onSearch,
567
+ }: {
568
+ initialQuery?: string;
569
+ action?: string;
570
+ onSearch?: (query: string) => void;
571
+ }) {
572
+ const [query, setQuery] = useState(initialQuery);
573
+ const canSubmit = useMemo(() => query.trim().length > 0, [query]);
574
+
575
+ return (
576
+ <form
577
+ className="fhq-blog-search"
578
+ action={action}
579
+ method="get"
580
+ onSubmit={(event) => {
581
+ if (!canSubmit) {
582
+ event.preventDefault();
583
+ return;
584
+ }
585
+ if (onSearch) {
586
+ event.preventDefault();
587
+ onSearch(query.trim());
588
+ }
589
+ }}
590
+ >
591
+ <input
592
+ name="q"
593
+ type="search"
594
+ value={query}
595
+ onChange={(event) => setQuery(event.target.value)}
596
+ placeholder="Search articles"
597
+ />
598
+ <button type="submit" disabled={!canSubmit}>
599
+ Search
600
+ </button>
601
+ </form>
602
+ );
603
+ }