@the-smithy/create-hearth-site 0.24.1 → 0.25.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 (53) hide show
  1. package/package.json +3 -3
  2. package/template/AGENTS.md +4 -0
  3. package/template/CLAUDE.md +72 -10
  4. package/template/README.md +18 -4
  5. package/template/package.json +3 -2
  6. package/template/scripts/hearth.mjs +56 -8
  7. package/template/src/app/donate/thank-you/page.tsx +1 -1
  8. package/template/src/app/globals.css +1 -0
  9. package/template/src/app/layout.tsx +1 -1
  10. package/template/src/app/not-found.tsx +1 -1
  11. package/template/src/app/registered/page.tsx +1 -1
  12. package/template/src/hearth/base.css +66 -0
  13. package/template/src/hearth/blocks/collections.tsx +6 -5
  14. package/template/src/hearth/blocks/content.tsx +64 -28
  15. package/template/src/hearth/blocks/custom.tsx +9 -1
  16. package/template/src/hearth/blocks/events.tsx +25 -17
  17. package/template/src/hearth/blocks/giving.tsx +24 -20
  18. package/template/src/hearth/blocks/index.tsx +29 -4
  19. package/template/src/hearth/blocks/info.tsx +15 -13
  20. package/template/src/hearth/blocks/media.tsx +10 -9
  21. package/template/src/hearth/components/EventView.tsx +61 -29
  22. package/template/src/hearth/components/FundraiserView.tsx +77 -0
  23. package/template/src/hearth/components/NotHere.tsx +9 -0
  24. package/template/src/hearth/components/PageContent.tsx +23 -0
  25. package/template/src/hearth/components/Registration.tsx +3 -2
  26. package/template/src/hearth/components/Share.tsx +11 -11
  27. package/template/src/hearth/format.ts +5 -3
  28. package/template/src/hearth/forms/ContactForm.tsx +5 -3
  29. package/template/src/hearth/forms/DonateForm.tsx +49 -32
  30. package/template/src/hearth/forms/DonationForm.tsx +13 -0
  31. package/template/src/hearth/forms/RegistrationForm.tsx +16 -14
  32. package/template/src/hearth/forms/VolunteerForm.tsx +10 -8
  33. package/template/src/hearth/forms/fields.tsx +17 -5
  34. package/template/src/hearth/hearth.css +3 -47
  35. package/template/src/hearth/layout/MobileNav.tsx +1 -1
  36. package/template/src/hearth/layout/NavLink.tsx +6 -2
  37. package/template/src/hearth/layout/RootLayout.tsx +8 -32
  38. package/template/src/hearth/layout/SiteFooter.tsx +13 -6
  39. package/template/src/hearth/layout/SiteFrame.tsx +48 -0
  40. package/template/src/hearth/layout/SiteHeader.tsx +9 -4
  41. package/template/src/hearth/overrides.ts +48 -0
  42. package/template/src/hearth/pages/donate.tsx +24 -15
  43. package/template/src/hearth/pages/event.tsx +3 -2
  44. package/template/src/hearth/pages/events.tsx +22 -10
  45. package/template/src/hearth/pages/fundraiser.tsx +10 -63
  46. package/template/src/hearth/pages/fundraisers.tsx +21 -11
  47. package/template/src/hearth/pages/not-found.tsx +28 -8
  48. package/template/src/hearth/pages/page.tsx +9 -15
  49. package/template/src/hearth/pages/registered.tsx +23 -10
  50. package/template/src/hearth/pages/thank-you.tsx +23 -10
  51. package/template/src/hearth/theme/defaults.ts +125 -2
  52. package/template/src/hearth/ui.tsx +20 -5
  53. package/template/src/theme/overrides.tsx +19 -5
@@ -1,77 +1,24 @@
1
1
  import { metadataFromMeta } from "@the-smithy/hearth/next";
2
- import { JsonLd } from "@the-smithy/hearth/react";
3
- import { theme } from "@/theme";
4
- import { Blocks } from "../blocks";
5
- import { Progress } from "../blocks/giving";
6
2
  import { hearthNext } from "../client";
7
- import { Share } from "../components/Share";
8
- import { DonateForm } from "../forms/DonateForm";
9
- import { money } from "../format";
10
- import { hasFeature, siteUrl } from "../site";
11
- import { fill, Img, Section } from "../ui";
3
+ import { FundraiserView } from "../components/FundraiserView";
4
+ import { hasFeature } from "../site";
5
+ import { generateMetadata as notFoundMetadata } from "./not-found";
12
6
 
13
7
  type Props = { params: Promise<{ slug: string }> };
14
8
 
15
9
  async function load(slug: string) {
16
10
  const site = await hearthNext.getSite();
17
- return hasFeature(site, "giving") ? { site, fundraiser: await hearthNext.getFundraiser(slug) } : null;
11
+ return hasFeature(site, "giving") ? hearthNext.getFundraiser(slug) : null;
18
12
  }
