@the-smithy/create-hearth-site 0.26.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/README.md CHANGED
@@ -40,7 +40,8 @@ cd acme-website
40
40
 
41
41
  Add an API key to `.env.local` (Hearth → the site → API & webhooks → API keys), and run `pnpm dev`. The
42
42
  new site's README walks through the first run, previews and troubleshooting. Its `HOSTING.md` covers deploying to
43
- Netlify or Vercel, and its `AGENTS.md` holds the instructions for coding agents.
43
+ Netlify or Vercel, and its `AGENTS.md` holds the instructions for coding agents, who are set up to find code with
44
+ [Drift](https://github.com/The-Smithy/drift).
44
45
 
45
46
  ## Documentation
46
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-smithy/create-hearth-site",
3
- "version": "0.26.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.26.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
  }
@@ -4,6 +4,26 @@ This is a client website whose content comes from Hearth, The Smithy's headless
4
4
  replaced by `pnpm hearth:update`: notes about this particular site go in `SITE.md`, which you should read too.
5
5
  `CLAUDE.md` and `GEMINI.md` only point here, for the assistants that look for those names.
6
6
 
7
+ <!-- drift:start -->
8
+ ## Finding code: use Drift first
9
+
10
+ Before grepping or reading whole files, use [Drift](https://github.com/The-Smithy/drift). It costs far fewer tokens.
11
+
12
+ - `drift query <symbol>` or `drift find <words>`: where something is defined
13
+ - `drift show <symbol>` or `drift sig <symbol>`: its source, or just its signature
14
+ - `drift outline <file>`: a file's definitions and imports
15
+ - `drift refs <symbol>`, `drift importers <file>`, `drift tests --for <file>`: what uses it
16
+ - `drift context <symbol or file>`: all of the above before changing something
17
+ - `drift map`: orientation in a new repo
18
+
19
+ Never read `.drift-index.json`. Grep is still right for strings and comments. If `drift` isn't installed: `cargo install --git https://github.com/The-Smithy/drift`.
20
+ <!-- drift:end -->
21
+
22
+ Here, `pnpm hearth:drift` is the way to install or update Drift and its skill: it gets the latest release from GitHub,
23
+ without Rust or an API key, and `pnpm hearth:docs` runs it too. Drift is only for working on the code. The build and
24
+ deploy don't use it, so don't add it to `package.json` or the host's build, and if it can't be installed here, carry on
25
+ with grep.
26
+
7
27
  ## How this site is built: Hearth's code, the site's styling
8
28
 
9
29
  Everything that works with Hearth is already here, and it's the same in every site Hearth runs: every block type,
@@ -66,6 +86,13 @@ Work in this order, and stop at the first step that gets the result:
66
86
  don't reach it, so it falls behind Hearth. Use one only when everything above can't get there, build it from
67
87
  `src/hearth/ui.tsx` and the theme, and say why in `SITE.md`.
68
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
+
69
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,
70
97
  say so: it belongs in Hearth's template for every site (tell the developer to raise it with The Smithy), and an
71
98
  override can bridge the gap meanwhile.
@@ -139,8 +166,8 @@ Do these before writing code, and tell the developer what you found:
139
166
  fix `.env.local`; don't guess values.
140
167
  2. **Get the full reference:** `pnpm hearth:docs` saves Hearth's API guide (every block and its fields, events,
141
168
  forms, previews, SEO) to `hearth-api.md`. Read the parts you need from it, and from
142
- `node_modules/@the-smithy/hearth/llms.md` for the SDK. It also installs any skills Hearth offers this site (see
143
- "Skills" below), so run it again when they're missing. These aren't committed, so some assistants' search skips
169
+ `node_modules/@the-smithy/hearth/llms.md` for the SDK. It also installs Drift and any skills Hearth offers this site
170
+ (see "Skills" below), so run it again when they're missing. These aren't committed, so some assistants' search skips
144
171
  them: open them by their exact path, or from the terminal if your tool won't.
145
172
  3. **"Nothing is published"** means every page is a 404 until editors publish in Hearth. That's not a code
146
173
  problem: say so, and don't add placeholder content to work around it.
@@ -153,7 +180,7 @@ and, as the same copies, in `.claude/skills/`, since Claude Code only reads that
153
180
  for and when to use it. If your assistant doesn't load them by itself, read the ones that fit the task and follow them.
154
181
  Use them for that work, design work in particular, rather than starting from scratch. They aren't committed (a
155
182
  `.gitignore` in each folder lists them), so a fresh checkout gets them by running `pnpm hearth:docs`; never edit them,
156
- because the next run replaces them.
183
+ because the next run replaces them. The `drift` skill works the same way, from `pnpm hearth:drift`.
157
184
 
158
185
  ## Hosting
159
186
 
