@the-smithy/create-hearth-site 0.20.0 → 0.21.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.
Files changed (85) hide show
  1. package/bin/create-hearth-site.js +5 -0
  2. package/package.json +2 -2
  3. package/template/AGENTS.md +10 -4
  4. package/template/CLAUDE.md +77 -19
  5. package/template/README.md +51 -23
  6. package/template/SITE.md +17 -0
  7. package/template/package.json +2 -1
  8. package/template/scripts/hearth.mjs +157 -13
  9. package/template/src/app/[[...slug]]/page.tsx +2 -54
  10. package/template/src/app/api/preview/exit/route.ts +1 -1
  11. package/template/src/app/api/preview/route.ts +1 -1
  12. package/template/src/app/api/revalidate/route.ts +1 -1
  13. package/template/src/app/apple-icon.tsx +1 -1
  14. package/template/src/app/donate/page.tsx +2 -35
  15. package/template/src/app/donate/thank-you/page.tsx +2 -22
  16. package/template/src/app/events/[slug]/page.tsx +2 -26
  17. package/template/src/app/events/page.tsx +2 -29
  18. package/template/src/app/fundraisers/[slug]/page.tsx +2 -55
  19. package/template/src/app/fundraisers/page.tsx +2 -37
  20. package/template/src/app/globals.css +3 -77
  21. package/template/src/app/icon.tsx +1 -1
  22. package/template/src/app/layout.tsx +2 -36
  23. package/template/src/app/not-found.tsx +2 -14
  24. package/template/src/app/registered/page.tsx +2 -22
  25. package/template/src/app/robots.ts +1 -1
  26. package/template/src/app/sitemap.ts +2 -2
  27. package/template/src/{app → hearth}/actions.ts +1 -1
  28. package/template/src/{components → hearth}/blocks/OpenMap.tsx +11 -9
  29. package/template/src/hearth/blocks/collections.tsx +74 -0
  30. package/template/src/hearth/blocks/content.tsx +252 -0
  31. package/template/src/hearth/blocks/custom.tsx +12 -0
  32. package/template/src/hearth/blocks/events.tsx +130 -0
  33. package/template/src/hearth/blocks/giving.tsx +123 -0
  34. package/template/src/{components → hearth}/blocks/index.tsx +14 -10
  35. package/template/src/hearth/blocks/info.tsx +84 -0
  36. package/template/src/{components → hearth}/blocks/media.tsx +30 -27
  37. package/template/src/hearth/components/EventView.tsx +120 -0
  38. package/template/src/{components → hearth/components}/Registration.tsx +14 -14
  39. package/template/src/hearth/components/Share.tsx +102 -0
  40. package/template/src/hearth/format.ts +31 -0
  41. package/template/src/hearth/forms/ContactForm.tsx +27 -0
  42. package/template/src/hearth/forms/DonateForm.tsx +69 -0
  43. package/template/src/{components/blocks → hearth/forms}/EmberFormBlock.tsx +5 -4
  44. package/template/src/hearth/forms/RegistrationForm.tsx +68 -0
  45. package/template/src/hearth/forms/VolunteerForm.tsx +58 -0
  46. package/template/src/hearth/forms/fields.tsx +137 -0
  47. package/template/src/hearth/hearth.css +52 -0
  48. package/template/src/hearth/layout/MobileNav.tsx +49 -0
  49. package/template/src/hearth/layout/NavLink.tsx +16 -0
  50. package/template/src/hearth/layout/RootLayout.tsx +40 -0
  51. package/template/src/{components → hearth/layout}/SiteFooter.tsx +19 -14
  52. package/template/src/hearth/layout/SiteHeader.tsx +53 -0
  53. package/template/src/hearth/manifest.json +36 -0
  54. package/template/src/hearth/pages/donate.tsx +36 -0
  55. package/template/src/hearth/pages/event.tsx +26 -0
  56. package/template/src/hearth/pages/events.tsx +30 -0
  57. package/template/src/hearth/pages/fundraiser.tsx +77 -0
  58. package/template/src/hearth/pages/fundraisers.tsx +38 -0
  59. package/template/src/hearth/pages/not-found.tsx +15 -0
  60. package/template/src/hearth/pages/page.tsx +54 -0
  61. package/template/src/hearth/pages/registered.tsx +19 -0
  62. package/template/src/hearth/pages/thank-you.tsx +19 -0
  63. package/template/src/hearth/theme/defaults.ts +510 -0
  64. package/template/src/hearth/theme/index.ts +31 -0
  65. package/template/src/hearth/ui.tsx +144 -0
  66. package/template/src/theme/custom-blocks.tsx +11 -0
  67. package/template/src/theme/fonts.ts +9 -0
  68. package/template/src/theme/index.ts +13 -0
  69. package/template/src/theme/overrides.tsx +16 -0
  70. package/template/src/theme/theme.css +29 -0
  71. package/template/src/components/DonateForm.tsx +0 -104
  72. package/template/src/components/EventView.tsx +0 -107
  73. package/template/src/components/RegistrationForm.tsx +0 -103
  74. package/template/src/components/SiteHeader.tsx +0 -37
  75. package/template/src/components/VolunteerForm.tsx +0 -143
  76. package/template/src/components/blocks/Form.tsx +0 -92
  77. package/template/src/components/blocks/collections.tsx +0 -75
  78. package/template/src/components/blocks/content.tsx +0 -258
  79. package/template/src/components/blocks/custom.tsx +0 -20
  80. package/template/src/components/blocks/events.tsx +0 -138
  81. package/template/src/components/blocks/giving.tsx +0 -120
  82. package/template/src/components/blocks/info.tsx +0 -83
  83. package/template/src/components/ui.tsx +0 -137
  84. /package/template/src/{lib/hearth.ts → hearth/client.ts} +0 -0
  85. /package/template/src/{lib → hearth}/site.ts +0 -0