19
13
 
20
14
  export async function generateMetadata({ params }: Props) {
21
- const loaded = await load((await params).slug);
22
- return loaded?.fundraiser ? metadataFromMeta(loaded.fundraiser.meta) : {};
15
+ const fundraiser = await load((await params).slug);
16
+ return fundraiser ? metadataFromMeta(fundraiser.meta) : notFoundMetadata();
23
17
  }
24
18
 
25
- /** A fundraiser's page: progress, Donate and Share, then its story blocks and the donation form. */
26
- export default async function FundraiserPage({ params }: Props) {
19
+ export default async function FundraiserRoute({ params }: Props) {
27
20
  const slug = (await params).slug;
28
- const loaded = await load(slug);
29
- if (!loaded?.fundraiser) return hearthNext.redirectOrNotFound(`/fundraisers/${slug}`);
30
- const { site, fundraiser: f } = loaded;
31
- const giving = site.settings.giving;
32
- const t = theme.fundraiser;
33
- const base = siteUrl(site);
34
- const url = base ? `${base}${f.path}` : null;
35
- const active = f.status === "active";
36
-
37
- return (
38
- <>
39
- <JsonLd data={f.meta.jsonLd} />
40
- <section className={t.header}>
41
- <div className={t.headerInner}>
42
- <div className={t.details}>
43
- <p className={t.label}>{active ? "Fundraiser" : "Completed fundraiser"}</p>
44
- <h1 className={t.title}>{f.title}</h1>
45
- {f.summary ? <p className={t.summary}>{f.summary}</p> : null}
46
- <Progress fundraiser={f} large />
47
- {active ? (
48
- <div className={t.actions}>
49
- <a href="#donate" className={theme.button.primary}>
50
- {theme.text.donate}
51
- </a>
52
- {url ? (
53
- <Share
54
- title={f.title}
55
- url={url}
56
- message={`I'm supporting ${f.title}${f.goalCents ? `: ${money(f.raisedCents)} raised of ${money(f.goalCents)} so far` : ""}. Give here: ${url}`}
57
- heading="Share this fundraiser"
58
- />
59
- ) : null}
60
- </div>
61
- ) : null}
62
- </div>
63
- {f.image ? <Img image={f.image} crop={4 / 3} className={t.image} /> : null}
64
- </div>
65
- </section>
66
- <Blocks blocks={f.blocks} />
67
- {active ? (
68
- <Section block="fundraiserDonate">
69
- <div className={t.give}>
70
- <h2 className={t.giveTitle}>{fill(theme.text.giveTo, { fundraiser: f.title })}</h2>
71
- <DonateForm presetsCents={giving.presetsCents} minCents={giving.minCents} maxCents={giving.maxCents} fundraiser={f.slug} />
72
- </div>
73
- </Section>
74
- ) : null}
75
- </>
76
- );
21
+ const fundraiser = await load(slug);
22
+ if (!fundraiser) return hearthNext.redirectOrNotFound(`/fundraisers/${slug}`);
23
+ return <FundraiserView fundraiser={fundraiser} />;
77
24
  }
@@ -3,36 +3,46 @@ import { notFound } from "next/navigation";
3
3
  import { theme } from "@/theme";
4
4
  import { FundraiserList } from "../blocks/giving";
5
5
  import { hearthNext } from "../client";
6
+ import { PageContent } from "../components/PageContent";
6
7
  import { hasFeature } from "../site";
7
- import { Section } from "../ui";
8
+ import { fill, Section } from "../ui";
8
9
 
9
10
  export async function generateMetadata(): Promise<Metadata> {
10
- const site = await hearthNext.getSite();
11
+ const [site, own] = await Promise.all([hearthNext.getSite(), hearthNext.generatePageMetadata("fundraisers")]);
12
+ if (Object.keys(own).length) return own;
11
13
  return {
12
- title: "Fundraisers",
13
- description: `Fundraisers and campaigns from ${site.name}: see how each is doing and give.`,
14
+ title: theme.text.fundraisersTitle,
15
+ description: fill(theme.text.fundraisersDescription, { site: site.name }),
14
16
  alternates: { canonical: "/fundraisers" },
15
- openGraph: { title: "Fundraisers", url: "/fundraisers" },
17
+ openGraph: { title: theme.text.fundraisersTitle, url: "/fundraisers" },
16
18
  };
17
19
  }
18
20
 
19
- /** Active fundraisers, then completed ones (only on sites with the Giving feature in Hearth). */
21
+ /** Active fundraisers, then completed ones (only on sites with the Giving feature in Hearth), or a page in Hearth at `fundraisers` instead. */
20
22
  export default async function FundraisersPage() {
21
23
  const site = await hearthNext.getSite();
22
24
  if (!hasFeature(site, "giving")) notFound();
25
+ const page = await hearthNext.getPage("fundraisers");
26
+ if (page) {
27
+ return (
28
+ <div data-page="fundraisers">
29
+ <PageContent page={page} />
30
+ </div>
31
+ );
32
+ }
23
33
  const [active, completed] = await Promise.all([hearthNext.getFundraisers({ status: "active" }), hearthNext.getFundraisers({ status: "completed" })]);
24
34
  return (
25
- <>
35
+ <div data-page="fundraisers">
26
36
  <Section block="fundraisers">
27
- <h1 className={theme.page.title}>Fundraisers</h1>
28
- <FundraiserList fundraisers={active} />
37
+ <h1 className={theme.page.title}>{theme.text.fundraisersTitle}</h1>
38
+ <FundraiserList fundraisers={active} heading="h2" />
29
39
  </Section>
30
40
  {completed.length ? (
31
41
  <Section block="fundraisers" tone="muted">
32
- <h2 className={theme.fundraiser.completedTitle}>Completed</h2>
42
+ <h2 className={theme.fundraiser.completedTitle}>{theme.text.completedFundraisers}</h2>
33
43
  <FundraiserList fundraisers={completed} />
34
44
  </Section>
35
45
  ) : null}
36
- </>
46
+ </div>
37
47
  );
38
48
  }
@@ -1,15 +1,35 @@
1
+ import type { Metadata } from "next";
1
2
  import Link from "next/link";
2
3
  import { theme } from "@/theme";
4
+ import { hearthNext } from "../client";
5
+ import { PageBlocks } from "../components/PageContent";
3
6
  import { Section } from "../ui";
4
7
 
5
- export default function NotFound() {
8
+ /**
9
+ * With a page in Hearth at `not-found`, its title and description (Next.js already keeps a 404 out of search results).
10
+ * Pages use it too when they find nothing at their address: this file's own generateMetadata only runs when no route matches.
11
+ */
12
+ export async function generateMetadata(): Promise<Metadata> {
13
+ const own = await hearthNext.getPage("not-found");
14
+ return own ? { title: { absolute: own.meta.fullTitle }, description: own.meta.description ?? undefined, robots: { index: false, follow: false } } : {};
15
+ }
16
+
17
+ /** The 404, always with that status: its words, or the blocks of a page in Hearth at `not-found`. Redirects run before it (pages/page.tsx). */
18
+ export default async function NotFound() {
19
+ const page = await hearthNext.getPage("not-found");
6
20
  return (
7
- <Section block="notFound" innerClassName={theme.page.notFound}>
8
- <h1 className={theme.page.title}>Page not found</h1>
9
- <p className={theme.page.lead}>The page you&apos;re looking for has moved or no longer exists.</p>
10
- <Link href="/" className={theme.button.primary}>
11
- Go to the homepage
12
- </Link>
13
- </Section>
21
+ <div data-page="not-found">
22
+ {page ? (
23
+ <PageBlocks page={page} />
24
+ ) : (
25
+ <Section block="notFound" innerClassName={theme.page.notFound}>
26
+ <h1 className={theme.page.title}>{theme.text.notFoundTitle}</h1>
27
+ <p className={theme.page.lead}>{theme.text.notFoundLead}</p>
28
+ <Link href="/" className={theme.button.primary}>
29
+ {theme.text.notFoundHome}
30
+ </Link>
31
+ </Section>
32
+ )}
33
+ </div>
14
34
  );
15
35
  }
@@ -1,11 +1,11 @@
1
1
  import { normalizeSlug } from "@the-smithy/hearth";
2
2
  import { metadataFromMeta } from "@the-smithy/hearth/next";
3
- import { JsonLd } from "@the-smithy/hearth/react";
4
3
  import type { Metadata } from "next";
5
- import { Blocks } from "../blocks";
6
4
  import { hearthNext } from "../client";
7
5
  import { EventView } from "../components/EventView";
6
+ import { PageContent } from "../components/PageContent";
8
7
  import { hasFeature } from "../site";
8
+ import { generateMetadata as notFoundMetadata } from "./not-found";
9
9
 
10
10
  type Props = { params: Promise<{ slug?: string[] }> };
11
11
 
@@ -24,20 +24,14 @@ async function collectionEvent(slug: string[] | undefined) {
24
24
  return event?.collection?.id === collection.id ? event : null;
25
25
  }
26
26
 
27
+ /** The page in Hearth at this address `not-found` is what the 404 shows (pages/not-found.tsx), never a page of its own. */
28
+ const isServed = (slug: string[] | undefined) => normalizeSlug(slug) !== "not-found";
29
+
27
30
  /** Every page in Hearth at its path ("/" is "home", "/about" is "about", "/blog/post" is a blog entry), plus events in collections. */
28
31
  export default async function Page({ params }: Props) {
29
32
  const slug = (await params).slug;
30
- const page = await hearthNext.getPage(slug);
31
- if (page) {
32
- return (
33
- <>
34
- <JsonLd data={page.meta.jsonLd} />
35
- {/* Pages usually open with a Hero (the visible title); this keeps a heading for the rest. */}
36
- {page.blocks[0]?.type === "hero" ? null : <h1 className="sr-only">{page.title}</h1>}
37
- <Blocks blocks={page.blocks} />
38
- </>
39
- );
40
- }
33
+ const page = isServed(slug) ? await hearthNext.getPage(slug) : null;
34
+ if (page) return <PageContent page={page} />;
41
35
 
42
36
  const event = await collectionEvent(slug);
43
37
  if (event) return <EventView event={event} />;
@@ -47,8 +41,8 @@ export default async function Page({ params }: Props) {
47
41
 
48
42
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
49
43
  const slug = (await params).slug;
50
- const metadata = await hearthNext.generatePageMetadata(slug);
44
+ const metadata = isServed(slug) ? await hearthNext.generatePageMetadata(slug) : {};
51
45
  if (Object.keys(metadata).length) return metadata;
52
46
  const event = await collectionEvent(slug);
53
- return event ? metadataFromMeta(event.meta) : {};
47
+ return event ? metadataFromMeta(event.meta) : notFoundMetadata();
54
48
  }
@@ -1,19 +1,32 @@
1
1
  import type { Metadata } from "next";
2
2
  import Link from "next/link";
3
3
  import { theme } from "@/theme";
4
+ import { hearthNext } from "../client";
5
+ import { PageContent } from "../components/PageContent";
4
6
  import { Section } from "../ui";
5
7
 
6
- export const metadata: Metadata = { title: "You're registered", robots: { index: false, follow: false } };
8
+ /** Never in search results, whatever the page in Hearth says. */
9
+ export async function generateMetadata(): Promise<Metadata> {
10
+ const own = await hearthNext.generatePageMetadata("registered");
11
+ return { ...(Object.keys(own).length ? own : { title: theme.text.registeredTitle }), robots: { index: false, follow: false } };
12
+ }
7
13
 
8
- /** Where the payment page sends people after paying for a ticket. The confirmation is emailed once payment completes. */
9
- export default function RegisteredPage() {
14
+ /** Where the payment page sends people after paying for a ticket. The confirmation is emailed once payment completes. A page in Hearth at `registered` replaces these words. */
15
+ export default async function Page() {
16
+ const page = await hearthNext.getPage("registered");
10
17
  return (
11
- <Section block="registered">
12
- <h1 className={theme.page.title}>You&apos;re registered</h1>
13
- <p className={theme.page.lead}>Thanks for signing up. A confirmation is on its way to your email.</p>
14
- <Link href="/events" className={theme.page.back}>
15
- See all events
16
- </Link>
17
- </Section>
18
+ <div data-page="registered">
19
+ {page ? (
20
+ <PageContent page={page} />
21
+ ) : (
22
+ <Section block="registered">
23
+ <h1 className={theme.page.title}>{theme.text.registeredTitle}</h1>
24
+ <p className={theme.page.lead}>{theme.text.registeredLead}</p>
25
+ <Link href="/events" className={theme.page.back}>
26
+ {theme.text.registeredBack}
27
+ </Link>
28
+ </Section>
29
+ )}
30
+ </div>
18
31
  );
19
32
  }
@@ -1,19 +1,32 @@
1
1
  import type { Metadata } from "next";
2
2
  import Link from "next/link";
3
3
  import { theme } from "@/theme";
4
+ import { hearthNext } from "../client";
5
+ import { PageContent } from "../components/PageContent";
4
6
  import { Section } from "../ui";
5
7
 
6
- export const metadata: Metadata = { title: "Thank you", robots: { index: false, follow: false } };
8
+ /** Never in search results, whatever the page in Hearth says. */
9
+ export async function generateMetadata(): Promise<Metadata> {
10
+ const own = await hearthNext.generatePageMetadata("donate/thank-you");
11
+ return { ...(Object.keys(own).length ? own : { title: theme.text.thankYouTitle }), robots: { index: false, follow: false } };
12
+ }
7
13
 
8
- /** Where the payment page sends donors back. The receipt is emailed once the payment completes. */
9
- export default function ThankYouPage() {
14
+ /** Where the payment page sends donors back. The receipt is emailed once the payment completes. A page in Hearth at `donate/thank-you` replaces these words. */
15
+ export default async function Page() {
16
+ const page = await hearthNext.getPage("donate/thank-you");
10
17
  return (
11
- <Section block="thankYou">
12
- <h1 className={theme.page.title}>Thank you</h1>
13
- <p className={theme.page.lead}>Your gift makes a difference. If you gave your email, a receipt is on its way.</p>
14
- <Link href="/" className={theme.page.back}>
15
- Back to the homepage
16
- </Link>
17
- </Section>
18
+ <div data-page="thank-you">
19
+ {page ? (
20
+ <PageContent page={page} />
21
+ ) : (
22
+ <Section block="thankYou">
23
+ <h1 className={theme.page.title}>{theme.text.thankYouTitle}</h1>
24
+ <p className={theme.page.lead}>{theme.text.thankYouLead}</p>
25
+ <Link href="/" className={theme.page.back}>
26
+ {theme.text.thankYouBack}
27
+ </Link>
28
+ </Section>
29
+ )}
30
+ </div>
18
31
  );
19
32
  }
@@ -8,8 +8,14 @@
8
8
  * data-[size=full]: data-[size=small]: data-[width=narrow]: data-[columns=4]: data-[fit=contain]:
9
9
  * data-highlight: data-cancelled: data-full: data-flip: aria-invalid: aria-[current=page]: has-checked:
10
10
  * in-data-[…]: the same, set on an ancestor (Tailwind's in-* variant)
11
+ * For CSS that picks out something in particular (site CSS in src/theme/theme.css, or in-data-[…]: in a slot):
12
+ * [data-block="hero"] a block's type, with [data-tone] and, on a hero, [data-size] beside it
13
+ * [data-page="events"] the pages the code serves (events, fundraisers, donate, thank-you, registered, not-found)
14
+ * [data-card="our-history"] a card in a Cards block, by its title; [data-icon="graduation-cap"] by its Icon in Hearth
11
15
  * Slots named `tone` aren't classes: they pick the ground (default, muted, inverse or accent) a block sits on.
12
- * Slots under `text` are the words the code shows (labels, buttons), for sites that word them differently.
16
+ * Slots under `text` are the words the code shows (labels, buttons), for sites that word them differently; {name} marks a
17
+ * value the code fills in. A few others hold a setting, not classes: `page.themeColor`, `page.externalLinks`,
18
+ * `page.navMatch`, `page.dateStyle` and `map.style`.
13
19
  */
14
20
 
15
21
  const card = "rounded-2xl bg-surface ring-1 ring-line on-dark:bg-white/10 on-dark:ring-0";
@@ -38,6 +44,14 @@ export const defaults = {
38
44
  lead: "-mt-6 mb-10 max-w-2xl text-lg opacity-85",
39
45
  back: more,
40
46
  notFound: "py-12",
47
+ /** A setting: the browser's color around the page on phones, e.g. "#1c1c1a". Empty for none. */
48
+ themeColor: "",
49
+ /** A setting: "new-tab" opens web links off the site in a new tab (buttons, footer links, logos, registration, directions); "same-tab" doesn't. Rich text and social links stay as they are. */
50
+ externalLinks: "same-tab",
51
+ /** A setting: when a menu link counts as the current page. "prefix": on its address and the ones under it; "exact": only its own. */
52
+ navMatch: "prefix",
53
+ /** A setting: event dates, "short" (Sat, Oct 18, 2026) or "long" (Saturday, October 18, 2026). */
54
+ dateStyle: "short",
41
55
  },
42
56
  header: {
43
57
  /** The header's ground: `tones` colors it, and inverse or accent turn on on-dark: inside it. */
@@ -52,12 +66,14 @@ export const defaults = {
52
66
  nav: "hidden flex-wrap items-center justify-end gap-x-6 gap-y-1 font-medium md:flex",
53
67
  link: "py-2 hover:text-highlight aria-[current=page]:text-highlight",
54
68
  cta: "rounded-lg bg-accent px-4 py-2 font-semibold text-accent-ink hover:opacity-90",
69
+ /** What a site inside an app adds beside the menu, at every width (see SiteFrame). */
70
+ actions: "flex items-center gap-2",
55
71
  /** A menu item with a dropdown (a global block in a panel): the item, its link, the button that opens it, the panel. */
56
72
  dropdown: "relative flex items-center gap-0.5",
57
73
  dropdownLink: "py-2 hover:text-highlight aria-[current=page]:text-highlight",
58
74
  dropdownToggle: "inline-flex size-8 items-center justify-center rounded-md hover:text-highlight aria-expanded:rotate-180",
59
75
  dropdownPanel:
60
- "absolute right-0 top-full z-40 mt-2 max-h-[80dvh] w-[min(32rem,calc(100vw-2rem))] overflow-y-auto rounded-2xl text-ink shadow-xl ring-1 ring-line [&_h2]:text-2xl [&_section>div]:px-6 [&_section>div]:py-6",
76
+ "absolute right-0 top-full z-40 mt-2 max-h-[80dvh] w-[min(32rem,calc(100vw-2rem))] overflow-y-auto rounded-2xl text-ink shadow-xl ring-1 ring-line [&_h2:not(li_*)]:text-2xl [&_section>div]:px-6 [&_section>div]:py-6",
61
77
  },
62
78
  /** The menu on phones: a button that opens the links full screen. */
63
79
  mobileNav: {
@@ -100,6 +116,10 @@ export const defaults = {
100
116
  social: "flex flex-col gap-2",
101
117
  socialLink: "opacity-80 hover:opacity-100",
102
118
  copyright: "mx-auto max-w-site px-4 pb-8 text-sm opacity-60 sm:px-6",
119
+ /** Small print under the copyright line (Footer in Hearth's Navigation). */
120
+ note: "mx-auto -mt-6 max-w-site whitespace-pre-line px-4 pb-8 text-sm opacity-60 sm:px-6",
121
+ /** The Logo from src/theme/overrides.tsx, above the site's name. Empty, it isn't drawn, as a header logo often doesn't suit the footer: set classes here to show it. */
122
+ logo: "",
103
123
  },
104
124
 
105
125
  /* ─── Shared pieces ─── */
@@ -107,6 +127,8 @@ export const defaults = {
107
127
  section: {
108
128
  root: "",
109
129
  inner: "mx-auto max-w-site px-4 py-16 sm:px-6 sm:py-20",
130
+ /** On a block with an anchor, or anything else links jump to: the space kept above it (for a sticky header, make it larger). */
131
+ anchored: "scroll-mt-8",
110
132
  },
111
133
  tones: {
112
134
  default: "bg-bg text-ink",
@@ -132,6 +154,8 @@ export const defaults = {
132
154
  /* ─── Blocks ─── */
133
155
  hero: {
134
156
  root: "relative overflow-hidden bg-inverse text-inverse-ink",
157
+ /** In place of `root` on a hero given a tone in Hearth, whose `tones` set its colors. */
158
+ toned: "relative overflow-hidden",
135
159
  image: "absolute inset-0 size-full object-cover opacity-35",
136
160
  inner: "relative mx-auto max-w-site px-4 py-16 sm:px-6 sm:py-20 data-[size=full]:py-28 sm:data-[size=full]:py-36",
137
161
  logo: "mb-6 h-20 w-auto sm:h-24",
@@ -214,6 +238,11 @@ export const defaults = {
214
238
  question: "flex min-h-14 cursor-pointer list-none items-center justify-between gap-4 py-4 text-lg font-semibold [&::-webkit-details-marker]:hidden",
215
239
  icon: "text-highlight transition before:content-['+'] group-open:rotate-45",
216
240
  answer: "prose pb-5 text-lg opacity-85",
241
+ /** Set to show every answer in Hearth: the list, each question and answer, the question, the answer. */
242
+ terms: "grid max-w-3xl gap-8",
243
+ term: "",
244
+ termQuestion: "text-lg font-semibold",
245
+ termAnswer: "prose mt-2 text-lg opacity-85",
217
246
  },
218
247
  quote: {
219
248
  figure: "flex max-w-3xl flex-col gap-6",
@@ -528,5 +557,99 @@ export const defaults = {
528
557
  share: "Share",
529
558
  visitProject: "Visit {name}",
530
559
  mapFailed: "The map couldn't load.",
560
+ mapOf: "Map of {name}",
561
+ mapHeading: "Map: {heading}",
562
+ mapPlaces: "Map of our locations",
563
+ /** Read out after Get directions, in a list of places. */
564
+ directionsTo: "to {name}",
565
+ /** Read out after a link, with `page.externalLinks` set to "new-tab". */
566
+ opensInNewTab: "(opens in a new tab)",
567
+ skipLink: "Skip to content",
568
+ /** Upcoming events limited to a collection, as a link to it. */
569
+ allCollectionEvents: "All {collection}",
570
+
571
+ /* The pages the code serves, until they're replaced by a page in Hearth at the same address. */
572
+ eventsTitle: "Events",
573
+ eventsDescription: "Upcoming events from {site}.",
574
+ fundraisersTitle: "Fundraisers",
575
+ fundraisersDescription: "Fundraisers and campaigns from {site}: see how each is doing and give.",
576
+ completedFundraisers: "Completed",
577
+ donateTitle: "Donate",
578
+ donateDescription: "Support {site} with a donation.",
579
+ donateLead: "Your gift supports {site}.",
580
+ thankYouTitle: "Thank you",
581
+ thankYouLead: "Your gift makes a difference. If you gave your email, a receipt is on its way.",
582
+ thankYouBack: "Back to the homepage",
583
+ registeredTitle: "You're registered",
584
+ registeredLead: "Thanks for signing up. A confirmation is on its way to your email.",
585
+ registeredBack: "See all events",
586
+ notFoundTitle: "Page not found",
587
+ notFoundLead: "The page you're looking for has moved or no longer exists.",
588
+ notFoundHome: "Go to the homepage",
589
+
590
+ /* Fundraisers. */
591
+ fundraiserLabel: "Fundraiser",
592
+ completedFundraiserLabel: "Completed fundraiser",
593
+ raised: "raised",
594
+ raisedOfGoal: "raised of {goal}",
595
+ progressLabel: "Progress toward the goal",
596
+ oneDonation: "{count} online donation",
597
+ donations: "{count} online donations",
598
+
599
+ /* Share (the button's dialog). */
600
+ shareEventHeading: "Share {title}",
601
+ shareEventMessage: "{title}, {date}: {url}",
602
+ shareFundraiserHeading: "Share this fundraiser",
603
+ shareFundraiserMessage: "I'm supporting {title}. Give here: {url}",
604
+ shareFundraiserGoalMessage: "I'm supporting {title}: {raised} raised of {goal} so far. Give here: {url}",
605
+ shareMessageLabel: "Your message",
606
+ shareCopyMessage: "Copy message",
607
+ shareCopyLink: "Copy link",
608
+ shareTheMessage: "Message",
609
+ shareTheLink: "Link",
610
+ shareCopied: "{what} copied.",
611
+ shareCopyFailed: "Couldn't copy automatically. Select the text and copy it.",
612
+ shareTo: "Or share to",
613
+ shareMoreApps: "More apps…",
614
+ shareEmail: "Email",
615
+ shareTextMessage: "Text message",
616
+
617
+ /* Forms. */
618
+ optional: "(optional)",
619
+ chooseOne: "Choose one…",
620
+ honeypot: "Leave this empty",
621
+ sending: "Sending…",
622
+ yourName: "Name",
623
+ yourEmail: "Email",
624
+ yourPhone: "Phone",
625
+ openingCheckout: "Opening secure checkout…",
626
+ /* The donation form. */
627
+ donateAmount: "Amount",
628
+ donateOther: "Other",
629
+ donateCustomAmount: "Amount in dollars",
630
+ donateRange: "{min} to {max}.",
631
+ donateChoice: "Where should your gift go?",
632
+ donateAnywhere: "Where it's needed most",
633
+ donateEmail: "Email for your receipt",
634
+ donateMessage: "Message",
635
+ donateAnonymous: "Don't show my name publicly",
636
+ donateUpdates: "Send me updates about this fundraiser",
637
+ donateNote: "You'll finish on a secure payment page.",
638
+ /* Registration. */
639
+ ticket: "Ticket",
640
+ ticketsLeft: "{count} left",
641
+ teamName: "Team or organization",
642
+ attendee: "Attendee",
643
+ notes: "Notes",
644
+ free: "Free",
645
+ continueToPayment: "Continue to payment",
646
+ registering: "Registering…",
647
+ /* Volunteer sign-up. */
648
+ shiftsMany: "Shifts you can do",
649
+ shiftsOne: "Choose a shift",
650
+ shiftFull: "Full",
651
+ oneSpotLeft: "{count} spot left",
652
+ spotsLeft: "{count} spots left",
653
+ signingUp: "Signing up…",
531
654
  },
532
655
  };
@@ -1,6 +1,7 @@
1
1
  import Link from "next/link";
2
2
  import { Fragment, type AnchorHTMLAttributes, type ReactNode } from "react";
3
3
  import ReactMarkdown from "react-markdown";
4
+ import remarkGfm from "remark-gfm";
4
5
  import { focalPosition, type ImageRef } from "@the-smithy/hearth";
5
6
  import { theme } from "@/theme";
6
7
  import { cx } from "./theme";
@@ -23,10 +24,13 @@ export const ground = (tone?: Tone) => ({ "data-ground": isDark(tone) ? "dark" :
23
24
  /** Fills {name} placeholders in a theme text: fill(theme.text.giveTo, { fundraiser: f.title }). */
24
25
  export const fill = (text: string, values: Record<string, string>) => text.replace(/\{(\w+)\}/g, (m, k: string) => values[k] ?? m);
25
26
 
26
- /** A full-width band with the site's content width inside. `block` names the block type, for CSS that targets it. */
27
+ /**
28
+ * A full-width band with the site's content width inside. `block` names the block type, for CSS that targets it; `id`
29
+ * is what links jump to: blocks pass their anchor from Hearth (`block.anchor`).
30
+ */
27
31
  export function Section({ tone = "default", id, block, className, innerClassName, children }: { tone?: Tone; id?: string; block?: string; className?: string; innerClassName?: string; children: ReactNode }) {
28
32
  return (
29
- <section id={id} data-block={block} {...ground(tone)} className={cx(theme.tones[tone], theme.section.root, id && "scroll-mt-8", className)}>
33
+ <section id={id} data-block={block} {...ground(tone)} className={cx(theme.tones[tone], theme.section.root, id && theme.section.anchored, className)}>
30
34
  <div className={cx(theme.section.inner, innerClassName)}>{children}</div>
31
35
  </section>
32
36
  );
@@ -75,19 +79,30 @@ export function SectionHeading({ eyebrow, heading, intro }: { eyebrow?: string;
75
79
  export function Markdown({ children, className = theme.markdown.root, ...data }: { children: string; className?: string } & Record<`data-${string}`, string | undefined>) {
76
80
  return (
77
81
  <div className={className} {...data}>
78
- <ReactMarkdown>{children}</ReactMarkdown>
82
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
79
83
  </div>
80
84
  );
81
85
  }
82
86
 
83
87
  export const isExternal = (href: string) => /^(https?:|mailto:|tel:)/.test(href);
84
88
 
89
+ /** A plain link off the site. With `page.externalLinks` set to "new-tab", web links open in a new tab and say so to screen readers. */
90
+ export function ExternalLink({ href, rel, children, ...rest }: { href: string; children: ReactNode } & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href">) {
91
+ const newTab = theme.page.externalLinks === "new-tab" && /^https?:/.test(href);
92
+ return (
93
+ <a href={href} rel={newTab ? (rel ?? "noopener") : rel} target={newTab ? "_blank" : undefined} {...rest}>
94
+ {children}
95
+ {newTab ? <span className="sr-only"> {theme.text.opensInNewTab}</span> : null}
96
+ </a>
97
+ );
98
+ }
99
+
85
100
  /** A link to a path on the site or to anywhere else. */
86
101
  export function SmartLink({ href, className, children, ...rest }: { href: string; className?: string; children: ReactNode } & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href">) {
87
102
  return isExternal(href) ? (
88
- <a href={href} className={className} {...rest}>
103
+ <ExternalLink href={href} className={className} {...rest}>
89
104
  {children}
90
- </a>
105
+ </ExternalLink>
91
106
  ) : (
92
107
  <Link href={href} className={className} {...rest}>
93
108
  {children}
@@ -3,14 +3,28 @@ import type { ComponentType } from "react";
3
3
  import type { Site } from "@the-smithy/hearth";
4
4
 
5
5
  /**
6
- * The last resort, when a design can't be reached with slots and tokens: replace a whole block's component, or draw
7
- * the header's logo yourself. Replaced components are this site's own code: `pnpm hearth:update` won't update them,
8
- * so check them against src/hearth/blocks after each update. Build them from src/hearth/ui.tsx and the theme.
6
+ * The last resort, when a design can't be reached with slots and tokens: replace a whole block's component, or the
7
+ * parts below. Replaced components are this site's own code: `pnpm hearth:update` won't update them, so check them
8
+ * against src/hearth after each update. Build them from src/hearth/ui.tsx and the theme, and say why in SITE.md.
9
9
  *
10
10
  * import { CountingStats } from "./CountingStats";
11
11
  * export const blocks: Partial<BlockComponents> = { stats: CountingStats };
12
+ *
13
+ * Export any of these too, used everywhere the template draws them (types in src/hearth/overrides.ts). They can import
14
+ * Hearth's components, Blocks too. Words, dates and hiding things are slots: try those first.
15
+ *
16
+ * export const Footer: Overrides["Footer"] = ({ site }) => … the whole footer
17
+ * export const EventPage: Overrides["EventPage"] = ({ event }) => … an event's page, reusing EventRegisterSection
18
+ * and EventVolunteerSection from EventView
19
+ * export const FundraiserPage: Overrides["FundraiserPage"] = ({ fundraiser }) => …
20
+ * export const EventCard: Overrides["EventCard"] = async ({ event, heading }) => <li>…</li>
21
+ * export const FundraiserCard: Overrides["FundraiserCard"] = ({ fundraiser, heading }) => <li>…</li>
22
+ * export const DonateForm: Overrides["DonateForm"] = (props) => …
12
23
  */
13
24
  export const blocks: Partial<BlockComponents> = {};
14
25
 
15
- /** The header's logo; leave null to show the logo uploaded in Hearth (Settings → Search & sharing) and the site name. */
16
- export const Logo: ComponentType<{ site: Site }> | null = null;
26
+ /**
27
+ * The header's logo; leave null to show the logo uploaded in Hearth (Settings → Search & sharing) and the site name.
28
+ * Set the `footer.logo` slot to draw it in the footer too (`place` says which).
29
+ */
30
+ export const Logo: ComponentType<{ site: Site; place: "header" | "footer" }> | null = null;