@@ -42,12 +42,17 @@ without touching your styling. See "Styling the site" below.
42
42
  ## Working with an AI coding assistant
43
43
 
44
44
  The project comes with instructions for coding agents in `AGENTS.md`, which Codex, Cursor, Copilot and others read on
45
- their own. `CLAUDE.md` and `GEMINI.md` point Claude Code and Gemini CLI to it. Once your API key is in `.env.local`:
45
+ their own. `CLAUDE.md` and `GEMINI.md` point Claude Code and Gemini CLI to it.
46
+
47
+ Agents also use [Drift](https://github.com/The-Smithy/drift) to find code with far fewer tokens. `pnpm hearth:drift`
48
+ installs or updates it and its skill from the latest release on GitHub. The site builds and deploys without it.
49
+
50
+ Once your API key is in `.env.local`:
46
51
 
47
52
  1. **Check the connection yourself, or have the agent do it:** `pnpm hearth:check` confirms the key works and
48
53
  lists the published pages and the block types they use. It never prints the key.
49
54
  2. **Give the agent the full reference:** `pnpm hearth:docs` saves Hearth's API guide to `hearth-api.md` (not
50
- committed), and installs any coding-assistant skills Hearth offers your site in `.agents/skills/` and
55
+ committed), and installs Drift and any coding-assistant skills Hearth offers your site in `.agents/skills/` and
51
56
  `.claude/skills/`. The agent's instructions already point it at both.
52
57
  3. **Then tell it what to build.** Prompts that work well:
53
58
 
@@ -93,6 +98,7 @@ Keep the API key out of chat: the agent never needs to see it, only `.env.local`
93
98
  | `src/theme/theme.css` | Brand tokens (colors, fonts, content width) and the site's own CSS. **Start here** |
94
99
  | `src/theme/index.ts` | The Tailwind classes of any part of any block, page, form, header or footer, by slot |
95
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 |
96
102
  | `src/theme/fonts.ts` | Web fonts (next/font) |
97
103
  | `src/theme/custom-blocks.tsx` | Components for the site's own block types (Settings → Custom blocks in Hearth) |
