@the-smithy/create-hearth-site 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/bin/create-hearth-site.js +5 -0
  2. package/package.json +2 -2
  3. package/template/AGENTS.md +10 -4
  4. package/template/CLAUDE.md +77 -19
  5. package/template/README.md +51 -23
  6. package/template/SITE.md +17 -0
  7. package/template/package.json +2 -1
  8. package/template/scripts/hearth.mjs +157 -13
  9. package/template/src/app/[[...slug]]/page.tsx +2 -54
  10. package/template/src/app/api/preview/exit/route.ts +1 -1
  11. package/template/src/app/api/preview/route.ts +1 -1
  12. package/template/src/app/api/revalidate/route.ts +1 -1
  13. package/template/src/app/apple-icon.tsx +1 -1
  14. package/template/src/app/donate/page.tsx +2 -35
  15. package/template/src/app/donate/thank-you/page.tsx +2 -22
  16. package/template/src/app/events/[slug]/page.tsx +2 -26
  17. package/template/src/app/events/page.tsx +2 -29
  18. package/template/src/app/fundraisers/[slug]/page.tsx +2 -55
  19. package/template/src/app/fundraisers/page.tsx +2 -37
  20. package/template/src/app/globals.css +3 -77
  21. package/template/src/app/icon.tsx +1 -1
  22. package/template/src/app/layout.tsx +2 -36
  23. package/template/src/app/not-found.tsx +2 -14
  24. package/template/src/app/registered/page.tsx +2 -22
  25. package/template/src/app/robots.ts +1 -1
  26. package/template/src/app/sitemap.ts +2 -2
  27. package/template/src/{app → hearth}/actions.ts +1 -1
  28. package/template/src/{components → hearth}/blocks/OpenMap.tsx +11 -9
  29. package/template/src/hearth/blocks/collections.tsx +74 -0
  30. package/template/src/hearth/blocks/content.tsx +252 -0
  31. package/template/src/hearth/blocks/custom.tsx +12 -0
  32. package/template/src/hearth/blocks/events.tsx +130 -0
  33. package/template/src/hearth/blocks/giving.tsx +123 -0
  34. package/template/src/{components → hearth}/blocks/index.tsx +14 -10
  35. package/template/src/hearth/blocks/info.tsx +84 -0
  36. package/template/src/{components → hearth}/blocks/media.tsx +30 -27
  37. package/template/src/hearth/components/EventView.tsx +120 -0
  38. package/template/src/{components → hearth/components}/Registration.tsx +14 -14
  39. package/template/src/hearth/components/Share.tsx +102 -0
  40. package/template/src/hearth/format.ts +31 -0
  41. package/template/src/hearth/forms/ContactForm.tsx +27 -0
  42. package/template/src/hearth/forms/DonateForm.tsx +69 -0
  43. package/template/src/{components/blocks → hearth/forms}/EmberFormBlock.tsx +5 -4
  44. package/template/src/hearth/forms/RegistrationForm.tsx +68 -0
  45. package/template/src/hearth/forms/VolunteerForm.tsx +58 -0
  46. package/template/src/hearth/forms/fields.tsx +137 -0
  47. package/template/src/hearth/hearth.css +52 -0
  48. package/template/src/hearth/layout/MobileNav.tsx +49 -0
  49. package/template/src/hearth/layout/NavLink.tsx +16 -0
  50. package/template/src/hearth/layout/RootLayout.tsx +40 -0
  51. package/template/src/{components → hearth/layout}/SiteFooter.tsx +19 -14
  52. package/template/src/hearth/layout/SiteHeader.tsx +53 -0
  53. package/template/src/hearth/manifest.json +36 -0
  54. package/template/src/hearth/pages/donate.tsx +36 -0
  55. package/template/src/hearth/pages/event.tsx +26 -0
  56. package/template/src/hearth/pages/events.tsx +30 -0
  57. package/template/src/hearth/pages/fundraiser.tsx +77 -0
  58. package/template/src/hearth/pages/fundraisers.tsx +38 -0
  59. package/template/src/hearth/pages/not-found.tsx +15 -0
  60. package/template/src/hearth/pages/page.tsx +54 -0
  61. package/template/src/hearth/pages/registered.tsx +19 -0
  62. package/template/src/hearth/pages/thank-you.tsx +19 -0
  63. package/template/src/hearth/theme/defaults.ts +510 -0
  64. package/template/src/hearth/theme/index.ts +31 -0
  65. package/template/src/hearth/ui.tsx +144 -0
  66. package/template/src/theme/custom-blocks.tsx +11 -0
  67. package/template/src/theme/fonts.ts +9 -0
  68. package/template/src/theme/index.ts +13 -0
  69. package/template/src/theme/overrides.tsx +16 -0
  70. package/template/src/theme/theme.css +29 -0
  71. package/template/src/components/DonateForm.tsx +0 -104
  72. package/template/src/components/EventView.tsx +0 -107
  73. package/template/src/components/RegistrationForm.tsx +0 -103
  74. package/template/src/components/SiteHeader.tsx +0 -37
  75. package/template/src/components/VolunteerForm.tsx +0 -143
  76. package/template/src/components/blocks/Form.tsx +0 -92
  77. package/template/src/components/blocks/collections.tsx +0 -75
  78. package/template/src/components/blocks/content.tsx +0 -258
  79. package/template/src/components/blocks/custom.tsx +0 -20
  80. package/template/src/components/blocks/events.tsx +0 -138
  81. package/template/src/components/blocks/giving.tsx +0 -120
  82. package/template/src/components/blocks/info.tsx +0 -83
  83. package/template/src/components/ui.tsx +0 -137
  84. /package/template/src/{lib/hearth.ts → hearth/client.ts} +0 -0
  85. /package/template/src/{lib → hearth}/site.ts +0 -0
