@the-smithy/create-hearth-site 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-smithy/create-hearth-site",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Start a new client website powered by Hearth: pnpm create @the-smithy/hearth-site",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -30,9 +30,10 @@
30
30
  ],
31
31
  "devDependencies": {
32
32
  "vitest": "^5.0.3",
33
- "@the-smithy/hearth-schema": "^0.27.0"
33
+ "@the-smithy/hearth-schema": "^0.28.0"
34
34
  },
35
35
  "scripts": {
36
- "test": "node --test \"test/**/*.test.js\" && vitest run test/blocks.test.ts test/overrides.test.ts"
36
+ "test": "node --test \"test/**/*.test.js\" && vitest run test/blocks.test.ts test/overrides.test.ts",
37
+ "blocks:reference": "vitest run --config reference/vitest.config.ts"
37
38
  }
38
39
  }
@@ -86,6 +86,13 @@ Work in this order, and stop at the first step that gets the result:
86
86
  don't reach it, so it falls behind Hearth. Use one only when everything above can't get there, build it from
87
87
  `src/hearth/ui.tsx` and the theme, and say why in `SITE.md`.
88
88
 
89
+ To style blocks without setting up pages for them, open `/hearth/blocks` while `pnpm dev` is running: every block with
90
+ its options and states, in sample content, drawn with this site's theme, so a change in `src/theme` shows at once.
91
+ `/hearth/blocks?block=hero` shows one block. Its **Unstyled** switch (`/hearth/blocks/unstyled`) shows them with no design
92
+ and each element's slot name. Neither is served in production. Global blocks and the site's custom blocks aren't in it.
93
+ Without a browser, the unstyled page is `node_modules/@the-smithy/hearth/block-reference.html`, but it's large: for slots,
94
+ use `src/hearth/theme/defaults.ts`.
95
+
89
96
  Never copy a component out of `src/hearth/` to restyle it. If a design needs a slot or an option Hearth doesn't have,
90
97
  say so: it belongs in Hearth's template for every site (tell the developer to raise it with The Smithy), and an
91
98
  override can bridge the gap meanwhile.
@@ -98,6 +98,7 @@ Keep the API key out of chat: the agent never needs to see it, only `.env.local`
98
98
  | `src/theme/theme.css` | Brand tokens (colors, fonts, content width) and the site's own CSS. **Start here** |
99
99
  | `src/theme/index.ts` | The Tailwind classes of any part of any block, page, form, header or footer, by slot |
100
100
  | `src/hearth/theme/defaults.ts` | Every slot and its default classes: read it to find the slot to change |
101
+ | `/hearth/blocks` | Not a file: while `pnpm dev` runs, this address shows every block on one page with your theme, without setting up pages for them. `?block=hero` shows one. Its **Unstyled** switch shows them with no design, and each element's slot name |
101
102
  | `src/theme/fonts.ts` | Web fonts (next/font) |
102
103
  | `src/theme/custom-blocks.tsx` | Components for the site's own block types (Settings → Custom blocks in Hearth) |
103
104
  | `src/theme/overrides.tsx` | Last resort: replace a whole block's component, the logo, the footer, the event or fundraiser page, their cards or the donation form (see `AGENTS.md`) |
@@ -170,6 +171,7 @@ packages are public on npm, so nothing else is needed to install them.
170
171
 
171
172
  The full reference (API, SDK, every block and its fields, previews, forms) is in Hearth under **API docs**,
172
173
  with a Markdown version to give an AI coding assistant. The SDK also ships `node_modules/@the-smithy/hearth/llms.md`.
174
+ Every block is at `/hearth/blocks` while `pnpm dev` runs, with your theme or unstyled. Hearth has the unstyled page under **Block library**.
173
175
 
174
176
  ## Updating
175
177
 