@@ -1,138 +0,0 @@
1
- import Link from "next/link";
2
- import type { BlockOf, HearthEvent } from "@the-smithy/hearth";
3
- import { Registration } from "@/components/Registration";
4
- import { VolunteerForm } from "@/components/VolunteerForm";
5
- import { Img, Section, SectionHeading } from "@/components/ui";
6
-
7
- /** "Sat, Oct 18 · 6:00 – 9:00 PM", in the event's own time zone. */
8
- export function formatEventDate(e: Pick<HearthEvent, "startsAt" | "endsAt" | "allDay" | "timezone">) {
9
- const tz = { timeZone: e.timezone };
10
- const day = new Intl.DateTimeFormat("en-US", { ...tz, weekday: "short", month: "short", day: "numeric", year: "numeric" });
11
- const time = new Intl.DateTimeFormat("en-US", { ...tz, hour: "numeric", minute: "2-digit" });
12
- const start = new Date(e.startsAt);
13
- const end = e.endsAt ? new Date(e.endsAt) : null;
14
- const sameDay = end && day.format(start) === day.format(end);
15
- if (e.allDay) return end && !sameDay ? `${day.format(start)} – ${day.format(end)}` : day.format(start);
16
- if (!end) return `${day.format(start)} · ${time.format(start)}`;
17
- return sameDay ? `${day.format(start)} · ${time.format(start)} – ${time.format(end)}` : `${day.format(start)} ${time.format(start)} – ${day.format(end)} ${time.format(end)}`;
18
- }
19
-
20
- export function EventCard({ event }: { event: HearthEvent }) {
21
- const cancelled = event.status === "cancelled";
22
- return (
23
- <li className="flex flex-col overflow-hidden rounded-2xl bg-surface ring-1 ring-line">
24
- {event.image ? <Img image={event.image} crop={16 / 9} className="aspect-[16/9] w-full object-cover" /> : null}
25
- <div className="flex flex-1 flex-col gap-2 p-6">
26
- <p className="text-sm font-semibold text-highlight">
27
- {cancelled ? <span className="mr-2 rounded bg-danger px-1.5 py-0.5 text-white">Cancelled</span> : null}
28
- <time dateTime={event.startsAt}>{formatEventDate(event)}</time>
29
- </p>
30
- <h3 className="text-xl font-semibold">
31
- <Link href={event.path} className="after:absolute after:inset-0 hover:underline">
32
- {event.title}
33
- </Link>
34
- </h3>
35
- {event.location.name ? <p className="opacity-75">{event.location.name}</p> : null}
36
- {event.summary ? <p className="leading-relaxed opacity-85">{event.summary}</p> : null}
37
- <div className="mt-auto pt-2">
38
- <Registration event={event} size="small" />
39
- </div>
40
- </div>
41
- </li>
42
- );
43
- }
44
-
45
- export function EventList({ events }: { events: HearthEvent[] }) {
46
- if (!events.length) return <p className="text-lg opacity-75">No upcoming events right now. Check back soon.</p>;
47
- return (
48
- <ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 [&>li]:relative">
49
- {events.map((e) => (
50
- <EventCard key={e.slug} event={e} />
51
- ))}
52
- </ul>
53
- );
54
- }
55
-
56
- export function UpcomingEvents({ block }: { block: BlockOf<"upcomingEvents"> }) {
57
- const d = block.data;
58
- return (
59
- <Section>
60
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
61
- <EventList events={d.events} />
62
- {d.events.length ? (
63
- <p className="mt-8">
64
- <Link href="/events" className="font-semibold underline underline-offset-4">
65
- All events →
66
- </Link>
67
- </p>
68
- ) : null}
69
- </Section>
70
- );
71
- }
72
-
73
- /** Events that have ended, most recent first (a talk archive). They keep their pages, so each still links to it. */
74
- export function PastEvents({ block }: { block: BlockOf<"pastEvents"> }) {
75
- const d = block.data;
76
- if (!d.events.length) return null;
77
- return (
78
- <Section tone="muted">
79
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
80
- <EventList events={d.events} />
81
- </Section>
82
- );
83
- }
84
-
85
- export function EventSpotlight({ block }: { block: BlockOf<"eventSpotlight"> }) {
86
- const e = block.data.event;
87
- if (!e) return null;
88
- return (
89
- <Section tone="muted">
90
- <div className="grid items-center gap-10 md:grid-cols-2">
91
- <div>
92
- {block.data.heading ? <p className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] opacity-75">{block.data.heading}</p> : null}
93
- <h2 className="text-[clamp(28px,3.4vw,40px)] font-bold leading-tight">{e.title}</h2>
94
- <p className="mt-3 text-lg font-semibold text-highlight">{formatEventDate(e)}</p>
95
- {e.location.name ? <p className="mt-1 opacity-80">{e.location.name}</p> : null}
96
- {e.summary ? <p className="mt-4 text-lg opacity-85">{e.summary}</p> : null}
97
- <Link href={e.path} className="mt-6 inline-block rounded-lg bg-accent px-6 py-3.5 font-semibold text-accent-ink hover:opacity-90">
98
- Event details
99
- </Link>
100
- </div>
101
- {e.image ? <Img image={e.image} crop={4 / 3} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
102
- </div>
103
- </Section>
104
- );
105
- }
106
-
107
- /**
108
- * Volunteer sign-up for an event, wherever the block is placed. On another page it names the event and links to it;
109
- * once sign-up closes it says so. The event's own page leaves out its built-in form when this block is on it.
110
- */
111
- export function VolunteerSignup({ block }: { block: BlockOf<"volunteerSignup"> }) {
112
- const e = block.data.event;
113
- const v = e.volunteering;
114
- const intro = block.data.intro || v.intro;
115
- return (
116
- <Section id={volunteerAnchor(e.slug)}>
117
- <div className="max-w-2xl">
118
- <h2 className="mb-2 text-2xl font-bold">{block.data.heading || "Volunteer"}</h2>
119
- {block.data.eventSlug ? (
120
- <p className="mb-2 font-semibold">
121
- <Link href={e.path} className="underline underline-offset-4 hover:opacity-80">
122
- {e.title}
123
- </Link>
124
- <span className="text-highlight"> · {formatEventDate(e)}</span>
125
- </p>
126
- ) : null}
127
- {intro ? <p className="mb-2 whitespace-pre-line opacity-85">{intro}</p> : null}
128
- {v.fundraiser ? <p className="mb-2 opacity-80">This event supports {v.fundraiser.title}.</p> : null}
129
- <div className="mt-6">
130
- {v.open ? <VolunteerForm slug={e.slug} timezone={e.timezone} volunteering={v} /> : <p className="opacity-80">Volunteer sign-up for this event has closed.</p>}
131
- </div>
132
- </div>
133
- </Section>
134
- );
135
- }
136
-
137
- /** Where an event's Volunteer sign-up block sits, for the event header's Volunteer button. */
138
- export const volunteerAnchor = (slug: string) => `volunteer-${slug}`;
@@ -1,120 +0,0 @@
1
- import Link from "next/link";
2
- import type { BlockOf, Fundraiser } from "@the-smithy/hearth";
3
- import { DonateForm } from "@/components/DonateForm";
4
- import { Img, Section, SectionHeading } from "@/components/ui";
5
-
6
- /** Cents → "$1,250". */
7
- export const money = (cents: number) => (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: cents % 100 ? 2 : 0 });
8
-
9
- /** Raised so far, against the goal when there is one. */
10
- export function Progress({ fundraiser, large }: { fundraiser: Fundraiser; large?: boolean }) {
11
- const { raisedCents, goalCents, donorCount } = fundraiser;
12
- const pct = goalCents ? Math.min(100, Math.round((raisedCents / goalCents) * 100)) : null;
13
- return (
14
- <div className="flex flex-col gap-2">
15
- <p className={large ? "text-2xl" : "text-base"}>
16
- <span className="font-bold">{money(raisedCents)}</span>
17
- <span className="opacity-75">{goalCents ? ` raised of ${money(goalCents)}` : " raised"}</span>
18
- </p>
19
- {pct !== null ? (
20
- <div className="h-2.5 overflow-hidden rounded-full bg-line" role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label="Progress toward the goal">
21
- <div className="h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
22
- </div>
23
- ) : null}
24
- {donorCount ? (
25
- <p className="text-sm opacity-75">
26
- {donorCount} online donation{donorCount === 1 ? "" : "s"}
27
- </p>
28
- ) : null}
29
- </div>
30
- );
31
- }
32
-
33
- export function FundraiserCard({ fundraiser }: { fundraiser: Fundraiser }) {
34
- return (
35
- <li className="relative flex flex-col overflow-hidden rounded-2xl bg-surface ring-1 ring-line">
36
- {fundraiser.image ? <Img image={fundraiser.image} crop={16 / 9} className="aspect-[16/9] w-full object-cover" /> : null}
37
- <div className="flex flex-1 flex-col gap-3 p-6">
38
- {fundraiser.status === "completed" ? <p className="w-fit rounded bg-muted px-2 py-0.5 text-sm font-semibold">Completed</p> : null}
39
- <h3 className="text-xl font-semibold">
40
- <Link href={fundraiser.path} className="after:absolute after:inset-0 hover:underline">
41
- {fundraiser.title}
42
- </Link>
43
- </h3>
44
- {fundraiser.summary ? <p className="leading-relaxed opacity-85">{fundraiser.summary}</p> : null}
45
- <div className="mt-auto pt-2">
46
- <Progress fundraiser={fundraiser} />
47
- </div>
48
- </div>
49
- </li>
50
- );
51
- }
52
-
53
- export function FundraiserList({ fundraisers }: { fundraisers: Fundraiser[] }) {
54
- if (!fundraisers.length) return <p className="text-lg opacity-75">No fundraisers right now. Check back soon.</p>;
55
- return (
56
- <ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
57
- {fundraisers.map((f) => (
58
- <FundraiserCard key={f.slug} fundraiser={f} />
59
- ))}
60
- </ul>
61
- );
62
- }
63
-
64
- export function FundraiserGrid({ block }: { block: BlockOf<"fundraiserGrid"> }) {
65
- const d = block.data;
66
- return (
67
- <Section>
68
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
69
- <FundraiserList fundraisers={d.fundraisers} />
70
- {d.fundraisers.length ? (
71
- <p className="mt-8">
72
- <Link href="/fundraisers" className="font-semibold underline underline-offset-4">
73
- All fundraisers →
74
- </Link>
75
- </p>
76
- ) : null}
77
- </Section>
78
- );
79
- }
80
-
81
- export function FundraiserSpotlight({ block }: { block: BlockOf<"fundraiserSpotlight"> }) {
82
- const f = block.data.fundraiser;
83
- if (!f) return null;
84
- return (
85
- <Section tone="muted">
86
- <div className="grid items-center gap-10 md:grid-cols-2">
87
- <div className="flex flex-col gap-4">
88
- {block.data.heading ? <p className="text-sm font-semibold uppercase tracking-[0.18em] opacity-75">{block.data.heading}</p> : null}
89
- <h2 className="text-[clamp(28px,3.4vw,40px)] font-bold leading-tight">{f.title}</h2>
90
- {f.summary ? <p className="text-lg opacity-85">{f.summary}</p> : null}
91
- <Progress fundraiser={f} large />
92
- <p className="flex flex-wrap gap-3 pt-2">
93
- {f.status === "active" ? (
94
- <Link href={`${f.path}#donate`} className="rounded-lg bg-accent px-6 py-3.5 font-semibold text-accent-ink hover:opacity-90">
95
- Donate
96
- </Link>
97
- ) : null}
98
- <Link href={f.path} className="rounded-lg px-6 py-3.5 font-semibold ring-1 ring-line hover:bg-surface">
99
- Learn more
100
- </Link>
101
- </p>
102
- </div>
103
- {f.image ? <Img image={f.image} crop={4 / 3} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
104
- </div>
105
- </Section>
106
- );
107
- }
108
-
109
- export function Donate({ block }: { block: BlockOf<"donate"> }) {
110
- const d = block.data;
111
- return (
112
- <Section>
113
- <div className="max-w-2xl">
114
- <SectionHeading heading={d.heading} intro={d.intro} />
115
- {d.fundraiser ? <p className="mb-6 font-semibold">Supporting: {d.fundraiser.title}</p> : null}
116
- <DonateForm presetsCents={d.presetsCents} minCents={d.minCents} maxCents={d.maxCents} fundraiser={d.fundraiser?.slug} submitLabel={d.submitLabel} />
117
- </div>
118
- </Section>
119
- );
120
- }
@@ -1,83 +0,0 @@
1
- import { directionsUrl, pinned, type BlockOf } from "@the-smithy/hearth";
2
- import { Markdown, Section, SectionHeading, SmartLink } from "@/components/ui";
3
- import { OpenMap } from "./OpenMap";
4
-
5
- const linkStyle = "font-semibold text-highlight underline underline-offset-4";
6
-
7
- export function Location({ block }: { block: BlockOf<"location"> }) {
8
- const d = block.data;
9
- return (
10
- <Section>
11
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} />
12
- <div className="grid gap-10 md:grid-cols-2">
13
- <div className="flex flex-col gap-4 text-lg">
14
- <h3 className="text-xl font-semibold">{d.name}</h3>
15
- <address className="whitespace-pre-line not-italic">{d.address}</address>
16
- {d.hours ? <p className="whitespace-pre-line">{d.hours}</p> : null}
17
- {d.notes ? <Markdown className="opacity-80">{d.notes}</Markdown> : null}
18
- <a href={directionsUrl(d)} rel="noopener" className={linkStyle}>
19
- Get directions
20
- </a>
21
- </div>
22
- {/* Content from before map pins has none: it shows no map until the pin is set in Hearth. */}
23
- {d.showMap && d.position ? <OpenMap places={[{ name: d.name, address: d.address, position: d.position }]} label={`Map of ${d.name}`} className="aspect-[4/3]" /> : null}
24
- </div>
25
- </Section>
26
- );
27
- }
28
-
29
- /** Several places: one map with a pin for each (places without a pin are only listed), and the list beside it. */
30
- export function Locations({ block }: { block: BlockOf<"locations"> }) {
31
- const d = block.data;
32
- const onMap = d.showMap ? pinned(d.items) : [];
33
- return (
34
- <Section>
35
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
36
- <div className={`grid gap-10 ${onMap.length ? "md:grid-cols-[2fr_3fr]" : ""}`}>
37
- {onMap.length ? (
38
- <OpenMap places={onMap} label={d.heading ? `Map: ${d.heading}` : "Map of our locations"} className="aspect-[4/3] md:sticky md:top-24 md:order-2 md:aspect-auto md:h-[min(36rem,80vh)]" />
39
- ) : null}
40
- <ul className={`divide-y divide-line border-y border-line ${onMap.length ? "" : "grid gap-x-10 sm:grid-cols-2 sm:divide-y-0 sm:border-y-0"}`}>
41
- {d.items.map((place, i) => (
42
- <li key={i} className="flex flex-col gap-2 py-6">
43
- <h3 className="text-xl font-semibold">{place.name}</h3>
44
- <address className="whitespace-pre-line not-italic">{place.address}</address>
45
- {place.hours ? <p className="whitespace-pre-line">{place.hours}</p> : null}
46
- {place.notes ? <p className="whitespace-pre-line opacity-80">{place.notes}</p> : null}
47
- <p className="mt-1 flex flex-wrap gap-x-6 gap-y-2">
48
- <a href={directionsUrl(place)} rel="noopener" className={linkStyle}>
49
- Get directions<span className="sr-only"> to {place.name}</span>
50
- </a>
51
- {place.link ? (
52
- <SmartLink href={place.link.href} className={linkStyle}>
53
- {place.link.label}
54
- </SmartLink>
55
- ) : null}
56
- </p>
57
- </li>
58
- ))}
59
- </ul>
60
- </div>
61
- </Section>
62
- );
63
- }
64
-
65
- export function Schedule({ block }: { block: BlockOf<"schedule"> }) {
66
- const d = block.data;
67
- return (
68
- <Section>
69
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
70
- <ol className="max-w-3xl divide-y divide-line border-y border-line">
71
- {d.items.map((item, i) => (
72
- <li key={i} className="grid gap-1 py-5 sm:grid-cols-[10rem_1fr] sm:gap-6">
73
- <p className="font-semibold text-highlight">{item.time}</p>
74
- <div>
75
- <p className="text-lg font-semibold">{item.title}</p>
76
- {item.details ? <p className="mt-1 opacity-80">{item.details}</p> : null}
77
- </div>
78
- </li>
79
- ))}
80
- </ol>
81
- </Section>
82
- );
83
- }
@@ -1,137 +0,0 @@
1
- import Link from "next/link";
2
- import { Fragment, type ReactNode } from "react";
3
- import ReactMarkdown from "react-markdown";
4
- import { focalPosition, type ImageRef } from "@the-smithy/hearth";
5
-
6
- type LinkRef = { label: string; href: string };
7
- export type Tone = "default" | "muted" | "inverse" | "accent";
8
-
9
- const toneClass: Record<Tone, string> = {
10
- default: "bg-bg text-ink",
11
- muted: "bg-muted text-ink",
12
- inverse: "bg-inverse text-inverse-ink",
13
- accent: "bg-accent text-accent-ink",
14
- };
15
-
16
- export const isDark = (tone?: Tone) => tone === "inverse" || tone === "accent";
17
-
18
- /** A full-width band with the site's content width inside. */
19
- export function Section({ tone = "default", id, className = "", children }: { tone?: Tone; id?: string; className?: string; children: ReactNode }) {
20
- return (
21
- <section id={id} className={`${toneClass[tone]} ${id ? "scroll-mt-8" : ""}`}>
22
- <div className={`mx-auto max-w-site px-4 py-16 sm:px-6 sm:py-20 ${className}`}>{children}</div>
23
- </section>
24
- );
25
- }
26
-
27
- /**
28
- * Headings may mark words with *asterisks* to highlight them:
29
- * "Fresh bread, *baked daily*".
30
- */
31
- export function Emphasis({ text, className = "text-highlight" }: { text: string; className?: string }) {
32
- return (
33
- <>
34
- {text.split(/\*([^*]+)\*/g).map((part, i) =>
35
- i % 2 ? (
36
- <span key={i} className={className}>
37
- {part}
38
- </span>
39
- ) : (
40
- <Fragment key={i}>{part}</Fragment>
41
- ),
42
- )}
43
- </>
44
- );
45
- }
46
-
47
- /** The text without highlight markers, for alt text and titles. */
48
- export const plain = (text: string) => text.replace(/\*([^*]+)\*/g, "$1");
49
-
50
- export function SectionHeading({ eyebrow, heading, intro, dark }: { eyebrow?: string; heading?: string; intro?: string; dark?: boolean }) {
51
- if (!eyebrow && !heading && !intro) return null;
52
- return (
53
- <div className="mb-10 max-w-3xl">
54
- {eyebrow ? <p className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] opacity-75">{eyebrow}</p> : null}
55
- {heading ? (
56
- <h2 className="text-[clamp(28px,3.4vw,40px)] font-bold leading-tight tracking-tight">
57
- <Emphasis text={heading} className={dark ? "underline decoration-2 underline-offset-4" : "text-highlight"} />
58
- </h2>
59
- ) : null}
60
- {intro ? <p className="mt-4 text-lg leading-relaxed opacity-85">{intro}</p> : null}
61
- </div>
62
- );
63
- }
64
-
65
- export function Markdown({ children, className = "" }: { children: string; className?: string }) {
66
- return (
67
- <div className={`prose text-lg ${className}`}>
68
- <ReactMarkdown>{children}</ReactMarkdown>
69
- </div>
70
- );
71
- }
72
-
73
- const isExternal = (href: string) => /^(https?:|mailto:|tel:)/.test(href);
74
-
75
- /** A link to a path on the site or to anywhere else. */
76
- export function SmartLink({ href, className, children }: { href: string; className?: string; children: ReactNode }) {
77
- return isExternal(href) ? (
78
- <a href={href} className={className}>
79
- {children}
80
- </a>
81
- ) : (
82
- <Link href={href} className={className}>
83
- {children}
84
- </Link>
85
- );
86
- }
87
-
88
- export function Button({ link, variant = "primary", dark }: { link?: LinkRef; variant?: "primary" | "secondary"; dark?: boolean }) {
89
- if (!link) return null;
90
- const cls =
91
- variant === "primary"
92
- ? dark
93
- ? "rounded-lg bg-inverse-ink px-6 py-3.5 font-semibold text-inverse hover:opacity-90"
94
- : "rounded-lg bg-accent px-6 py-3.5 font-semibold text-accent-ink hover:opacity-90"
95
- : "rounded-lg border-[1.5px] border-current/40 px-6 py-3.5 font-medium hover:border-current";
96
- return (
97
- <SmartLink href={link.href} className={`inline-block ${cls}`}>
98
- {link.label}
99
- </SmartLink>
100
- );
101
- }
102
-
103
- /**
104
- * An image from Hearth. Its width and height (from Hearth's media library) reserve its space, so the page doesn't jump
105
- * as it loads. `priority` loads it first: use it for the image at the top of the page (the hero), which is usually
106
- * the largest thing on screen; everything else loads as it scrolls into view.
107
- *
108
- * `crop` is for images cropped to fill a frame (`object-cover`): they keep the focal point set in Hearth's media
109
- * library in view. Give the frame's aspect ratio (`crop={4 / 3}` for `aspect-[4/3]`) to centre the focal point, or
110
- * `crop` alone for frames whose shape changes with the screen (a full-bleed hero). Leave it off for uncropped images.
111
- */
112
- /* eslint-disable @next/next/no-img-element -- Hearth serves media from its CDN at the uploaded size */
113
- export function Img({
114
- image,
115
- className = "",
116
- priority = false,
117
- crop,
118
- }: {
119
- image: ImageRef;
120
- className?: string;
121
- priority?: boolean;
122
- crop?: number | true;
123
- }) {
124
- const objectPosition = crop ? focalPosition(image, crop === true ? undefined : crop) : undefined;
125
- return (
126
- <img
127
- src={image.src}
128
- alt={image.alt}
129
- {...(image.width && image.height ? { width: image.width, height: image.height } : {})}
130
- loading={priority ? "eager" : "lazy"}
131
- fetchPriority={priority ? "high" : "auto"}
132
- decoding={priority ? "sync" : "async"}
133
- className={className}
134
- style={objectPosition ? { objectPosition } : undefined}
135
- />
136
- );
137
- }
File without changes