@@ -91,6 +91,11 @@ async function main() {
91
91
  }
92
92
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
93
93
 
94
+ // Record which release Hearth's code in the site came from, for pnpm hearth:update.
95
+ const manifestPath = path.join(target, "src", "hearth", "manifest.json");
96
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
97
+ await writeFile(manifestPath, JSON.stringify({ ...manifest, version: self.version }, null, 2) + "\n");
98
+
94
99
  const env = (await readFile(path.join(target, ".env.example"), "utf8")).replace(/^HEARTH_SITE=.*$/m, `HEARTH_SITE=${site ?? ""}`);
95
100
  await writeFile(path.join(target, ".env.local"), env);
96
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-smithy/create-hearth-site",
3
- "version": "0.20.0",
3
+ "version": "0.21.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",
@@ -29,7 +29,7 @@
29
29
  ],
30
30
  "devDependencies": {
31
31
  "vitest": "^5.0.3",
32
- "@the-smithy/hearth-schema": "^0.20.0"
32
+ "@the-smithy/hearth-schema": "^0.21.0"
33
33
  },
34
34
  "scripts": {
35
35
  "test": "node --test \"test/**/*.test.js\" && vitest run test/blocks.test.ts"
@@ -1,7 +1,13 @@
1
1
  # Notes for coding agents
2
2
 
3
- Read `CLAUDE.md` in this folder: it holds this project's instructions for every AI coding assistant, including
4
- the first steps to run in a new checkout (`pnpm hearth:check`, then `pnpm hearth:docs`).
3
+ Read `CLAUDE.md` in this folder: it holds this project's instructions for every AI coding assistant, then `SITE.md`
4
+ for notes about this site.
5
5
 
6
- `pnpm hearth:docs` also installs the skills Hearth offers this site in `.claude/skills/`. Read the `SKILL.md` of any
7
- that fits your task and follow it (see "Skills" in `CLAUDE.md`).
6
+ In short: Hearth's code (`src/hearth/`, and the files `src/hearth/manifest.json` lists) is shared by every site and
7
+ replaced by `pnpm hearth:update`, so never edit it. A site is styled in `src/theme/`: tokens in `theme.css`, and the
8
+ Tailwind classes of each element's slot in `index.ts` (every slot and its default is in
9
+ `src/hearth/theme/defaults.ts`).
10
+
11
+ In a new checkout, run `pnpm hearth:check`, then `pnpm hearth:docs`. The latter also installs the skills Hearth
12
+ offers this site in `.claude/skills/`. Read the `SKILL.md` of any that fits your task and follow it (see "Skills" in
13
+ `CLAUDE.md`).
@@ -1,7 +1,70 @@
1
1
  # Notes for Claude
2
2
 
