@the-smithy/create-hearth-site 0.0.0-stage → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +36 -2
  3. package/bin/create-hearth-site.js +120 -0
  4. package/package.json +34 -3
  5. package/template/.env.example +8 -0
  6. package/template/CLAUDE.md +37 -0
  7. package/template/README.md +68 -0
  8. package/template/_gitignore +11 -0
  9. package/template/_pnpm-workspace.yaml +2 -0
  10. package/template/netlify.toml +6 -0
  11. package/template/next.config.ts +27 -0
  12. package/template/package.json +32 -0
  13. package/template/postcss.config.mjs +3 -0
  14. package/template/src/app/[[...slug]]/page.tsx +54 -0
  15. package/template/src/app/actions.ts +43 -0
  16. package/template/src/app/api/preview/exit/route.ts +4 -0
  17. package/template/src/app/api/preview/route.ts +8 -0
  18. package/template/src/app/api/revalidate/route.ts +8 -0
  19. package/template/src/app/donate/page.tsx +27 -0
  20. package/template/src/app/donate/thank-you/page.tsx +22 -0
  21. package/template/src/app/events/[slug]/page.tsx +25 -0
  22. package/template/src/app/events/page.tsx +21 -0
  23. package/template/src/app/fundraisers/[slug]/page.tsx +55 -0
  24. package/template/src/app/fundraisers/page.tsx +29 -0
  25. package/template/src/app/globals.css +75 -0
  26. package/template/src/app/layout.tsx +39 -0
  27. package/template/src/app/not-found.tsx +14 -0
  28. package/template/src/app/registered/page.tsx +22 -0
  29. package/template/src/app/robots.ts +10 -0
  30. package/template/src/app/sitemap.ts +4 -0
  31. package/template/src/components/DonateForm.tsx +97 -0
  32. package/template/src/components/EventView.tsx +48 -0
  33. package/template/src/components/Registration.tsx +42 -0
  34. package/template/src/components/RegistrationForm.tsx +103 -0
  35. package/template/src/components/SiteFooter.tsx +61 -0
  36. package/template/src/components/SiteHeader.tsx +31 -0
  37. package/template/src/components/blocks/EmberFormBlock.tsx +38 -0
  38. package/template/src/components/blocks/Form.tsx +92 -0
  39. package/template/src/components/blocks/collections.tsx +75 -0
  40. package/template/src/components/blocks/content.tsx +257 -0
  41. package/template/src/components/blocks/custom.tsx +20 -0
  42. package/template/src/components/blocks/events.tsx +92 -0
  43. package/template/src/components/blocks/giving.tsx +120 -0
  44. package/template/src/components/blocks/index.tsx +65 -0
  45. package/template/src/components/blocks/info.tsx +52 -0
  46. package/template/src/components/blocks/media.tsx +107 -0
  47. package/template/src/components/ui.tsx +105 -0
  48. package/template/src/lib/hearth.ts +12 -0
  49. package/template/src/lib/site.ts +9 -0
  50. package/template/tsconfig.json +21 -0