@@ -0,0 +1,2 @@
1
+ // Managed by Hearth (pnpm hearth:update replaces it): every block with this site's theme, under pnpm dev only. See src/hearth/block-library/page.tsx.
2
+ export { default, metadata } from "@/hearth/block-library/page";
@@ -0,0 +1,14 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const SWITCH = `<span class="view-switch"><span class="view-current">Unstyled</span><a href="/hearth/blocks" onclick="this.href='/hearth/blocks'+location.hash">This site's theme</a></span>`;
5
+
6
+ /**
7
+ * Every block unstyled, with its options, states and slot names, to build against while developing. Only under
8
+ * `pnpm dev`: the page comes from the SDK, so it matches this site's release, and visitors never see it.
9
+ */
10
+ export function GET() {
11
+ if (process.env.NODE_ENV !== "development") return new Response("Not found", { status: 404 });
12
+ const page = readFileSync(path.join(process.cwd(), "node_modules/@the-smithy/hearth/block-reference.html"), "utf8");
13
+ return new Response(page.replace("<!-- hearth:view-switch -->", SWITCH), { headers: { "Content-Type": "text/html; charset=utf-8" } });
14
+ }
@@ -0,0 +1,45 @@
1
+ "use client";
2
+
3
+ import { Component, useEffect, type ReactNode } from "react";
4
+
5
+ /** Nothing in a sample goes anywhere: its links don't navigate, and its forms never reach the site's real inbox in Hearth. */
6
+ export function SampleGuard() {
7
+ useEffect(() => {
8
+ const stop = (e: Event) => {
9
+ const target = e.target as Element;
10
+ if (!target.closest?.("[data-library-sample]")) return;
11
+ if (e.type === "submit" || target.closest("a")) {
12
+ e.preventDefault();
13
+ e.stopPropagation();
14
+ }
15
+ };
16
+ // Before React's own handlers, which is where a form's server action and a next/link run.
17
+ document.addEventListener("click", stop, true);
18
+ document.addEventListener("submit", stop, true);
19
+ return () => {
20
+ document.removeEventListener("click", stop, true);
21
+ document.removeEventListener("submit", stop, true);
22
+ };
23
+ }, []);
24
+ return null;
25
+ }
26
+
27
+ /** A link to the block library's other view, staying on the same block. */
28
+ export function ViewLink({ href, children }: { href: string; children: ReactNode }) {
29
+ return (
30
+ <a href={href} className="hearth-library-view" onClick={(e) => (e.currentTarget.href = href + window.location.hash)}>
31
+ {children}
32
+ </a>
33
+ );
34
+ }
35
+
36
+ /** One sample failing (a site without Giving has no fundraisers to offer) leaves the rest of the page working. */
37
+ export class SampleBoundary extends Component<{ children: ReactNode }, { message?: string }> {
38
+ state: { message?: string } = {};
39
+ static getDerivedStateFromError(error: Error) {
40
+ return { message: error.message };
41
+ }
42
+ render() {
43
+ return this.state.message ? <p className="hearth-library-failed">This sample couldn&apos;t be drawn: {this.state.message}</p> : this.props.children;
44
+ }
45
+ }
@@ -0,0 +1,116 @@
1
+ import type { Metadata } from "next";
2
+ import { notFound } from "next/navigation";
3
+ import { Fragment, Suspense, type ReactNode } from "react";
4
+ import { Blocks } from "../blocks";
5
+ import { SampleBoundary, SampleGuard, ViewLink } from "./controls";
6
+ import { FormStates, Ground, GROUND_NAMES } from "./pieces";
7
+ import { blockSamples, libraryGroups } from "./samples";
8
+
9
+ export const metadata: Metadata = { title: "Block library", robots: { index: false, follow: false } };
10
+
11
+ const TONES = ["default", "muted", "inverse", "accent"] as const;
12
+
13
+ // Its own plain styles, so that nothing in a site's theme changes the tools around the samples.
14
+ const CSS = `
15
+ .hearth-library-bar, .hearth-library-label, .hearth-library-failed { font: 14px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #1a1a1a; }
16
+ .hearth-library-bar { position: sticky; top: 0; z-index: 50; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; padding: 8px 16px; background: #fff; border-bottom: 1px solid #d8d8d4; }
17
+ .hearth-library-bar b { font-weight: 700; }
18
+ .hearth-library-switch { display: inline-flex; border: 1px solid #d8d8d4; border-radius: 6px; overflow: hidden; }
19
+ .hearth-library-switch > * { padding: 4px 12px; }
20
+ .hearth-library-switch > * + * { border-left: 1px solid #d8d8d4; }
21
+ .hearth-library-current { background: #1f4fd8; color: #fff; }
22
+ .hearth-library-jump { position: relative; }
23
+ .hearth-library-jump summary { cursor: pointer; padding: 4px 12px; border: 1px solid #d8d8d4; border-radius: 6px; list-style: none; }
24
+ .hearth-library-jump summary::after { content: " ▾"; }
25
+ .hearth-library-jump nav { position: absolute; left: 0; top: calc(100% + 6px); width: min(260px, calc(100vw - 32px)); max-height: 70dvh; overflow-y: auto; padding: 8px; background: #fff; border: 1px solid #d8d8d4; border-radius: 6px; }
26
+ .hearth-library-jump p { margin: 10px 8px 2px; font-size: 12px; font-weight: 600; color: #5c5c58; }
27
+ .hearth-library-jump a { display: block; padding: 3px 8px; }
28
+ .hearth-library-jump a:hover, .hearth-library-label a:hover { text-decoration: underline; }
29
+ .hearth-library-note { color: #5c5c58; }
30
+ .hearth-library-label { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 2px 16px; padding: 6px 16px; background: #f4f4f2; border-block: 1px solid #d8d8d4; scroll-margin-top: 48px; }
31
+ .hearth-library-label b { font-weight: 700; }
32
+ .hearth-library-label a { color: #1f4fd8; }
33
+ .hearth-library-failed { padding: 16px; }
34
+ `;
35
+
36
+ function Sample({ id, name, label, action, children }: { id?: string; name: string; label: string; action?: ReactNode; children: ReactNode }) {
37
+ return (
38
+ <>
39
+ <div id={id} className="hearth-library-label">
40
+ <span>
41
+ <b>{name}</b> · {label}
42
+ </span>
43
+ {action}
44
+ </div>
45
+ <div data-library-sample="">
46
+ <SampleBoundary>
47
+ <Suspense>{children}</Suspense>
48
+ </SampleBoundary>
49
+ </div>
50
+ </>
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Every block on one page, drawn by the real components with this site's theme and overrides, so that a change in
56
+ * src/theme shows here at once. For `pnpm dev` only. Its samples stand in for a site's content, so nothing depends on
57
+ * which pages exist, and nothing here is saved. ?block=hero shows one block's samples.
58
+ */
59
+ export default async function BlockLibrary({ searchParams }: { searchParams: Promise<{ block?: string | string[] }> }) {
60
+ if (process.env.NODE_ENV !== "development") notFound();
61
+ const { block } = await searchParams;
62
+ const samples = blockSamples(new Date());
63
+ const all = libraryGroups.flatMap((g) => g.blocks);
64
+ const only = all.find((b) => b.type === block);
65
+ const unstyled = `/hearth/blocks/unstyled${only ? `#block-${only.type}` : ""}`;
66
+ const showing = only ? [only] : all;
67
+ return (
68
+ <>
69
+ <style>{CSS}</style>
70
+ <SampleGuard />
71
+ <div className="hearth-library-bar">
72
+ <b>Block library</b>
73
+ <span className="hearth-library-switch">
74
+ <ViewLink href={unstyled}>Unstyled</ViewLink>
75
+ <span className="hearth-library-current">This site&apos;s theme</span>
76
+ </span>
77
+ <details className="hearth-library-jump">
78
+ <summary>{only ? only.label : "All blocks"}</summary>
79
+ <nav aria-label="Blocks">
80
+ <a href="/hearth/blocks">Every block on one page</a>
81
+ {libraryGroups.map((g) => (
82
+ <Fragment key={g.group}>
83
+ <p>{g.group}</p>
84
+ {g.blocks.map((b) => (
85
+ <a key={b.type} href={`/hearth/blocks?block=${b.type}`}>
86
+ {b.label}
87
+ </a>
88
+ ))}
89
+ </Fragment>
90
+ ))}
91
+ </nav>
92
+ </details>
93
+ <span className="hearth-library-note">Sample content. Links and forms here do nothing.</span>
94
+ </div>
95
+ {only
96
+ ? null
97
+ : TONES.map((tone) => (
98
+ <Sample key={tone} id={tone === "default" ? "grounds" : undefined} name="Grounds" label={`${GROUND_NAMES[tone]} ground`}>
99
+ <Ground tone={tone} />
100
+ </Sample>
101
+ ))}
102
+ {only ? null : (
103
+ <Sample id="form-states" name="Form states" label="Errors, filled-in fields, options and the success message">
104
+ <FormStates />
105
+ </Sample>
106
+ )}
107
+ {showing.map((b) =>
108
+ samples[b.type].map((s, i) => (
109
+ <Sample key={s.block.id} id={i ? undefined : `block-${b.type}`} name={b.label} label={s.label} action={only ? <a href="/hearth/blocks">Show every block</a> : <a href={`/hearth/blocks?block=${b.type}`}>Only this block</a>}>
110
+ <Blocks blocks={[s.block]} />
111
+ </Sample>
112
+ )),
113
+ )}
114
+ </>
115
+ );
116
+ }
@@ -0,0 +1,42 @@
1
+ import { theme } from "@/theme";
2
+ import { Alert, OptionCard, Question, Success } from "../forms/fields";
3
+ import { Button, Section, SectionHeading, type Tone } from "../ui";
4
+
5
+ /* What blocks share, for the block library: both its views draw these, the unstyled one and the site's own. */
6
+
7
+ export const GROUND_NAMES: Record<Tone, string> = { default: "Standard", muted: "Subtle", inverse: "Dark", accent: "Brand color" };
8
+
9
+ /** A heading and every button on one of the four grounds blocks sit on. */
10
+ export function Ground({ tone }: { tone: Tone }) {
11
+ return (
12
+ <Section tone={tone} block="ground">
13
+ <SectionHeading eyebrow="Eyebrow" heading="A section heading with a *highlighted* phrase" intro="An intro under the heading, in a sentence or two." />
14
+ <div className={theme.hero.actions}>
15
+ <Button link={{ label: "Primary button", href: "#" }} />
16
+ <Button link={{ label: "Secondary button", href: "#" }} variant="secondary" />
17
+ <Button link={{ label: "Small", href: "#" }} size="small" />
18
+ <Button link={{ label: "Small", href: "#" }} variant="secondary" size="small" />
19
+ </div>
20
+ </Section>
21
+ );
22
+ }
23
+
24
+ /** What forms only show after someone submits, all together. */
25
+ export function FormStates() {
26
+ return (
27
+ <Section block="form-states">
28
+ <div className={`${theme.formBlock.wrap} ${theme.form.root}`}>
29
+ <Alert>Please check the fields below and try again.</Alert>
30
+ <Question field={{ name: "email", label: "Email", type: "email", required: true }} idPrefix="states" error="Enter a valid email address." value="dana@" />
31
+ <Question field={{ name: "topic", label: "What is this about?", type: "select", required: true, options: ["Volunteering", "Donating"] }} idPrefix="states" error="Choose one." />
32
+ <Question field={{ name: "message", label: "Message", type: "textarea", required: true }} idPrefix="states" value="A message someone has already typed." />
33
+ <Question field={{ name: "updates", label: "Send me the monthly newsletter", type: "checkbox", required: false }} idPrefix="states" value="on" />
34
+ <OptionCard input={<input type="radio" name="states-option" defaultChecked className={theme.form.optionInput} />} title="A chosen option" meta="3 spots left">
35
+ <span className={theme.form.optionDetail}>Its details, under the title</span>
36
+ </OptionCard>
37
+ <OptionCard input={<input type="radio" name="states-option" disabled className={theme.form.optionInput} />} title="A full option" meta="Full" />
38
+ <Success>Thanks! We&apos;ll be in touch soon.</Success>
39
+ </div>
40
+ </Section>
41
+ );
42
+ }
@@ -0,0 +1,342 @@
1
+ /*
2
+ * What the block library shows: each block type with the options and states a design has to cover. The content is a
3
+ * made-up community kitchen, long enough to show how real copy sits. A new block type needs an entry here; Hearth's
4
+ * tests fail without one.
5
+ */
6
+ import type { BlockOf, Fundraiser, HearthEvent, ImageRef } from "@the-smithy/hearth";
7
+ import type { BlockComponents } from "@the-smithy/hearth/react";
8
+
9
+ export type AnyType = keyof BlockComponents;
10
+ type FormField = NonNullable<BlockOf<"form">["data"]["fields"]>[number];
11
+ export type Sample = { label: string; block: BlockOf<AnyType> };
12
+
13
+ let count = 0;
14
+ function blockOf<T extends AnyType>(type: T, data: BlockOf<T>["data"]) {
15
+ return { id: `sample-${++count}`, type, data } as unknown as BlockOf<AnyType>;
16
+ }
17
+
18
+ /** A grey placeholder with its size written on it. */
19
+ function image(width: number, height: number, alt = "Placeholder image"): ImageRef {
20
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="100%" height="100%" fill="#d9d9d9"/><path d="M0 0L${width} ${height}M${width} 0L0 ${height}" stroke="#bdbdbd" stroke-width="2"/></svg>`;
21
+ return { src: `data:image/svg+xml,${encodeURIComponent(svg)}`, alt, width, height } as ImageRef;
22
+ }
23
+ const photo = () => image(1200, 800);
24
+ const portrait = (name: string) => image(600, 600, name);
25
+ function logo(name: string): ImageRef {
26
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="40" viewBox="0 0 160 40"><rect width="160" height="40" fill="#d9d9d9"/><text x="80" y="25" font-family="sans-serif" font-size="14" fill="#595959" text-anchor="middle">${name}</text></svg>`;
27
+ return { src: `data:image/svg+xml,${encodeURIComponent(svg)}`, alt: name, width: 160, height: 40 } as ImageRef;
28
+ }
29
+
30
+ function fundraiser(slug: string, title: string, raisedCents: number, goalCents: number | null, donorCount: number, extra: Partial<Fundraiser> = {}): Fundraiser {
31
+ return {
32
+ slug,
33
+ path: `/fundraisers/${slug}`,
34
+ title,
35
+ summary: "Every gift goes toward equipment and ingredients for the meals we cook each week.",
36
+ image: photo(),
37
+ status: "active",
38
+ goalCents,
39
+ raisedCents,
40
+ donorCount,
41
+ startsAt: null,
42
+ endsAt: null,
43
+ categories: [],
44
+ ...extra,
45
+ };
46
+ }
47
+ const fundraisers = [
48
+ fundraiser("new-ovens", "New ovens for the kitchen", 1842500, 2500000, 214),
49
+ fundraiser("winter-meals", "Winter meals program", 640000, null, 57, { image: null }),
50
+ fundraiser("school-lunches", "School lunch fund", 1200000, 1200000, 132, { status: "completed" }),
51
+ ];
52
+
53
+ const entry = (slug: string, title: string, date: string, tags: string[], hasImage = true) => ({
54
+ slug,
55
+ path: `/stories/${slug}`,
56
+ title,
57
+ date,
58
+ summary: "What we learned this month, from the volunteers who were there and the families they cooked for.",
59
+ image: hasImage ? photo() : null,
60
+ tags,
61
+ categories: [],
62
+ publishedAt: `${date}T12:00:00Z`,
63
+ });
64
+ const entries = [entry("first-year", "What a first year of shared meals taught us", "2026-09-14", ["Community", "Meals"]), entry("garden", "The garden that feeds forty families", "2026-08-02", ["Garden"]), entry("ovens", "Why we need new ovens", "2026-06-21", [], false)];
65
+ const stories = { id: "c1", slug: "stories", name: "Stories", kind: "pages", description: null, path: "/stories", sort: "newest" } as const;
66
+
67
+ const fields: FormField[] = [
68
+ { name: "name", label: "Your name", type: "text", required: true },
69
+ { name: "email", label: "Email", type: "email", required: true, placeholder: "you@example.org" },
70
+ { name: "phone", label: "Phone", type: "tel", required: false },
71
+ { name: "topic", label: "What is this about?", type: "select", required: true, options: ["Volunteering", "Donating", "Renting the kitchen"] },
72
+ { name: "message", label: "Message", type: "textarea", required: true },
73
+ { name: "updates", label: "Send me the monthly newsletter", type: "checkbox", required: false },
74
+ ];
75
+
76
+ const markdown = `## Why we cook together
77
+
78
+ Millbrook Community Kitchen opened in a church basement with two stoves. Today **more than three hundred neighbors** share a meal with us each week, and [anyone can join](/join).
79
+
80
+ ### What happens on a Tuesday
81
+
82
+ 1. Volunteers arrive at three and start prepping.
83
+ 2. Dinner goes out at six, with *no sign-up and no charge*.
84
+ 3. Whatever is left goes home with whoever wants it.
85
+
86
+ Things to bring:
87
+
88
+ - An appetite
89
+ - A container for leftovers
90
+
91
+ > The kitchen is the first place in this town where I knew everybody's name.
92
+
93
+ | Day | Meal | Served |
94
+ | --- | --- | --- |
95
+ | Tuesday | Community dinner | 6:00 PM |
96
+ | Saturday | Family breakfast | 9:00 AM |`;
97
+
98
+ const people = [
99
+ { name: "Dana Whitfield", role: "Executive Director", image: portrait("Dana Whitfield"), bio: "Started the kitchen with two stoves and a borrowed van." },
100
+ { name: "Marco Reyes", role: "Head Cook", image: portrait("Marco Reyes"), bio: "Cooked in restaurants for twenty years before coming home to Millbrook." },
101
+ { name: "Priya Nair", role: "Volunteer Coordinator", image: portrait("Priya Nair"), bio: "Knows every volunteer by name, and what they're best at." },
102
+ ];
103
+
104
+ const faqItems = [
105
+ { question: "Do I need to sign up for a meal?", answer: "No. Come any Tuesday at six. Everyone is welcome and every meal is free." },
106
+ { question: "Can I volunteer if I've never worked in a kitchen?", answer: "Yes. We'll pair you with someone experienced. **Most of our volunteers started that way.**" },
107
+ { question: "Can I rent the kitchen for my own event?", answer: "We rent to local groups on weekends. [Get in touch](/contact) for the rates." },
108
+ ];
109
+
110
+ const plans = [
111
+ { name: "Neighbor", price: "$10", period: "per month", description: "For anyone who wants to help keep dinner free.", features: ["Monthly newsletter", "Member night invitations"], cta: { label: "Join", href: "/join" } },
112
+ { name: "Friend", price: "$25", period: "per month", description: "Covers a family's meals for a month.", features: ["Everything in Neighbor", "Name on the wall of friends", "Two guest passes"], cta: { label: "Join", href: "/join" }, highlight: "Most popular" },
113
+ { name: "Partner", price: "Let's talk", description: "For businesses that sponsor a program.", features: ["Everything in Friend", "Logo on the website"], cta: { label: "Contact us", href: "/contact" } },
114
+ ];
115
+
116
+ const cards = [
117
+ { title: "Community dinners", body: "A free, hot meal every Tuesday, cooked by neighbors for neighbors.", image: photo(), link: { label: "See the menu", href: "/dinners" } },
118
+ { title: "Cooking classes", body: "Hands-on classes for every level, from knife skills to bread.", image: photo(), link: { label: "View classes", href: "/classes" } },
119
+ { title: "The kitchen garden", body: "Forty raised beds that supply a third of the produce we cook.", image: photo(), link: { label: "Visit the garden", href: "/garden" } },
120
+ ];
121
+
122
+ const projects = [
123
+ { name: "Riverside Food Hub", description: "A shared cold store that lets six pantries buy together and **waste less**.", image: photo(), url: "https://example.org/hub", role: "Lead partner", status: "Live", stack: ["Cold storage", "Delivery van", "Shared ordering"] },
124
+ { name: "Lunch Box Library", description: "Reusable lunch boxes that families borrow and return at any school.", image: photo(), url: "https://example.org/lunch", role: "Program designer", status: "2024–2026", stack: ["Schools", "Volunteers"] },
125
+ { name: "Seed Exchange", description: "A yearly swap for seeds saved from the kitchen garden.", image: photo(), stack: [] },
126
+ ];
127
+
128
+ const place = (name: string, address: string, lat: number, lng: number, hours: string) => ({ name, address, position: { lat, lng }, hours, notes: "Free parking behind the building. Step-free entrance on Mill Street.", link: { label: "About this kitchen", href: "/kitchens" } });
129
+ const places = [place("Mill Street Kitchen", "410 Mill Street\nMillbrook, IL", 41.6, -88.5, "Mon–Fri · 9am–5pm"), place("Eastside Pantry", "22 Elm Avenue\nMillbrook, IL", 41.61, -88.47, "Tue & Thu · 2pm–6pm"), place("Riverside Hub", "9 Water Street\nMillbrook, IL", 41.59, -88.52, "Saturdays · 8am–noon")];
130
+
131
+ /** The blocks in the order and groups Hearth's editor lists them (build.ts checks them against its registry). */
132
+ export const libraryGroups: { group: string; blocks: { type: AnyType; label: string }[] }[] = [
133
+ { group: "Layout", blocks: [{ type: "hero", label: "Hero" }, { type: "cta", label: "Call to action" }] },
134
+ {
135
+ group: "Content",
136
+ blocks: [
137
+ { type: "richText", label: "Text" },
138
+ { type: "mediaText", label: "Image + text" },
139
+ { type: "stats", label: "Stats" },
140
+ { type: "cardGrid", label: "Cards" },
141
+ { type: "faq", label: "FAQ" },
142
+ { type: "pricing", label: "Pricing" },
143
+ { type: "projects", label: "Projects" },
144
+ { type: "quote", label: "Quote" },
145
+ { type: "people", label: "People" },
146
+ { type: "logos", label: "Logos" },
147
+ { type: "collectionList", label: "Collection list" },
148
+ ],
149
+ },
150
+ { group: "Media", blocks: [{ type: "gallery", label: "Photo gallery" }, { type: "video", label: "Video" }, { type: "embed", label: "Embed" }] },
151
+ {
152
+ group: "Places & events",
153
+ blocks: [
154
+ { type: "location", label: "Location" },
155
+ { type: "locations", label: "Locations" },
156
+ { type: "schedule", label: "Schedule" },
157
+ { type: "upcomingEvents", label: "Upcoming events" },
158
+ { type: "pastEvents", label: "Past events" },
159
+ { type: "eventSpotlight", label: "Event spotlight" },
160
+ ],
161
+ },
162
+ { group: "Forms", blocks: [{ type: "volunteerSignup", label: "Volunteer sign-up" }, { type: "form", label: "Form" }] },
163
+ { group: "Giving", blocks: [{ type: "fundraiserSpotlight", label: "Fundraiser spotlight" }, { type: "fundraiserGrid", label: "Fundraisers" }, { type: "donate", label: "Donation form" }] },
164
+ ];
165
+
166
+ /** The active fundraisers a general donation form offers. */
167
+ export const activeFundraisers = fundraisers.filter((f) => f.status === "active");
168
+
169
+ /** Every block's samples. `today` sets their dates, so that upcoming events stay upcoming. */
170
+ export function blockSamples(today: Date): Record<AnyType, Sample[]> {
171
+ const DAY = 86_400_000;
172
+ const at = (days: number, hour: number) => {
173
+ const d = new Date(today.getTime() + days * DAY);
174
+ d.setUTCHours(hour + 5, 0, 0, 0);
175
+ return d.toISOString();
176
+ };
177
+
178
+ function event(slug: string, title: string, days: number, extra: Partial<HearthEvent> = {}): HearthEvent {
179
+ return {
180
+ slug,
181
+ title,
182
+ status: "published",
183
+ summary: "An evening of cooking, sharing a table and meeting the neighbors who keep the kitchen running.",
184
+ startsAt: at(days, 18),
185
+ endsAt: at(days, 21),
186
+ allDay: false,
187
+ timezone: "America/Chicago",
188
+ location: { name: "Millbrook Community Kitchen", address: "410 Mill Street, Millbrook, IL" },
189
+ attendance: "in_person",
190
+ registrationUrl: "https://example.org/register",
191
+ image: photo(),
192
+ customFields: {},
193
+ path: `/events/${slug}`,
194
+ collection: null,
195
+ categories: [],
196
+ registration: null,
197
+ volunteering: null,
198
+ ...extra,
199
+ };
200
+ }
201
+
202
+ const events = [
203
+ event("harvest-supper", "Harvest Supper", 30),
204
+ event("knife-skills", "Knife Skills for Beginners", 44, { registrationUrl: null, registration: { open: false, closesAt: null, tickets: [{ soldOut: true }], fundraiser: null } as unknown as HearthEvent["registration"] }),
205
+ event("preserving", "Preserving the Season", 58, { status: "cancelled", image: null }),
206
+ ];
207
+ const pastEvents = [event("spring-table", "Spring Table", -60, { registrationUrl: null }), event("bread-day", "Community Bread Day", -95, { registrationUrl: null, image: null }), event("soup-swap", "Soup Swap", -130, { registrationUrl: null })];
208
+
209
+ const volunteering = {
210
+ open: true,
211
+ closesAt: null,
212
+ intro: "We need hands for setup, cooking and cleanup. No experience needed.",
213
+ fields: [{ name: "shirt", label: "Anything we should know?", type: "textarea", required: false }],
214
+ pickShifts: "many",
215
+ shifts: [
216
+ { id: "s1", name: "Setup", description: "Tables, chairs and signs.", startsAt: at(30, 15), endsAt: at(30, 17), remaining: null, full: false },
217
+ { id: "s2", name: "Cooking", description: null, startsAt: at(30, 16), endsAt: at(30, 19), remaining: 3, full: false },
218
+ { id: "s3", name: "Cleanup", description: null, startsAt: at(30, 21), endsAt: at(30, 23), remaining: 0, full: true },
219
+ ],
220
+ submitLabel: "Sign up",
221
+ successMessage: "Thanks for signing up.",
222
+ fundraiser: { slug: "new-ovens", title: "New ovens for the kitchen" },
223
+ } as const;
224
+
225
+ return {
226
+ hero: [
227
+ { label: "Full size, with image and both buttons", block: blockOf("hero", { eyebrow: "Millbrook Community Kitchen", heading: "A seat at the table for *everyone*", body: "Free meals, cooking classes and a garden that feeds forty families, all run by the neighbors who use them.", image: photo(), primaryCta: { label: "Come to dinner", href: "/dinners" }, secondaryCta: { label: "Volunteer", href: "/volunteer" }, size: "full" }) },
228
+ { label: "Compact, without an image (inner pages)", block: blockOf("hero", { heading: "About the kitchen", body: "How a church basement and two stoves became a meal for three hundred people a week.", size: "compact" }) },
229
+ { label: "With the site's logo", block: blockOf("hero", { heading: "A seat at the table for *everyone*", showLogo: true, logo: logo("Site logo"), primaryCta: { label: "Come to dinner", href: "/dinners" }, size: "compact" }) },
230
+ { label: "Background: Standard", block: blockOf("hero", { eyebrow: "Our story", heading: "Cooked by neighbors, for neighbors", body: "Every meal since 2011.", primaryCta: { label: "Read more", href: "/about" }, secondaryCta: { label: "Volunteer", href: "/volunteer" }, size: "compact", tone: "default" }) },
231
+ { label: "Background: Subtle", block: blockOf("hero", { eyebrow: "Our story", heading: "Cooked by neighbors, for neighbors", primaryCta: { label: "Read more", href: "/about" }, size: "compact", tone: "muted" }) },
232
+ { label: "Background: Brand color", block: blockOf("hero", { eyebrow: "Our story", heading: "Cooked by neighbors, for neighbors", primaryCta: { label: "Read more", href: "/about" }, size: "compact", tone: "accent" }) },
233
+ ],
234
+ richText: [
235
+ { label: "Narrow, with every kind of formatting", block: blockOf("richText", { markdown, width: "narrow" }) },
236
+ { label: "Wide", block: blockOf("richText", { markdown: "## A wider column of text\n\nWide text runs the full width of the site's content area. It suits short statements and text with tables more than long reading.", width: "wide" }) },
237
+ ],
238
+ mediaText: [
239
+ { label: "Image on the right, with a button", block: blockOf("mediaText", { eyebrow: "The garden", heading: "Forty beds, *one* big harvest", markdown: "Our garden supplies a third of the produce we cook. Families who tend a bed take home what they grow.\n\n- Open to everyone\n- Tools provided", image: photo(), imagePosition: "right", imageFit: "cover", cta: { label: "Visit the garden", href: "/garden" } }) },
240
+ { label: "Image on the left", block: blockOf("mediaText", { heading: "Classes for every level", markdown: "From knife skills to sourdough, our classes are hands-on and pay-what-you-can.", image: photo(), imagePosition: "left", imageFit: "cover" }) },
241
+ { label: "Image fit: whole image (logos, badges)", block: blockOf("mediaText", { heading: "Certified by the state food bank", markdown: "We meet every food safety standard, and we're inspected each year.", image: logo("Badge"), imagePosition: "right", imageFit: "contain" }) },
242
+ ],
243
+ stats: [
244
+ { label: "With a heading", block: blockOf("stats", { heading: "Last year at the kitchen", items: [{ value: "15,600", label: "Meals served" }, { value: "340", label: "Volunteers" }, { value: "40", label: "Garden beds" }, { value: "$0", label: "Charged for dinner" }] }) },
245
+ { label: "Without a heading, two numbers", block: blockOf("stats", { items: [{ value: "15+", label: "Years cooking together" }, { value: "6", label: "Partner pantries" }] }) },
246
+ ],
247
+ cardGrid: [
248
+ { label: "Images and links, Standard background", block: blockOf("cardGrid", { eyebrow: "What we do", heading: "Three ways to take part", intro: "Whichever you choose, there's a place for you.", items: cards, tone: "default" }) },
249
+ { label: "Text only, Subtle background", block: blockOf("cardGrid", { heading: "What to expect", items: cards.map(({ title, body }) => ({ title, body })), tone: "muted" }) },
250
+ { label: "Dark background", block: blockOf("cardGrid", { heading: "What to expect", items: cards.map(({ title, body, link }) => ({ title, body, link })), tone: "inverse" }) },
251
+ { label: "Brand color background, no heading", block: blockOf("cardGrid", { items: cards.map(({ title, body, link }) => ({ title, body, link })), tone: "accent" }) },
252
+ ],
253
+ faq: [
254
+ { label: "Answers open on click (the first is open here)", block: blockOf("faq", { heading: "Common questions", items: faqItems, display: "accordion" }) },
255
+ { label: "All answers showing", block: blockOf("faq", { heading: "Common questions", items: faqItems, display: "list" }) },
256
+ ],
257
+ pricing: [
258
+ { label: "Three plans, one highlighted", block: blockOf("pricing", { eyebrow: "Membership", heading: "Keep dinner free", intro: "Members cover the cost of what we cook.", plans, footnote: "Cancel any time. Donations are tax deductible. [See our finances](/finances).", tone: "default" }) },
259
+ { label: "Four plans, Subtle background", block: blockOf("pricing", { heading: "Membership", plans: [...plans, { name: "Patron", price: "$100", period: "per month", features: ["Everything in Friend", "An annual dinner with the cooks"], cta: { label: "Join", href: "/join" } }], tone: "muted" }) },
260
+ { label: "Dark background", block: blockOf("pricing", { heading: "Membership", plans, tone: "inverse" }) },
261
+ ],
262
+ projects: [
263
+ { label: "Showcase (large images, alternating sides)", block: blockOf("projects", { eyebrow: "Our work", heading: "What we've built together", intro: "Programs that started in the kitchen and grew.", items: projects, layout: "showcase", tone: "default" }) },
264
+ { label: "Grid (compact cards), Subtle background", block: blockOf("projects", { heading: "Our work", items: projects, layout: "grid", tone: "muted" }) },
265
+ ],
266
+ cta: [
267
+ { label: "Brand color", block: blockOf("cta", { heading: "Ready to *pull up a chair*?", body: "Dinner is every Tuesday at six. Everyone is welcome.", primary: { label: "Get directions", href: "/visit" }, secondary: { label: "Volunteer", href: "/volunteer" }, tone: "accent" }) },
268
+ { label: "Dark", block: blockOf("cta", { heading: "Ready to pull up a chair?", primary: { label: "Get directions", href: "/visit" }, secondary: { label: "Volunteer", href: "/volunteer" }, tone: "inverse" }) },
269
+ { label: "Subtle, with one button", block: blockOf("cta", { heading: "Ready to pull up a chair?", body: "Dinner is every Tuesday at six.", primary: { label: "Get directions", href: "/visit" }, tone: "muted" }) },
270
+ { label: "Standard, with one button", block: blockOf("cta", { heading: "Ready to pull up a chair?", primary: { label: "Get directions", href: "/visit" }, tone: "default" }) },
271
+ { label: "With a background photo", block: blockOf("cta", { heading: "Ready to pull up a chair?", body: "Dinner is every Tuesday at six.", primary: { label: "Get directions", href: "/visit" }, secondary: { label: "Volunteer", href: "/volunteer" }, tone: "default", image: photo() }) },
272
+ ],
273
+ quote: [
274
+ { label: "With photo, Standard background", block: blockOf("quote", { quote: "The kitchen is the first place in this town where I knew everybody's name.", name: "Rosa Delgado", role: "Volunteer since 2019", image: portrait("Rosa Delgado"), tone: "default" }) },
275
+ { label: "Name only, Subtle background", block: blockOf("quote", { quote: "My kids ask every week whether it's Tuesday yet.", name: "James Okafor", tone: "muted" }) },
276
+ { label: "Dark background", block: blockOf("quote", { quote: "Nobody asks why you came. They just hand you a plate.", name: "Rosa Delgado", role: "Volunteer since 2019", image: portrait("Rosa Delgado"), tone: "inverse" }) },
277
+ { label: "Brand color, no name", block: blockOf("quote", { quote: "Nobody asks why you came. They just hand you a plate.", tone: "accent" }) },
278
+ ],
279
+ people: [
280
+ { label: "Photos, roles and bios", block: blockOf("people", { eyebrow: "Who we are", heading: "Our team", intro: "A small staff and a lot of volunteers.", people }) },
281
+ { label: "Names and roles only", block: blockOf("people", { heading: "Our board", people: people.map(({ name, role }) => ({ name, role })) }) },
282
+ ],
283
+ logos: [
284
+ { label: "Logos, some linked", block: blockOf("logos", { heading: "Our partners", intro: "We couldn't do this without them.", logos: [{ name: "Food Bank", logo: logo("Food Bank"), url: "https://example.org" }, { name: "City Farm", logo: logo("City Farm") }, { name: "School District", logo: logo("School District"), url: "https://example.org" }, { name: "Rotary", logo: logo("Rotary") }, { name: "Library", logo: logo("Library") }] }) },
285
+ { label: "Names, when there's no logo", block: blockOf("logos", { logos: [{ name: "Millbrook Food Bank", url: "https://example.org" }, { name: "City Farm" }, { name: "Rotary Club" }] }) },
286
+ ],
287
+ gallery: [
288
+ { label: "3 across, with captions", block: blockOf("gallery", { heading: "Life in the kitchen", intro: "A few photos from this year.", images: [1, 2, 3, 4, 5, 6].map((n) => ({ image: photo(), caption: n % 2 ? "Tuesday dinner service" : undefined })), columns: "3" }) },
289
+ { label: "2 across (larger)", block: blockOf("gallery", { images: [1, 2].map(() => ({ image: photo() })), columns: "2" }) },
290
+ { label: "4 across (smaller)", block: blockOf("gallery", { images: [1, 2, 3, 4].map(() => ({ image: photo() })), columns: "4" }) },
291
+ ],
292
+ video: [{ label: "With heading and caption", block: blockOf("video", { heading: "A Tuesday at the kitchen", url: "https://www.youtube.com/watch?v=aqz-KE-bpKQ", title: "A Tuesday at the kitchen", caption: "Filmed by volunteers in the spring." }) }],
293
+ embed: [
294
+ { label: "Narrow", block: blockOf("embed", { html: "<p>Booking widget</p>", title: "Book a table", height: 300, width: "narrow", caption: "Bookings are handled by another service." }) },
295
+ { label: "Wide", block: blockOf("embed", { html: "<p>Booking widget</p>", title: "Book a table", height: 300, width: "wide" }) },
296
+ { label: "Full width", block: blockOf("embed", { html: "<p>Booking widget</p>", title: "Book a table", height: 300, width: "full" }) },
297
+ ],
298
+ location: [
299
+ { label: "With a map", block: blockOf("location", { eyebrow: "Visit us", heading: "Find the kitchen", name: "Mill Street Kitchen", address: "410 Mill Street\nMillbrook, IL 60541", hours: "Mon–Fri · 9am–5pm\nDinner Tuesdays at 6pm", notes: "Free parking behind the building. **Step-free entrance** on Mill Street.", position: { lat: 41.6, lng: -88.5 }, showMap: true }) },
300
+ { label: "Without a map or heading", block: blockOf("location", { name: "Mill Street Kitchen", address: "410 Mill Street\nMillbrook, IL 60541", showMap: false }) },
301
+ ],
302
+ locations: [
303
+ { label: "With a map", block: blockOf("locations", { eyebrow: "Find us", heading: "Our locations", intro: "Three places, all open to everyone.", items: places, showMap: true }) },
304
+ { label: "Without a map", block: blockOf("locations", { heading: "Our locations", items: places, showMap: false }) },
305
+ ],
306
+ schedule: [{ label: "With details on some items", block: blockOf("schedule", { eyebrow: "Saturday", heading: "Schedule", intro: "Doors open at eleven.", items: [{ time: "11:00 am", title: "Doors open", details: "Coffee and pastries in the hall." }, { time: "12:30 pm", title: "Lunch" }, { time: "2:00 pm", title: "Cooking demonstration", details: "Marco Reyes shows how to make stock from kitchen scraps." }, { time: "4:00 pm", title: "Close" }] }) }],
307
+ upcomingEvents: [
308
+ { label: "Register button, sold out, and cancelled without an image", block: blockOf("upcomingEvents", { eyebrow: "What's on", heading: "Upcoming events", intro: "Everyone is welcome.", limit: 3, events }) },
309
+ { label: "Registration coming soon, with a link to all of a collection's events", block: blockOf("upcomingEvents", { heading: "Talks", limit: 1, events: [event("talk", "Feeding a Town", 70, { registrationUrl: null })], collection: { ...stories, name: "Talks", path: "/talks", kind: "events" } }) },
310
+ { label: "When there are none", block: blockOf("upcomingEvents", { heading: "Upcoming events", limit: 3, events: [] }) },
311
+ ],
312
+ pastEvents: [
313
+ { label: "Past events (Subtle background by default)", block: blockOf("pastEvents", { heading: "Past events", intro: "Photos and recipes from earlier gatherings.", limit: 6, events: pastEvents }) },
314
+ { label: "When there are none", block: blockOf("pastEvents", { heading: "Past events", limit: 6, events: [], emptyText: "Nothing here yet. Our first gathering is coming up." }) },
315
+ ],
316
+ collectionList: [
317
+ { label: "Cards with images", block: blockOf("collectionList", { eyebrow: "From the kitchen", heading: "Latest stories", intro: "News and recipes from our volunteers.", collectionId: "c1", limit: 3, layout: "cards", collection: stories, entries }) },
318
+ { label: "Simple list", block: blockOf("collectionList", { heading: "Latest stories", collectionId: "c1", limit: 3, layout: "list", collection: stories, entries }) },
319
+ { label: "When there are none", block: blockOf("collectionList", { heading: "Latest stories", collectionId: "c1", limit: 3, layout: "cards", collection: stories, entries: [] }) },
320
+ ],
321
+ eventSpotlight: [{ label: "With a label and image", block: blockOf("eventSpotlight", { heading: "Featured event", event: { ...events[0], meta: {}, blocks: [] } as never }) }],
322
+ volunteerSignup: [
323
+ { label: "Open, with shifts (one is full)", block: blockOf("volunteerSignup", { eventSlug: "harvest-supper", heading: "Volunteer", event: { ...events[0], volunteering: volunteering } as never }) },
324
+ { label: "Closed", block: blockOf("volunteerSignup", { eventSlug: "harvest-supper", heading: "Volunteer", event: { ...events[0], volunteering: { ...volunteering, open: false } } as never }) },
325
+ ],
326
+ form: [{ label: "Every kind of field", block: blockOf("form", { heading: "Get in touch", intro: "We read everything and reply within two days.", formKey: "contact", fields: [...fields], submitLabel: "Send", successMessage: "Thanks! We'll be in touch soon." }) }],
327
+ fundraiserSpotlight: [
328
+ { label: "Active, with a goal", block: blockOf("fundraiserSpotlight", { heading: "Featured fundraiser", fundraiser: fundraisers[0] }) },
329
+ { label: "Completed (no Donate button)", block: blockOf("fundraiserSpotlight", { fundraiser: fundraisers[2] }) },
330
+ ],
331
+ fundraiserGrid: [
332
+ { label: "With a goal, without a goal or image, and completed", block: blockOf("fundraiserGrid", { eyebrow: "Giving", heading: "Current fundraisers", intro: "Choose what your gift supports.", status: "all", limit: 6, fundraisers }) },
333
+ { label: "When there are none", block: blockOf("fundraiserGrid", { heading: "Current fundraisers", status: "active", limit: 6, fundraisers: [] }) },
334
+ ],
335
+ donate: [
336
+ { label: "General, with a choice of fundraiser", block: blockOf("donate", { heading: "Make a gift", intro: "Your gift keeps dinner free.", submitLabel: "Donate", presetsCents: [2500, 5000, 10000, 25000], minCents: 500, maxCents: 2500000, fundraiser: null }) },
337
+ { label: "Giving to one fundraiser", block: blockOf("donate", { heading: "Make a gift", fundraiserSlug: "new-ovens", submitLabel: "Donate", presetsCents: [2500, 5000, 10000], minCents: 500, maxCents: 2500000, fundraiser: fundraisers[0] }) },
338
+ ],
339
+ // Each site designs its own, in its own code, so the template has nothing to show.
340
+ custom: [],
341
+ };
342
+ }
@@ -17,6 +17,8 @@
17
17
  "src/app/robots.ts",
18
18
  "src/app/sitemap.ts",
19
19
  "src/app/llms.txt/route.ts",
20
+ "src/app/hearth/blocks/page.tsx",
21
+ "src/app/hearth/blocks/unstyled/route.ts",
20
22
  "src/app/api/preview/route.ts",
21
23
  "src/app/api/preview/exit/route.ts",
22
24
  "src/app/api/revalidate/route.ts",