3
- This is a client website whose content comes from Hearth, The Smithy's headless CMS. Read
4
- `node_modules/@the-smithy/hearth/llms.md` for the SDK reference before changing data fetching.
3
+ This is a client website whose content comes from Hearth, The Smithy's headless CMS. This file is Hearth's and is
4
+ replaced by `pnpm hearth:update`: notes about this particular site go in `SITE.md`, which you should read too.
5
+
6
+ ## How this site is built: Hearth's code, the site's styling
7
+
8
+ Everything that works with Hearth is already here, and it's the same in every site Hearth runs: every block type,
9
+ the event, fundraiser and donation pages, forms (contact, volunteer sign-up, registration, donations), the
10
+ header and phone menu, the footer, previews, SEO, analytics, redirects and the sitemap. **A site is built by styling
11
+ it, not by writing code.** It has two parts:
12
+
13
+ | Folder | Whose | What's in it |
14
+ | -------------- | ---------- | ---------------------------------------------------------------------------------------------- |
15
+ | `src/hearth/` | Hearth's | All the components and pages, and the theme's slots and defaults. **Never edit it.** |
16
+ | `src/theme/` | The site's | Its look: tokens, slot classes, fonts, wording, custom blocks. **All design work goes here.** |
17
+
18
+ `src/app/` holds one-line route files that point at `src/hearth/pages/`; they're Hearth's too (see
19
+ `src/hearth/manifest.json` for every file Hearth manages). Pages the site needs that Hearth doesn't serve can be
20
+ added in `src/app/` as the site's own.
21
+
22
+ `pnpm hearth:update` brings in a new release of Hearth's code: it replaces the managed files and leaves `src/theme`
23
+ and the site's own files alone. That's how every site gets new blocks and fixes at once, so an edit to a managed file
24
+ is lost at the next update, and stops the update (it refuses to overwrite uncommitted changes there).
25
+
26
+ ## Styling a site
27
+
28
+ Work in this order, and stop at the first step that gets the result:
29
+
30
+ 1. **Tokens** in `src/theme/theme.css`: colors, fonts, content width. Every slot uses these names (`bg-accent`,
31
+ `text-highlight`, `bg-muted`, `border-field`…), so most of a brand is set here. Hearth's tones (default, muted,
32
+ inverse, accent) map onto them.
33
+ 2. **Fonts** in `src/theme/fonts.ts` (next/font), used from the tokens.
34
+ 3. **Slots** in `src/theme/index.ts`. Every element Hearth's code draws takes its Tailwind classes from one slot.
35
+ `src/hearth/theme/defaults.ts` lists every part (`hero`, `cardGrid`, `header`, `form`, `event`…), every slot
36
+ and its default classes: read it before styling. Set only the slots the design changes:
37
+
38
+ ```ts
39
+ export const theme = defineTheme({
40
+ hero: { title: "font-display text-7xl uppercase", inner: (base) => `${base} text-center` },
41
+ button: { primary: "inline-block bg-accent px-6 py-3 font-mono uppercase text-accent-ink hover:bg-ink" },
42
+ stats: { tone: "accent" },
43
+ });
44
+ ```
45
+
46
+ - A string **replaces** the slot's default; a function **extends** it (`(base) => …`). Replace when you change a
47
+ property the default already sets (a radius, a size), since two classes for the same property don't reliably
48
+ resolve in the order written.
49
+ - Each slot styles one element, so slots never fight. States are attributes on the element, so they're plain
50
+ Tailwind variants in the slot: `on-dark:` (on an inverse or accent ground), `data-[size=full]:`,
51
+ `data-highlight:`, `aria-invalid:`, `aria-[current=page]:`, `has-checked:`, `in-data-[…]:` and others; the top
52
+ of `defaults.ts` lists them and each slot's comment says which apply.
53
+ - Slots named `tone` pick a block's ground (default, muted, inverse or accent). `text` holds the words the code
54
+ shows ("Get directions", "Register"…). `map.style` is the map's MapLibre style; `emberForm` shapes Ember forms.
55
+ - Icons and marks drawn with CSS (`before:content-['✓']`, the FAQ's `+`, link arrows) live in slots too.
56
+ 4. **Site CSS** under the tokens in `theme.css`: keyframes, background patterns, `@utility` classes your slots use,
57
+ and rules for Hearth's rich text (`.prose`) or a block (`[data-block="hero"]`).
58
+ 5. **Overrides**, last: `src/theme/overrides.tsx` can replace a whole block's component or draw the header's logo.
59
+ An override is the site's own code: updates don't reach it, so it falls behind Hearth. Use one only when slots,
60
+ tokens and CSS can't get there, build it from `src/hearth/ui.tsx` and the theme, and say why in `SITE.md`.
61
+
62
+ Never copy a component out of `src/hearth/` to restyle it. If a design needs a slot or an option Hearth doesn't have,
63
+ say so: it belongs in Hearth's template for every site (tell the developer to raise it with The Smithy), and an
64
+ override can bridge the gap meanwhile.
65
+
66
+ **Custom blocks** (Settings → Custom blocks in Hearth) are the one place a site writes components: in
67
+ `src/theme/custom-blocks.tsx`, built from `src/hearth/ui.tsx` so they take the theme.
5
68
 
6
69
  ## First steps in a new checkout
7
70
 
@@ -17,8 +80,7 @@ Do these before writing code, and tell the developer what you found:
17
80
  `.claude/skills/` (see "Skills" below), so run it again when they're missing.
18
81
  3. **"Nothing is published"** means every page is a 404 until editors publish in Hearth. That's not a code
19
82
  problem: say so, and don't add placeholder content to work around it.
20
- 4. **Then build the design** in `src/app/globals.css` (tokens) and `src/components/blocks/` (one component per
21
- block type), checking with `pnpm dev` against the published pages.
83
+ 4. **Then style the site** as above, checking with `pnpm dev` against the published pages, on a phone width too.
22
84
 
23
85
  ## Skills
24
86
 
@@ -31,30 +93,26 @@ them by running `pnpm hearth:docs`; never edit them, because the next run replac
31
93
  ## Rules
32
94
 
33
95
  - **Content comes from Hearth.** Don't hard-code page text, menus, contact details or SEO tags. They're edited
34
- in Hearth and arrive through `src/lib/hearth.ts`. The code owns design and layout.
35
- - **The API key is secret.** Only import `@/lib/hearth` from server components, route handlers and server
96
+ in Hearth and arrive through `src/hearth/client.ts`. The site's code owns design only.
97
+ - **The API key is secret.** Only import `@/hearth/client` from server components, route handlers and server
36
98
  actions. Never from a `"use client"` file.
37
99
  - **Use `hearthNext.*` for reads.** It caches and tags requests so the publish webhook can refresh them.
38
100
  Calling `hearth.*` directly skips the cache.
39
- - **Blocks are `{ id, type, data }`.** Each block type has one component in `src/components/blocks/`, typed
40
- with `BlockOf<"type">`. Keep them working for every option the block offers: tones, image positions,
41
- optional fields left empty.
42
- - **Styling uses the tokens in `src/app/globals.css`** (`bg-accent`, `text-highlight`, …). Tones are semantic.
43
- Check text contrast on every ground.
101
+ - **Keep every option working.** Blocks have tones, image positions, sizes and optional fields; check a restyle
102
+ with each, on light and dark grounds, and check text contrast on every ground (form field borders need 3:1).
44
103
  - **No bundled content.** Every page comes from Hearth; an address with no published page is a 404. Don't add
45
104
  fallback or sample content to the code.
46
- - **Previews come from the SDK.** `<HearthPreview />` in the root layout shows drafts and refreshes as editors
47
- work in Hearth. Don't build a preview banner of your own.
48
- - **Forms** post through `submitHearthForm` in `src/app/actions.ts`. Keep the hidden `_gotcha` field.
49
- - **Analytics comes from the SDK.** `<HearthAnalytics />` in the root layout counts visits for the site's Traffic
50
- page in Hearth: cookieless, so no consent banner. Don't add Google Analytics or other trackers. Marketing tags
51
- (ads pixels) need a consent banner and are a separate, opt-in feature. Use `track("name")` from
52
- `@the-smithy/hearth/analytics` for meaningful actions (a booking, a sign-up).
105
+ - **Previews, analytics and forms come from Hearth's code.** Don't build a preview banner, add Google Analytics or
106
+ other trackers (Hearth's analytics is cookieless; marketing tags need a consent banner and are a separate, opt-in
107
+ feature), or post forms anywhere else. Use `track("name")` from `@the-smithy/hearth/analytics` in the site's own
108
+ code for meaningful actions.
53
109
 
54
110
  ## Checks
55
111
 
56
112
  `pnpm typecheck` and `pnpm build`. The build needs the `.env.local` keys, because it reads content from Hearth.
57
- `pnpm hearth:check` after changing blocks shows any block type the published content uses without a component.
113
+ `pnpm hearth:check` shows any block type the published content uses without a component (run `pnpm hearth:update`).
114
+ After `pnpm hearth:update`: `pnpm install`, then the checks; a type error in `src/theme/index.ts` means a slot was
115
+ renamed or removed (see `defaults.ts`).
58
116
 
59
117
  <!-- BEGIN:nextjs-agent-rules -->
60
118
 
@@ -1,8 +1,13 @@
1
1
  # Website powered by Hearth
2
2
 
3
3
  A Next.js site whose content (pages, menu, footer, events and forms) comes from
4
- [Hearth](https://hearth.thesmithy.io), The Smithy's content management system. **The code decides how things
5
- look; Hearth decides what's on the page.** Editors change content in Hearth, and you never hard-code it here.
4
+ [Hearth](https://hearth.thesmithy.io), The Smithy's content management system. **Hearth decides what's on the page;
5
+ this site decides how it looks.** Editors change content in Hearth, and you never hard-code it here.
6
+
7
+ Everything that works with Hearth (every block, the event and fundraiser pages, forms, previews, SEO) is already
8
+ built, in `src/hearth/`, and shared by every Hearth site. **You build the site by styling it** in `src/theme/`: brand
9
+ tokens, and Tailwind classes for each part of each block. `pnpm hearth:update` brings in new blocks and fixes later
10
+ without touching your styling. See "Styling the site" below.
6
11
 
7
12
  ## Before you start
8
13
 
@@ -48,11 +53,11 @@ The project comes with instructions for coding agents (`CLAUDE.md`, which Claude
48
53
 
49
54
  - *"Run the first steps in CLAUDE.md and tell me what's published and which blocks the site uses."*
50
55
  - *"Restyle the site to match the brand: colors `#1F3A5F` and `#C8A15A`, font Lora for headings. Start with
51
- the tokens in `globals.css`, then the hero, card grid and media-text blocks."*
56
+ the tokens in `src/theme/theme.css`, then the hero, card grid and media-text slots in `src/theme/index.ts`."*
52
57
  - *"Match the current website at https://example.org: compare each published page with the same page there
53
- and restyle the blocks until they look alike. Content stays in Hearth."*
54
- - *"The gallery block looks plain. Make it a masonry grid with a lightbox, keeping it accessible."*
55
- - *"Run `pnpm hearth:check` and add a component for every block type it flags."*
58
+ and restyle the blocks through the theme until they look alike. Content stays in Hearth."*
59
+ - *"The gallery looks plain. Make it a masonry grid using its slots in the theme, keeping it accessible."*
60
+ - *"Run `pnpm hearth:update`, then check every published page still looks right."*
56
61
 
57
62
  Keep the API key out of chat: the agent never needs to see it, only `.env.local` does. If it asks for content
58
63
  (page text, menus, contact details), the answer is to add or publish it in Hearth, not to write it into the code.
@@ -61,13 +66,11 @@ Keep the API key out of chat: the agent never needs to see it, only `.env.local`
61
66
 
62
67
  - **Seeing published changes locally.** Content is cached, and Hearth's publish webhook can't reach your
63
68
  computer. After publishing, do a hard refresh (Ctrl+Shift+R, or Cmd+Shift+R on a Mac).
64
- - **Making it the client's own.** Start with the brand tokens in `src/app/globals.css` (colors, font, content
65
- width), then restyle the block components in `src/components/blocks/`.
66
- - Every block uses semantic tones (`default`, `muted`, `inverse`, `accent`). Map them to the brand rather than
67
- hard-coding colors in a block.
68
- - Headings can mark words with `*asterisks*` to highlight them (see `Emphasis` in `src/components/ui.tsx`).
69
- - To leave a block type out, delete its line in `src/components/blocks/index.tsx` and ask for it to be turned
70
- off for the site in Hearth.
69
+ - **Making it the client's own.** See "Styling the site" below: tokens first, then slots.
70
+ - Every block uses semantic tones (`default`, `muted`, `inverse`, `accent`). Map them to the brand in the tokens
71
+ rather than setting colors block by block.
72
+ - Headings can mark words with `*asterisks*` to highlight them (the `emphasis` slot, and `hero.emphasis`).
73
+ - To leave a block type out, ask for it to be turned off for the site in Hearth.
71
74
  - **SEO comes from Hearth.** Titles, descriptions, canonical addresses, share images, structured data, the
72
75
  sitemap, robots.txt, redirects for old addresses, Search Console verification and the page's language all come
73
76
  from Hearth (`siteMetadata`, `generatePageMetadata`, `sitemapWith`, `robots`, `redirectOrNotFound`). Preview deploys
@@ -79,20 +82,42 @@ Keep the API key out of chat: the agent never needs to see it, only `.env.local`
79
82
  - Add both the bare domain and `www` to the site in Netlify, with the one Hearth uses as primary: Netlify then
80
83
  redirects the other to it.
81
84
 
85
+ ### Styling the site
86
+
87
+ | Where | What |
88
+ | --- | --- |
89
+ | `src/theme/theme.css` | Brand tokens (colors, fonts, content width) and the site's own CSS. **Start here** |
90
+ | `src/theme/index.ts` | The Tailwind classes of any part of any block, page, form, header or footer, by slot |
91
+ | `src/hearth/theme/defaults.ts` | Every slot and its default classes: read it to find the slot to change |
92
+ | `src/theme/fonts.ts` | Web fonts (next/font) |
93
+ | `src/theme/custom-blocks.tsx` | Components for the site's own block types (Settings → Custom blocks in Hearth) |
94
+ | `src/theme/overrides.tsx` | Last resort: replace a whole block's component, or draw the logo |
95
+ | `SITE.md` | Notes about this site's design, for the next person or coding agent |
96
+
97
+ A slot holds the classes of one element, e.g. `hero.title` or `cardGrid.card`. Set a string to replace the default,
98
+ or a function to add to it:
99
+
100
+ ```ts
101
+ export const theme = defineTheme({
102
+ hero: { title: "font-display text-7xl uppercase", inner: (base) => `${base} text-center` },
103
+ cardGrid: { card: "flex flex-col gap-3 border-2 border-ink p-6" },
104
+ text: { directions: "Directions" },
105
+ });
106
+ ```
107
+
108
+ States are Tailwind variants inside the slot: `on-dark:` for blocks on an inverse or accent ground,
109
+ `data-[size=full]:`, `aria-invalid:` and so on (listed at the top of `defaults.ts`).
110
+
82
111
  ### Where things live
83
112
 
84
113
  | Where | What |
85
114
  | --- | --- |
86
- | `src/lib/hearth.ts` | The Hearth client. Server-side only, because the API key is secret |
87
- | `src/app/[[...slug]]/page.tsx` | Every page in Hearth at its address (`home` is `/`) |
88
- | `src/components/blocks/` | One component per Hearth block type. **This is where the design lives** |
89
- | `src/app/globals.css` | Brand tokens: colors, font, content width |
90
- | `src/app/events/` | Event list and detail pages (sites with Events) |
91
- | `src/app/fundraisers/`, `src/app/donate/` | Fundraisers and donations (sites with Giving) |
92
- | `src/components/RegistrationForm.tsx`, `src/app/registered/` | On-site event registration (sites with Tickets) |
115
+ | `src/hearth/` | Hearth's code: blocks, pages, forms, header and footer. Shared by every site; **don't edit it** |
116
+ | `src/hearth/client.ts` | The Hearth client. Server-side only, because the API key is secret |
117
+ | `src/hearth/manifest.json` | The files Hearth manages here, and the release they came from |
118
+ | `src/app/` | Route files that point at `src/hearth/pages/`, plus any pages of the site's own |
93
119
  | `src/app/api/revalidate/route.ts` | Hearth calls this when something is published, so changes go live in seconds |
94
120
  | `src/app/api/preview/route.ts` | Opens drafts from Hearth's "Preview on the website"; `/api/preview/exit` ends it |
95
- | `<HearthPreview />` in `src/app/layout.tsx` | Refreshes the page while an editor previews and edits in Hearth |
96
121
  | `src/app/sitemap.ts`, `robots.ts` | Generated from Hearth. Search engines stay out until the site's web address is set |
97
122
 
98
123
  ## Deploying to Netlify
@@ -131,6 +156,9 @@ Without the webhook, published changes still appear, but can take up to an hour.
131
156
  The full reference (API, SDK, every block and its fields, previews, forms) is in Hearth under **API docs**,
132
157
  with a Markdown version to give an AI coding assistant. The SDK also ships `node_modules/@the-smithy/hearth/llms.md`.
133
158
 
134
- ## Updating the SDK
159
+ ## Updating
135
160
 
136
- `pnpm up @the-smithy/hearth`. New block types arrive as typed additions: add a component when the site needs one.
161
+ `pnpm hearth:update` updates Hearth's code in the site (`src/hearth/` and the other files `src/hearth/manifest.json`
162
+ lists) to the latest release, with the packages it needs. Your `src/theme/` and your own files aren't touched. Then
163
+ `pnpm install`, `pnpm typecheck`, `pnpm build`, and a look at the site. New blocks arrive styled by the defaults and
164
+ your tokens; give them slots in the theme if the design needs it.
@@ -0,0 +1,17 @@
1
+ # This site
2
+
3
+ Notes for whoever works on this site next, people and coding agents alike: the design's intent, decisions, and
4
+ anything unusual. Hearth's own instructions are in `CLAUDE.md` (replaced by `pnpm hearth:update`); this file is the
5
+ site's and stays as it is.
6
+
7
+ ## Design
8
+
9
+ - Brand: (colors, fonts, the look in a sentence)
10
+ - Reference: (the design file, the client's old site, design library boards)
11
+
12
+ ## Overrides
13
+
14
+ Components replaced in `src/theme/overrides.tsx`, and why slots, tokens and CSS couldn't do it. Check each against
15
+ `src/hearth/blocks` after an update.
16
+
17
+ - None.
@@ -9,7 +9,8 @@
9
9
  "start": "next start",
10
10
  "typecheck": "tsc --noEmit",
11
11
  "hearth:check": "node scripts/hearth.mjs check",
12
- "hearth:docs": "node scripts/hearth.mjs docs"
12
+ "hearth:docs": "node scripts/hearth.mjs docs",
13
+ "hearth:update": "node scripts/hearth.mjs update"
13
14
  },
14
15
  "dependencies": {
15
16
  "@the-smithy/ember": "^0.3.1",
@@ -2,9 +2,12 @@
2
2
  // pnpm hearth:check checks .env.local and the connection to Hearth, then lists what's published
3
3
  // pnpm hearth:docs saves Hearth's full API guide to hearth-api.md (not committed) for an AI assistant to read,
4
4
  // and installs any coding-assistant skills Hearth offers this site in .claude/skills/
5
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ // pnpm hearth:update updates Hearth's code in this site (src/hearth and the other files its manifest lists) to the
6
+ // latest release, and the packages it needs; src/theme and your own files stay as they are
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
6
9
  import path from "node:path";
7
- import { createHearthClient, DEFAULT_BASE_URL, isHearthError } from "@the-smithy/hearth";
10
+ import { gunzipSync } from "node:zlib";
8
11
 
9
12
  const command = process.argv[2];
10
13
  /** Stops with a message. Thrown rather than process.exit(), which can crash Node on Windows mid-request. */
@@ -13,7 +16,10 @@ const fail = (message) => {
13
16
  throw new Stop(message);
14
17
  };
15
18
 
16
- function readEnv() {
19
+ /** The SDK, loaded only by the commands that talk to Hearth (so hearth:update works before pnpm install). */
20
+ const sdk = () => import("@the-smithy/hearth");
21
+
22
+ async function readEnv() {
17
23
  if (!existsSync(".env.local")) fail("There's no .env.local. Copy .env.example to .env.local and fill it in (see README.md → First run).");
18
24
  const env = Object.fromEntries(
19
25
  readFileSync(".env.local", "utf8")
@@ -25,19 +31,23 @@ function readEnv() {
25
31
  if (!env.HEARTH_SITE) fail("HEARTH_SITE is empty in .env.local. It's the site's address: the part after /admin/sites/ in Hearth's address bar.");
26
32
  if (!env.HEARTH_API_KEY) fail("HEARTH_API_KEY is empty in .env.local. Create a key in Hearth → the site → API & webhooks → API keys.");
27
33
  if (!env.HEARTH_API_KEY.startsWith("hk_")) fail("HEARTH_API_KEY doesn't look like a Hearth key (they start with hk_). Copy it again from API & webhooks.");
28
- return { site: env.HEARTH_SITE, apiKey: env.HEARTH_API_KEY, baseUrl: (env.HEARTH_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "") };
34
+ return { site: env.HEARTH_SITE, apiKey: env.HEARTH_API_KEY, baseUrl: (env.HEARTH_BASE_URL || (await sdk()).DEFAULT_BASE_URL).replace(/\/+$/, "") };
29
35
  }
30
36
 
31
- /** Block types the design has a component for (the keys of `blocks` in src/components/blocks/index.tsx). */
37
+ /** Block types the site has a component for (the keys of `blocks` in src/hearth/blocks/index.tsx and src/theme/overrides.tsx). */
32
38
  function designedBlocks() {
33
- const file = "src/components/blocks/index.tsx";
34
- if (!existsSync(file)) return null;
35
- const map = readFileSync(file, "utf8").match(/export const blocks[^{]*\{([\s\S]*?)\n\};/);
36
- return map ? new Set([...map[1].matchAll(/^\s*([a-zA-Z]+):/gm)].map((m) => m[1])) : null;
39
+ const keys = (file) => {
40
+ if (!existsSync(file)) return [];
41
+ const map = readFileSync(file, "utf8").match(/export const blocks[^{]*\{([\s\S]*?)\n?\};/);
42
+ return map ? [...map[1].matchAll(/^\s*([a-zA-Z]+):/gm)].map((m) => m[1]) : [];
43
+ };
44
+ const found = [...keys("src/hearth/blocks/index.tsx"), ...keys("src/theme/overrides.tsx")];
45
+ return found.length ? new Set(found) : null;
37
46
  }
38
47
 
39
48
  async function check() {
40
- const env = readEnv();
49
+ const env = await readEnv();
50
+ const { createHearthClient, isHearthError } = await sdk();
41
51
  const hearth = createHearthClient(env);
42
52
  let site;
43
53
  try {
@@ -74,7 +84,7 @@ async function check() {
74
84
  console.log(`\nBlock types in published pages (how often):`);
75
85
  for (const [type, count] of [...used].sort((a, b) => b[1] - a[1])) {
76
86
  const missing = designed && !designed.has(type);
77
- console.log(` ${type.padEnd(22)} ${String(count).padStart(3)}${missing ? " ✗ no component in src/components/blocks/index.tsx" : ""}`);
87
+ console.log(` ${type.padEnd(22)} ${String(count).padStart(3)}${missing ? " ✗ no component: run pnpm hearth:update" : ""}`);
78
88
  }
79
89
  if (site.features.includes("events")) {
80
90
  const events = await hearth.events.list({ when: "upcoming" });
@@ -84,7 +94,7 @@ async function check() {
84
94
  }
85
95
 
86
96
  async function docs() {
87
- const env = readEnv();
97
+ const env = await readEnv();
88
98
  const res = await fetch(`${env.baseUrl}/admin/docs/api.md`, { headers: { Authorization: `Bearer ${env.apiKey}` } });
89
99
  if (!res.ok) fail(`Hearth didn't return the API guide (${res.status}). Check the API key with pnpm hearth:check.`);
90
100
  writeFileSync("hearth-api.md", await res.text());
@@ -125,10 +135,144 @@ async function skills(env) {
125
135
  if (names.length) console.log(`✓ Installed ${names.length === 1 ? "a skill" : `${names.length} skills`} for your coding assistant in .claude/skills/: ${names.join(", ")}.`);
126
136
  }
127
137
 
138
+ const PACKAGE = "@the-smithy/create-hearth-site";
139
+ const MANIFEST = "src/hearth/manifest.json";
140
+
141
+ /** The files in an npm package tarball (.tgz), by their path inside it ("package/…"). */
142
+ function untar(tgz) {
143
+ const tar = gunzipSync(tgz);
144
+ const files = new Map();
145
+ let longName = null;
146
+ for (let at = 0; at + 512 <= tar.length; ) {
147
+ const header = tar.subarray(at, at + 512);
148
+ if (header.every((b) => b === 0)) break;
149
+ const text = (from, len) => header.subarray(from, from + len).toString("utf8").split("\0")[0];
150
+ const size = parseInt(text(124, 12).trim() || "0", 8);
151
+ const type = text(156, 1);
152
+ const body = tar.subarray(at + 512, at + 512 + size);
153
+ const name = longName ?? [text(345, 155), text(0, 100)].filter(Boolean).join("/");
154
+ longName = null;
155
+ if (type === "x") longName = body.toString("utf8").match(/\d+ path=([^\n]*)\n/)?.[1] ?? null;
156
+ else if (type === "0" || type === "") files.set(name, body);
157
+ at += 512 + Math.ceil(size / 512) * 512;
158
+ }
159
+ return files;
160
+ }
161
+
162
+ /** Every file at a manifest entry in this site (an entry ending in / is a folder), relative to the site. */
163
+ function localFiles(entry) {
164
+ if (!existsSync(entry)) return [];
165
+ if (!entry.endsWith("/")) return [entry];
166
+ return readdirSync(entry, { recursive: true })
167
+ .map((f) => path.join(entry, String(f)).split(path.sep).join("/"))
168
+ .filter((f) => statSync(f).isFile());
169
+ }
170
+
171
+ const listed = (entries, file) => entries.some((e) => (e.endsWith("/") ? file.startsWith(e) : file === e));
172
+
173
+ /** Is version range a lower than b? (^1.2.3 against ^1.4.0) */
174
+ const lower = (a, b) => {
175
+ const v = (r) => (String(r).match(/(\d+)\.(\d+)\.(\d+)/) ?? []).slice(1).map(Number);
176
+ const [x, y] = [v(a), v(b)];
177
+ if (x.length < 3 || y.length < 3) return false;
178
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i];
179
+ return false;
180
+ };
181
+
182
+ /**
183
+ * Updates the code Hearth manages in this site to the latest release (or --version x.y.z): replaces the managed files
184
+ * listed in src/hearth/manifest.json, removes managed files the release dropped, adds seeded files that are new to
185
+ * it (never overwriting the site's), and raises the packages the code needs. src/theme and the site's own files
186
+ * aren't touched.
187
+ */
188
+ async function update() {
189
+ const args = process.argv.slice(3);
190
+ const force = args.includes("--force");
191
+ const wanted = args.includes("--version") ? args[args.indexOf("--version") + 1] : "latest";
192
+ if (!wanted) fail("Give a version after --version, e.g. --version 0.21.0.");
193
+ if (!existsSync(MANIFEST)) fail(`There's no ${MANIFEST}, so this site predates hearth:update. Move it onto the template's structure first (see CLAUDE.md).`);
194
+ const old = JSON.parse(readFileSync(MANIFEST, "utf8"));
195
+
196
+ // Managed files edited here would be lost: styling belongs in src/theme.
197
+ const git = spawnSync("git", ["status", "--porcelain", "--", ...old.managed], { encoding: "utf8" });
198
+ if (git.status === 0 && git.stdout.trim() && !force) {
199
+ fail(`These managed files have uncommitted changes, which the update would replace:\n${git.stdout.trimEnd()}\nCommit them first (and move any styling into src/theme), or run with --force to replace them.`);
200
+ }
201
+
202
+ // HEARTH_UPDATE_FROM=<a create-hearth-site .tgz> tries a release before it's published.
203
+ const prefix = "package/template/";
204
+ let tarball;
205
+ let version;
206
+ if (process.env.HEARTH_UPDATE_FROM) {
207
+ tarball = untar(readFileSync(process.env.HEARTH_UPDATE_FROM));
208
+ version = JSON.parse(tarball.get("package/package.json").toString("utf8")).version;
209
+ } else {
210
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE.replace("/", "%2f")}`);
211
+ if (!res.ok) fail(`npm didn't return ${PACKAGE} (${res.status}).`);
212
+ const meta = await res.json();
213
+ version = meta["dist-tags"]?.[wanted] ?? wanted;
214
+ const release = meta.versions?.[version];
215
+ if (!release) fail(`${PACKAGE} has no version ${wanted}.`);
216
+ if (version === old.version && !force) return console.log(`✓ Already on ${version}, the latest. (--force reinstalls it.)`);
217
+ const download = await fetch(release.dist.tarball);
218
+ if (!download.ok) fail(`Couldn't download ${PACKAGE}@${version} (${download.status}).`);
219
+ tarball = untar(Buffer.from(await download.arrayBuffer()));
220
+ }
221
+ const files = new Map([...tarball].filter(([name]) => name.startsWith(prefix)).map(([name, body]) => [name.slice(prefix.length), body]));
222
+ if (!files.has(MANIFEST)) fail(`${PACKAGE}@${version} has no ${MANIFEST}.`);
223
+ const next = JSON.parse(files.get(MANIFEST).toString("utf8"));
224
+
225
+ const changed = [];
226
+ // Managed files the release no longer has.
227
+ for (const file of old.managed.flatMap(localFiles)) {
228
+ if (files.has(file) || file === MANIFEST) continue;
229
+ rmSync(file);
230
+ changed.push(`- ${file}`);
231
+ }
232
+ for (const [file, body] of files) {
233
+ if (file === MANIFEST) continue;
234
+ const managed = listed(next.managed, file);
235
+ // Seeded files are the site's own: add only ones that are new to this release's list, and never over a file.
236
+ const newSeed = !managed && listed(next.seeded, file) && !listed(old.seeded ?? [], file) && !existsSync(file);
237
+ if (!managed && !newSeed) continue;
238
+ const existed = existsSync(file);
239
+ if (existed && readFileSync(file).equals(body)) continue;
240
+ mkdirSync(path.dirname(file), { recursive: true });
241
+ writeFileSync(file, body);
242
+ changed.push(`${existed ? "~" : "+"} ${file}`);
243
+ }
244
+ writeFileSync(MANIFEST, JSON.stringify({ ...next, version }, null, 2) + "\n");
245
+
246
+ // The packages the code needs, at least at the release's versions, and its hearth:* scripts. The site's own stay.
247
+ const pkg = JSON.parse(readFileSync("package.json", "utf8"));
248
+ const theirs = JSON.parse(files.get("package.json").toString("utf8"));
249
+ for (const field of ["dependencies", "devDependencies"]) {
250
+ for (const [name, range] of Object.entries(theirs[field] ?? {})) {
251
+ const want = String(range).startsWith("workspace:") ? `^${version}` : range;
252
+ const have = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name];
253
+ if (have && !lower(have, want)) continue;
254
+ pkg[field] = { ...pkg[field], [name]: want };
255
+ changed.push(`~ package.json: ${name} ${have ?? "(new)"} → ${want}`);
256
+ }
257
+ }
258
+ for (const [name, script] of Object.entries(theirs.scripts ?? {})) {
259
+ if (name.startsWith("hearth:") && pkg.scripts?.[name] !== script) pkg.scripts = { ...pkg.scripts, [name]: script };
260
+ }
261
+ writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");
262
+
263
+ console.log(`✓ Updated Hearth's code from ${old.version} to ${version}${changed.length ? ":" : " (no files changed)."}`);
264
+ for (const line of changed) console.log(` ${line}`);
265
+ console.log(`\nNext: pnpm install, then pnpm typecheck and pnpm build, and check the site with pnpm dev.`);
266
+ console.log(` A type error in src/theme means a slot was renamed or removed: see src/hearth/theme/defaults.ts.`);
267
+ console.log(` Components replaced in src/theme/overrides.tsx are the site's own: compare them with src/hearth/blocks.`);
268
+ console.log(` What changed: CHANGELOG.md in ${PACKAGE}@${version}.`);
269
+ }
270
+
128
271
  try {
129
272
  if (command === "check") await check();
130
273
  else if (command === "docs") await docs();
131
- else fail("Usage: node scripts/hearth.mjs check | docs");
274
+ else if (command === "update") await update();
275
+ else fail("Usage: node scripts/hearth.mjs check | docs | update [--version x.y.z] [--force]");
132
276
  } catch (err) {
133
277
  if (!(err instanceof Stop)) throw err;
134
278
  console.error(`\n✗ ${err.message}\n`);
@@ -1,54 +1,2 @@
1
- import { normalizeSlug } from "@the-smithy/hearth";
2
- import { metadataFromMeta } from "@the-smithy/hearth/next";
3
- import { JsonLd } from "@the-smithy/hearth/react";
4
- import type { Metadata } from "next";
5
- import { Blocks } from "@/components/blocks";
6
- import { EventView } from "@/components/EventView";
7
- import { hearthNext } from "@/lib/hearth";
8
- import { hasFeature } from "@/lib/site";
9
-
10
- type Props = { params: Promise<{ slug?: string[] }> };
11
-
12
- /**
13
- * An event in an event collection, e.g. /talks/<event> for a "Talks" collection. Pages are checked first,
14
- * so a page at the same address wins.
15
- */
16
- async function collectionEvent(slug: string[] | undefined) {
17
- if (!slug || slug.length < 2) return null;
18
- const site = await hearthNext.getSite();
19
- if (!hasFeature(site, "events")) return null;
20
- const prefix = normalizeSlug(slug.slice(0, -1));
21
- const collection = (await hearthNext.getCollections()).find((c) => c.kind === "events" && c.slug === prefix);
22
- if (!collection) return null;
23
- const event = await hearthNext.getEvent(normalizeSlug(slug.at(-1)));
24
- return event?.collection?.id === collection.id ? event : null;
25
- }
26
-
27
- /** Every page in Hearth at its path ("/" is "home", "/about" is "about", "/blog/post" is a blog entry), plus events in collections. */
28
- export default async function Page({ params }: Props) {
29
- const slug = (await params).slug;
30
- const page = await hearthNext.getPage(slug);
31
- if (page) {
32
- return (
33
- <>
34
- <JsonLd data={page.meta.jsonLd} />
35
- {/* Pages usually open with a Hero (the visible title); this keeps a heading for the rest. */}
36
- {page.blocks[0]?.type === "hero" ? null : <h1 className="sr-only">{page.title}</h1>}
37
- <Blocks blocks={page.blocks} />
38
- </>
39
- );
40
- }
41
-
42
- const event = await collectionEvent(slug);
43
- if (event) return <EventView event={event} />;
44
- // An old address (a renamed page, or the site before it moved to Hearth) redirects; anything else is a 404.
45
- return hearthNext.redirectOrNotFound(slug);
46
- }
47
-
48
- export async function generateMetadata({ params }: Props): Promise<Metadata> {
49
- const slug = (await params).slug;
50
- const metadata = await hearthNext.generatePageMetadata(slug);
51
- if (Object.keys(metadata).length) return metadata;
52
- const event = await collectionEvent(slug);
53
- return event ? metadataFromMeta(event.meta) : {};
54
- }
1
+ // Managed by Hearth (pnpm hearth:update replaces it). Style it in src/theme; the page is src/hearth/pages/page.tsx.
2
+ export { default, generateMetadata } from "@/hearth/pages/page";
@@ -1,4 +1,4 @@
1
- import { hearthNext } from "@/lib/hearth";
1
+ import { hearthNext } from "@/hearth/client";
2
2
 
3
3
  /** Ends a draft preview and returns to the published page (?path=/about). */
4
4
  export const GET = hearthNext.exitPreviewHandler();
@@ -1,4 +1,4 @@
1
- import { hearthNext } from "@/lib/hearth";
1
+ import { hearthNext } from "@/hearth/client";
2
2
 
3
3
  /**
4
4
  * Hearth's "Preview on the website" button opens this with a short-lived token. It checks the token with Hearth,
@@ -1,4 +1,4 @@
1
- import { hearthNext } from "@/lib/hearth";
1
+ import { hearthNext } from "@/hearth/client";
2
2
 
3
3
  /**
4
4
  * Hearth calls this when anything on the site is published, so changes show up within seconds.
@@ -1,5 +1,5 @@
1
1
  import { ImageResponse } from "next/og";
2
- import { hearthNext } from "@/lib/hearth";
2
+ import { hearthNext } from "@/hearth/client";
3
3
 
4
4
  export const size = { width: 180, height: 180 };
5
5
  export const contentType = "image/png";