98
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`) |
@@ -165,6 +171,7 @@ packages are public on npm, so nothing else is needed to install them.
165
171
 
166
172
  The full reference (API, SDK, every block and its fields, previews, forms) is in Hearth under **API docs**,
167
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**.
168
175
 
169
176
  ## Updating
170
177
 
@@ -13,3 +13,6 @@ next-env.d.ts
13
13
 
14
14
  # Hearth's API guide, downloaded by pnpm hearth:docs for AI assistants.
15
15
  hearth-api.md
16
+
17
+ # Drift's code index, rebuilt as needed.
18
+ .drift-index.json
@@ -10,6 +10,7 @@
10
10
  "typecheck": "tsc --noEmit",
11
11
  "hearth:check": "node scripts/hearth.mjs check",
12
12
  "hearth:docs": "node scripts/hearth.mjs docs",
13
+ "hearth:drift": "node scripts/hearth.mjs drift",
13
14
  "hearth:update": "node scripts/hearth.mjs update"
14
15
  },
15
16
  "dependencies": {
@@ -2,13 +2,16 @@
2
2
  // pnpm hearth:check says whether The Smithy has released anything newer than this site uses, checks .env.local and
3
3
  // the connection to Hearth, then lists what's published
4
4
  // pnpm hearth:docs saves Hearth's full API guide to hearth-api.md (not committed) for an AI assistant to read,
5
- // and installs any coding-assistant skills Hearth offers this site
5
+ // and installs any coding-assistant skills Hearth offers this site, and Drift
6
+ // pnpm hearth:drift installs or updates Drift, which finds code for coding agents, from its latest GitHub release
6
7
  // pnpm hearth:update updates Hearth's code in this site (src/hearth and the other files its manifest lists) to the
7
8
  // latest release, and the packages it needs; src/theme and your own files stay as they are
8
9
  import { spawnSync } from "node:child_process";
9
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
10
+ import { createHash } from "node:crypto";
11
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
10
13
  import path from "node:path";
11
- import { gunzipSync } from "node:zlib";
14
+ import { gunzipSync, inflateRawSync } from "node:zlib";
12
15
 
13
16
  const command = process.argv[2];
14
17
  /** Stops with a message. Thrown rather than process.exit(), which can crash Node on Windows mid-request. */
@@ -19,6 +22,13 @@ const fail = (message) => {
19
22
 
20
23
  const PACKAGE = "@the-smithy/create-hearth-site";
21
24
  const SKILL_DIRS = [".agents/skills", ".claude/skills"];
25
+ /** Drift's release archive for each kind of machine it has a binary for. */
26
+ const DRIFT_ARCHIVES = {
27
+ "win32-x64": "drift-x86_64-pc-windows-msvc.zip",
28
+ "linux-x64": "drift-x86_64-unknown-linux-musl.tar.gz",
29
+ "darwin-arm64": "drift-aarch64-apple-darwin.tar.gz",
30
+ "darwin-x64": "drift-x86_64-apple-darwin.tar.gz",
31
+ };
22
32
  const MANIFEST = "src/hearth/manifest.json";
23
33
  /** The packages hearth:update raises; it leaves the site's other Smithy packages to it. */
24
34
  const UPDATED_TOGETHER = [PACKAGE, "@the-smithy/hearth", "@the-smithy/hearth-schema"];
@@ -149,6 +159,20 @@ async function docs() {
149
159
  writeFileSync("hearth-api.md", await res.text());
150
160
  console.log("✓ Saved Hearth's full API guide to hearth-api.md (not committed). Point your coding assistant at it.");
151
161
  await skills(env);
162
+ // Hearth's guide and skills are what this command is for, so Drift failing doesn't fail it.
163
+ await drift().catch((err) => console.log(`! Skipped Drift: ${err.message}`));
164
+ }
165
+
166
+ /** The skills a folder's .gitignore lists, which are the installed ones; anything else there is the site's own. */
167
+ function listedSkills(dir) {
168
+ const listFile = path.join(dir, ".gitignore");
169
+ if (!existsSync(listFile)) return [];
170
+ return readFileSync(listFile, "utf8").split(/\r?\n/).map((l) => l.replace(/^\/|\/$/g, "")).filter((l) => l && !l.startsWith("#") && l !== ".gitignore" && l !== "drift");
171
+ }
172
+
173
+ function listSkills(dir, names) {
174
+ mkdirSync(dir, { recursive: true });
175
+ writeFileSync(path.join(dir, ".gitignore"), ["# Installed by pnpm hearth:docs and hearth:drift; refreshed each run.", ".gitignore", "/drift/", ...names.map((n) => `/${n}/`)].join("\n") + "\n");
152
176
  }
153
177
 
154
178
  /**
@@ -164,10 +188,7 @@ async function skills(env) {
164
188
  const safe = (p) => /^[a-z0-9][a-z0-9-]*$/.test(p);
165
189
  const names = offered.map((s) => s.name).filter(safe);
166
190
  for (const dir of SKILL_DIRS) {
167
- const listFile = path.join(dir, ".gitignore");
168
- const before = existsSync(listFile)
169
- ? readFileSync(listFile, "utf8").split(/\r?\n/).map((l) => l.replace(/^\/|\/$/g, "")).filter((l) => l && !l.startsWith("#") && l !== ".gitignore")
170
- : [];
191
+ const before = listedSkills(dir);
171
192
  for (const name of before) if (safe(name) && !names.includes(name)) rmSync(path.join(dir, name), { recursive: true, force: true });
172
193
  for (const skill of offered.filter((s) => safe(s.name))) {
173
194
  rmSync(path.join(dir, skill.name), { recursive: true, force: true });
@@ -178,15 +199,74 @@ async function skills(env) {
178
199
  writeFileSync(target, file.content);
179
200
  }
180
201
  }
181
- if (names.length || before.length) {
182
- mkdirSync(dir, { recursive: true });
183
- writeFileSync(listFile, ["# Installed by pnpm hearth:docs from Hearth; refreshed each run.", ".gitignore", ...names.map((n) => `/${n}/`)].join("\n") + "\n");
184
- }
202
+ if (names.length || before.length) listSkills(dir, names);
185
203
  }
186
204
  if (names.length) console.log(`✓ Installed ${names.length === 1 ? "a skill" : `${names.length} skills`} for your coding assistant in ${SKILL_DIRS.map((d) => `${d}/`).join(" and ")}: ${names.join(", ")}.`);
187
205
  }
188
206
 
189
207
 
208
+ /**
209
+ * Installs or updates Drift from its latest GitHub release, replacing the copy already on the PATH, then has it write
210
+ * its own skill, so both stay current without a new Hearth release. Only hearth:drift and hearth:docs run this, never
211
+ * an install or a build: a host doesn't need Drift, and shouldn't download and run a program on every deploy.
212
+ */
213
+ async function drift() {
214
+ const releases = process.env.HEARTH_DRIFT_RELEASES ?? "https://github.com/The-Smithy/drift/releases";
215
+ const windows = process.platform === "win32";
216
+ const name = windows ? "drift.exe" : "drift";
217
+ const found = spawnSync(windows ? "where" : "which", ["drift"], { encoding: "utf8" });
218
+ const onPath = found.status === 0 ? found.stdout.split(/\r?\n/)[0].trim() : null;
219
+ const have = onPath && spawnSync(onPath, ["--version"], { encoding: "utf8" }).stdout?.trim().split(" ")[1];
220
+ const tag = (await fetch(`${releases}/latest`, { redirect: "manual" })).headers.get("location")?.match(/\/tag\/([^/]+)$/)?.[1];
221
+ if (!tag) fail("GitHub didn't say which release of Drift is the latest.");
222
+ const latest = tag.replace(/^v/, "");
223
+ const bin = onPath ?? path.join(windows ? path.join(process.env.LOCALAPPDATA ?? homedir(), "Programs", "drift") : path.join(homedir(), ".local", "bin"), name);
224
+
225
+ if (have && !lower(have, latest)) console.log(`✓ Drift ${have} is up to date.`);
226
+ else {
227
+ const archive = DRIFT_ARCHIVES[`${process.platform}-${process.arch}`];
228
+ if (!archive) fail(`Drift has no download for ${process.platform} ${process.arch}. Install it with: cargo install --git https://github.com/The-Smithy/drift`);
229
+ const download = async (file) => {
230
+ const res = await fetch(`${releases}/download/${tag}/${file}`);
231
+ if (!res.ok) fail(`Couldn't download ${file} from Drift's releases (${res.status}).`);
232
+ return Buffer.from(await res.arrayBuffer());
233
+ };
234
+ const [body, sums] = await Promise.all([download(archive), download("SHA256SUMS")]);
235
+ const sum = sums.toString("utf8").split(/\r?\n/).find((line) => line.endsWith(` ${archive}`))?.split(/\s+/)[0];
236
+ if (sum !== createHash("sha256").update(body).digest("hex")) fail(`${archive} doesn't match its checksum in Drift's release, so it wasn't installed.`);
237
+ const binary = (windows ? unzip : untar)(body).get(name);
238
+ if (!binary) fail(`${archive} has no ${name}.`);
239
+ mkdirSync(path.dirname(bin), { recursive: true });
240
+ writeFileSync(`${bin}.new`, binary);
241
+ chmodSync(`${bin}.new`, 0o755);
242
+ renameSync(`${bin}.new`, bin);
243
+ console.log(`✓ ${have ? `Updated Drift from ${have} to` : "Installed Drift"} ${latest} at ${bin}`);
244
+ if (!onPath) console.log(` Add ${path.dirname(bin)} to your PATH so you and your coding assistant can run drift.`);
245
+ }
246
+
247
+ // Drift's skill follows its version, so like Hearth's skills it's installed rather than committed.
248
+ for (const dir of SKILL_DIRS) listSkills(dir, listedSkills(dir));
249
+ const install = spawnSync(bin, ["install", "."], { encoding: "utf8" });
250
+ if (install.status !== 0) fail(`Drift couldn't install its skill: ${install.stderr?.trim() || install.error?.message}`);
251
+ console.log(`✓ Installed Drift's skill in ${SKILL_DIRS.map((d) => `${d}/`).join(" and ")}.`);
252
+ }
253
+
254
+ /** The files in a zip archive, by name. */
255
+ function unzip(zip) {
256
+ const files = new Map();
257
+ const end = zip.lastIndexOf(Buffer.from("PK\x05\x06", "latin1"));
258
+ let at = zip.readUInt32LE(end + 16);
259
+ for (let i = 0; i < zip.readUInt16LE(end + 10); i++) {
260
+ const nameLength = zip.readUInt16LE(at + 28);
261
+ const header = zip.readUInt32LE(at + 42);
262
+ const start = header + 30 + zip.readUInt16LE(header + 26) + zip.readUInt16LE(header + 28);
263
+ const body = zip.subarray(start, start + zip.readUInt32LE(at + 20));
264
+ files.set(zip.toString("utf8", at + 46, at + 46 + nameLength), zip.readUInt16LE(at + 10) === 8 ? inflateRawSync(body) : body);
265
+ at += 46 + nameLength + zip.readUInt16LE(at + 30) + zip.readUInt16LE(at + 32);
266
+ }
267
+ return files;
268
+ }
269
+
190
270
  /** The files in an npm package tarball (.tgz), by their path inside it ("package/…"). */
191
271
  function untar(tgz) {
192
272
  const tar = gunzipSync(tgz);
@@ -323,7 +403,8 @@ try {
323
403
  if (command === "check") await check();
324
404
  else if (command === "docs") await docs();
325
405
  else if (command === "update") await update();
326
- else fail("Usage: node scripts/hearth.mjs check | docs | update [--version x.y.z] [--force]");
406
+ else if (command === "drift") await drift().catch((err) => fail(err instanceof Stop ? err.message : `Couldn't install Drift: ${err.message}`));
407
+ else fail("Usage: node scripts/hearth.mjs check | docs | drift | update [--version x.y.z] [--force]");
327
408
  } catch (err) {
328
409
  if (!(err instanceof Stop)) throw err;
329
410
  console.error(`\n✗ ${err.message}\n`);
@@ -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",