@@ -0,0 +1,120 @@
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} 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} 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
+ }
@@ -0,0 +1,65 @@
1
+ import type { BlockOf, ResolvedBlock } from "@the-smithy/hearth";
2
+ import { HearthBlocks, type BlockComponents } from "@the-smithy/hearth/react";
3
+ import { Section, SectionHeading } from "@/components/ui";
4
+ import { CollectionList } from "./collections";
5
+ import { CallToAction, CardGrid, Faq, Hero, MediaText, Pricing, Projects, Quote, RichText, Stats } from "./content";
6
+ import { EventSpotlight, UpcomingEvents } from "./events";
7
+ import { CustomBlock } from "./custom";
8
+ import { EmberFormBlock } from "./EmberFormBlock";
9
+ import { HearthForm } from "./Form";
10
+ import { Donate, FundraiserGrid, FundraiserSpotlight } from "./giving";
11
+ import { Location, Schedule } from "./info";
12
+ import { Embed, Gallery, Logos, People, Video } from "./media";
13
+
14
+ function FormBlock({ block }: { block: BlockOf<"form"> }) {
15
+ return (
16
+ <Section>
17
+ <div className="max-w-2xl">
18
+ <SectionHeading heading={block.data.heading} intro={block.data.intro} />
19
+ {/* Set to an Ember form in Hearth: Ember renders it and keeps the leads; the site's form is the fallback. */}
20
+ {block.data.emberFormId ? (
21
+ <EmberFormBlock formId={block.data.emberFormId} fallback={<HearthForm form={block.data} />} />
22
+ ) : (
23
+ <HearthForm form={block.data} />
24
+ )}
25
+ </div>
26
+ </Section>
27
+ );
28
+ }
29
+
30
+ /**
31
+ * One component per Hearth block type. Restyle them freely; to leave a type out of this
32
+ * site, delete its line. Types without a component (including ones Hearth adds later)
33
+ * render nothing.
34
+ */
35
+ export const blocks: BlockComponents = {
36
+ hero: Hero,
37
+ richText: RichText,
38
+ mediaText: MediaText,
39
+ stats: Stats,
40
+ cardGrid: CardGrid,
41
+ cta: CallToAction,
42
+ faq: Faq,
43
+ pricing: Pricing,
44
+ projects: Projects,
45
+ quote: Quote,
46
+ gallery: Gallery,
47
+ video: Video,
48
+ embed: Embed,
49
+ people: People,
50
+ logos: Logos,
51
+ location: Location,
52
+ schedule: Schedule,
53
+ form: FormBlock,
54
+ upcomingEvents: UpcomingEvents,
55
+ eventSpotlight: EventSpotlight,
56
+ collectionList: CollectionList,
57
+ fundraiserSpotlight: FundraiserSpotlight,
58
+ fundraiserGrid: FundraiserGrid,
59
+ donate: Donate,
60
+ custom: CustomBlock,
61
+ };
62
+
63
+ export function Blocks({ blocks: list }: { blocks: ResolvedBlock[] }) {
64
+ return <HearthBlocks blocks={list} components={blocks} fallback={() => null} />;
65
+ }
@@ -0,0 +1,52 @@
1
+ import type { BlockOf } from "@the-smithy/hearth";
2
+ import { Section, SectionHeading } from "@/components/ui";
3
+
4
+ export function Location({ block }: { block: BlockOf<"location"> }) {
5
+ const d = block.data;
6
+ const query = encodeURIComponent(`${d.name}, ${d.address}`);
7
+ return (
8
+ <Section>
9
+ <SectionHeading eyebrow={d.eyebrow} heading={d.heading} />
10
+ <div className="grid gap-10 md:grid-cols-2">
11
+ <div className="flex flex-col gap-4 text-lg">
12
+ <h3 className="text-xl font-semibold">{d.name}</h3>
13
+ <address className="whitespace-pre-line not-italic">{d.address}</address>
14
+ {d.hours ? <p className="whitespace-pre-line">{d.hours}</p> : null}
15
+ {d.notes ? <p className="opacity-80">{d.notes}</p> : null}
16
+ <a href={`https://www.google.com/maps/dir/?api=1&destination=${query}`} rel="noopener" className="font-semibold text-highlight underline underline-offset-4">
17
+ Get directions
18
+ </a>
19
+ </div>
20
+ {d.showMap ? (
21
+ <iframe
22
+ title={`Map of ${d.name}`}
23
+ src={`https://www.google.com/maps?q=${query}&output=embed`}
24
+ loading="lazy"
25
+ referrerPolicy="no-referrer-when-downgrade"
26
+ className="aspect-[4/3] w-full rounded-xl border-0 bg-muted"
27
+ />
28
+ ) : null}
29
+ </div>
30
+ </Section>
31
+ );
32
+ }
33
+
34
+ export function Schedule({ block }: { block: BlockOf<"schedule"> }) {
35
+ const d = block.data;
36
+ return (
37
+ <Section>
38
+ <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
39
+ <ol className="max-w-3xl divide-y divide-line border-y border-line">
40
+ {d.items.map((item, i) => (
41
+ <li key={i} className="grid gap-1 py-5 sm:grid-cols-[10rem_1fr] sm:gap-6">
42
+ <p className="font-semibold text-highlight">{item.time}</p>
43
+ <div>
44
+ <p className="text-lg font-semibold">{item.title}</p>
45
+ {item.details ? <p className="mt-1 opacity-80">{item.details}</p> : null}
46
+ </div>
47
+ </li>
48
+ ))}
49
+ </ol>
50
+ </Section>
51
+ );
52
+ }
@@ -0,0 +1,107 @@
1
+ import { videoEmbedUrl, type BlockOf } from "@the-smithy/hearth";
2
+ import { HearthEmbed } from "@the-smithy/hearth/react";
3
+ import { Img, Section, SectionHeading } from "@/components/ui";
4
+
5
+ const columns = { "2": "sm:grid-cols-2", "3": "sm:grid-cols-2 lg:grid-cols-3", "4": "sm:grid-cols-2 lg:grid-cols-4" } as const;
6
+
7
+ export function Gallery({ block }: { block: BlockOf<"gallery"> }) {
8
+ const d = block.data;
9
+ return (
10
+ <Section>
11
+ <SectionHeading heading={d.heading} intro={d.intro} />
12
+ <ul className={`grid gap-4 ${columns[d.columns]}`}>
13
+ {d.images.map((item, i) => (
14
+ <li key={i}>
15
+ <figure>
16
+ <Img image={item.image} className="aspect-[4/3] w-full rounded-xl object-cover" />
17
+ {item.caption ? <figcaption className="mt-2 text-sm opacity-75">{item.caption}</figcaption> : null}
18
+ </figure>
19
+ </li>
20
+ ))}
21
+ </ul>
22
+ </Section>
23
+ );
24
+ }
25
+
26
+ export function Video({ block }: { block: BlockOf<"video"> }) {
27
+ const d = block.data;
28
+ const src = videoEmbedUrl(d.url);
29
+ if (!src) return null;
30
+ return (
31
+ <Section>
32
+ <SectionHeading heading={d.heading} />
33
+ <figure className="max-w-4xl">
34
+ <div className="aspect-video overflow-hidden rounded-xl bg-inverse">
35
+ <iframe
36
+ src={src}
37
+ title={d.title}
38
+ loading="lazy"
39
+ className="size-full"
40
+ allow="accelerometer; encrypted-media; gyroscope; picture-in-picture; fullscreen"
41
+ referrerPolicy="strict-origin-when-cross-origin"
42
+ />
43
+ </div>
44
+ {d.caption ? <figcaption className="mt-3 opacity-75">{d.caption}</figcaption> : null}
45
+ </figure>
46
+ </Section>
47
+ );
48
+ }
49
+
50
+ /** Embed code from another service, in the SDK's sandboxed frame (it can't reach the rest of the page). */
51
+ export function Embed({ block }: { block: BlockOf<"embed"> }) {
52
+ const width = { narrow: "max-w-2xl", wide: "max-w-4xl", full: "" }[block.data.width];
53
+ return (
54
+ <Section>
55
+ <figure className={width}>
56
+ <HearthEmbed block={block} className="rounded-xl" />
57
+ {block.data.caption ? <figcaption className="mt-3 opacity-75">{block.data.caption}</figcaption> : null}
58
+ </figure>
59
+ </Section>
60
+ );
61
+ }
62
+
63
+ export function People({ block }: { block: BlockOf<"people"> }) {
64
+ const d = block.data;
65
+ return (
66
+ <Section>
67
+ <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
68
+ <ul className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
69
+ {d.people.map((p, i) => (
70
+ <li key={i} className="flex flex-col gap-3">
71
+ {p.image ? <Img image={p.image} className="aspect-square w-full rounded-2xl object-cover" /> : null}
72
+ <div>
73
+ <h3 className="text-xl font-semibold">{p.name}</h3>
74
+ {p.role ? <p className="opacity-75">{p.role}</p> : null}
75
+ </div>
76
+ {p.bio ? <p className="leading-relaxed opacity-85">{p.bio}</p> : null}
77
+ </li>
78
+ ))}
79
+ </ul>
80
+ </Section>
81
+ );
82
+ }
83
+
84
+ export function Logos({ block }: { block: BlockOf<"logos"> }) {
85
+ const d = block.data;
86
+ return (
87
+ <Section>
88
+ <SectionHeading heading={d.heading} intro={d.intro} />
89
+ <ul className="flex flex-wrap items-center gap-x-12 gap-y-6">
90
+ {d.logos.map((l, i) => {
91
+ const mark = l.logo ? <Img image={{ src: l.logo.src, alt: l.logo.alt || l.name }} className="h-10 w-auto" /> : <span className="text-lg font-semibold">{l.name}</span>;
92
+ return (
93
+ <li key={i}>
94
+ {l.url ? (
95
+ <a href={l.url} rel="noopener" className="block opacity-85 hover:opacity-100">
96
+ {mark}
97
+ </a>
98
+ ) : (
99
+ mark
100
+ )}
101
+ </li>
102
+ );
103
+ })}
104
+ </ul>
105
+ </Section>
106
+ );
107
+ }
@@ -0,0 +1,105 @@
1
+ import Link from "next/link";
2
+ import { Fragment, type ReactNode } from "react";
3
+ import ReactMarkdown from "react-markdown";
4
+
5
+ type LinkRef = { label: string; href: string };
6
+ export type Tone = "default" | "muted" | "inverse" | "accent";
7
+
8
+ const toneClass: Record<Tone, string> = {
9
+ default: "bg-bg text-ink",
10
+ muted: "bg-muted text-ink",
11
+ inverse: "bg-inverse text-inverse-ink",
12
+ accent: "bg-accent text-accent-ink",
13
+ };
14
+
15
+ export const isDark = (tone?: Tone) => tone === "inverse" || tone === "accent";
16
+
17
+ /** A full-width band with the site's content width inside. */
18
+ export function Section({ tone = "default", className = "", children }: { tone?: Tone; className?: string; children: ReactNode }) {
19
+ return (
20
+ <section className={toneClass[tone]}>
21
+ <div className={`mx-auto max-w-site px-4 py-16 sm:px-6 sm:py-20 ${className}`}>{children}</div>
22
+ </section>
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Headings may mark words with *asterisks* to highlight them:
28
+ * "Fresh bread, *baked daily*".
29
+ */
30
+ export function Emphasis({ text, className = "text-highlight" }: { text: string; className?: string }) {
31
+ return (
32
+ <>
33
+ {text.split(/\*([^*]+)\*/g).map((part, i) =>
34
+ i % 2 ? (
35
+ <span key={i} className={className}>
36
+ {part}
37
+ </span>
38
+ ) : (
39
+ <Fragment key={i}>{part}</Fragment>
40
+ ),
41
+ )}
42
+ </>
43
+ );
44
+ }
45
+
46
+ /** The text without highlight markers, for alt text and titles. */
47
+ export const plain = (text: string) => text.replace(/\*([^*]+)\*/g, "$1");
48
+
49
+ export function SectionHeading({ eyebrow, heading, intro, dark }: { eyebrow?: string; heading?: string; intro?: string; dark?: boolean }) {
50
+ if (!eyebrow && !heading && !intro) return null;
51
+ return (
52
+ <div className="mb-10 max-w-3xl">
53
+ {eyebrow ? <p className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] opacity-75">{eyebrow}</p> : null}
54
+ {heading ? (
55
+ <h2 className="text-[clamp(28px,3.4vw,40px)] font-bold leading-tight tracking-tight">
56
+ <Emphasis text={heading} className={dark ? "underline decoration-2 underline-offset-4" : "text-highlight"} />
57
+ </h2>
58
+ ) : null}
59
+ {intro ? <p className="mt-4 text-lg leading-relaxed opacity-85">{intro}</p> : null}
60
+ </div>
61
+ );
62
+ }
63
+
64
+ export function Markdown({ children, className = "" }: { children: string; className?: string }) {
65
+ return (
66
+ <div className={`prose text-lg ${className}`}>
67
+ <ReactMarkdown>{children}</ReactMarkdown>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ const isExternal = (href: string) => /^(https?:|mailto:|tel:)/.test(href);
73
+
74
+ /** A link to a path on the site or to anywhere else. */
75
+ export function SmartLink({ href, className, children }: { href: string; className?: string; children: ReactNode }) {
76
+ return isExternal(href) ? (
77
+ <a href={href} className={className}>
78
+ {children}
79
+ </a>
80
+ ) : (
81
+ <Link href={href} className={className}>
82
+ {children}
83
+ </Link>
84
+ );
85
+ }
86
+
87
+ export function Button({ link, variant = "primary", dark }: { link?: LinkRef; variant?: "primary" | "secondary"; dark?: boolean }) {
88
+ if (!link) return null;
89
+ const cls =
90
+ variant === "primary"
91
+ ? dark
92
+ ? "rounded-lg bg-inverse-ink px-6 py-3.5 font-semibold text-inverse hover:opacity-90"
93
+ : "rounded-lg bg-accent px-6 py-3.5 font-semibold text-accent-ink hover:opacity-90"
94
+ : "rounded-lg border-[1.5px] border-current/40 px-6 py-3.5 font-medium hover:border-current";
95
+ return (
96
+ <SmartLink href={link.href} className={`inline-block ${cls}`}>
97
+ {link.label}
98
+ </SmartLink>
99
+ );
100
+ }
101
+
102
+ /* eslint-disable @next/next/no-img-element -- Hearth serves media from its CDN at the uploaded size */
103
+ export function Img({ image, className = "" }: { image: { src: string; alt: string }; className?: string }) {
104
+ return <img src={image.src} alt={image.alt} loading="lazy" decoding="async" className={className} />;
105
+ }
@@ -0,0 +1,12 @@
1
+ import { createHearthClient } from "@the-smithy/hearth";
2
+ import { createHearthNext } from "@the-smithy/hearth/next";
3
+
4
+ /** The site's content client. Server-side only: the API key is secret. */
5
+ export const hearth = createHearthClient({
6
+ site: process.env.HEARTH_SITE!,
7
+ apiKey: process.env.HEARTH_API_KEY!,
8
+ baseUrl: process.env.HEARTH_BASE_URL || undefined,
9
+ });
10
+
11
+ /** Cached, tagged reads plus metadata, sitemap, revalidation and form helpers. */
12
+ export const hearthNext = createHearthNext(hearth);
@@ -0,0 +1,9 @@
1
+ import type { Site } from "@the-smithy/hearth";
2
+
3
+ /** https://<domain> once the site's domain is set in Hearth; otherwise null. */
4
+ export function siteUrl(site: Pick<Site, "domain">): string | null {
5
+ if (!site.domain) return null;
6
+ return `https://${site.domain.replace(/^https?:\/\//, "").replace(/\/+$/, "")}`;
7
+ }
8
+
9
+ export const hasFeature = (site: Pick<Site, "features">, feature: string) => site.features.includes(feature);
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": false,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": { "@/*": ["./src/*"] }
18
+ },
19
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
20
+ "exclude": ["node_modules"]
21
+ }