@the-smithy/create-hearth-site 0.30.0 → 0.33.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.
@@ -21,7 +21,7 @@ const self = JSON.parse(await readFile(path.join(here, "..", "package.json"), "u
21
21
  // The template's lockfile is the Hearth workspace's (the SDK as a workspace link); a new site makes its own.
22
22
  const SKIP = new Set(["node_modules", ".next", ".netlify", ".vercel", ".env.local", "next-env.d.ts", "tsconfig.tsbuildinfo", "pnpm-lock.yaml"]);
23
23
  /** Dotfiles npm won't publish (or that would confuse the monorepo) are stored with a leading underscore. */
24
- const RENAMES = { _gitignore: ".gitignore", "_pnpm-workspace.yaml": "pnpm-workspace.yaml" };
24
+ const RENAMES = { _gitignore: ".gitignore", _gitattributes: ".gitattributes", "_pnpm-workspace.yaml": "pnpm-workspace.yaml" };
25
25
 
26
26
  const usage = `Usage: pnpm create @the-smithy/hearth-site@latest <folder> --site <address> [--skip-install]
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-smithy/create-hearth-site",
3
- "version": "0.30.0",
3
+ "version": "0.33.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,7 +30,7 @@
30
30
  ],
31
31
  "devDependencies": {
32
32
  "vitest": "^5.0.3",
33
- "@the-smithy/hearth-schema": "^0.30.0"
33
+ "@the-smithy/hearth-schema": "^0.33.0"
34
34
  },
35
35
  "scripts": {
36
36
  "test": "node --test \"test/**/*.test.js\" && vitest run test/blocks.test.ts test/overrides.test.ts",
@@ -83,8 +83,9 @@ Work in this order, and stop at the first step that gets the result:
83
83
  (`[data-page="events"]`, so the same blocks elsewhere stay as they are) or one card (`[data-card="our-history"]`, from
84
84
  its title).
85
85
  5. **A page in Hearth** for words the code draws on its own pages: see "Pages at the code's addresses" below.
86
- 6. **Overrides**, last: `src/theme/overrides.tsx` (see "Overrides" below). An override is the site's own code: updates
87
- don't reach it, so it falls behind Hearth. Use one only when everything above can't get there, build it from
86
+ 6. **Overrides**, last: `src/theme/overrides.tsx` (see "Overrides" below). Use one only when everything above can't
87
+ get there, and the smallest that does: add to Hearth's component rather than replace it, and replace one part of a
88
+ block rather than the whole. What the site replaces is its own code, which updates don't reach. Build it from
88
89
  `src/hearth/ui.tsx` and the theme, and say why in `SITE.md`.
89
90
 
90
91
  To style blocks without setting up pages for them, open `/hearth/blocks` while `pnpm dev` is running: each block with
@@ -94,9 +95,9 @@ It shows one block at a time: `/hearth/blocks?block=hero` goes to one, and `?blo
94
95
  Without a browser, the unstyled page is `node_modules/@the-smithy/hearth/block-reference.html`, but it's large: for slots,
95
96
  use `src/hearth/theme/defaults.ts`.
96
97
 
97
- Never copy a component out of `src/hearth/` to restyle it. If a design needs a slot or an option Hearth doesn't have,
98
- say so: it belongs in Hearth's template for every site (tell the developer to raise it with The Smithy), and an
99
- override can bridge the gap meanwhile.
98
+ Never copy a component out of `src/hearth/` to restyle or extend it: an override is given Hearth's own as `children`
99
+ for that. If a design needs a slot or an option Hearth doesn't have, say so: it belongs in Hearth's template for every
100
+ site (tell the developer to raise it with The Smithy), and an override can bridge the gap meanwhile.
100
101
 
101
102
  ### Pages at the code's addresses
102
103
 
@@ -112,19 +113,83 @@ never serves at `/not-found`, and redirects run before it.
112
113
  ### Overrides
113
114
 
114
115
  Everything is optional. A site's file only exports what it uses, and an update never replaces it. They can import
115
- Hearth's components, `Blocks` too. The types are in `src/hearth/overrides.ts`. They're used everywhere the template draws
116
- these things:
116
+ Hearth's components, `Blocks` too. The types are in `src/hearth/overrides.ts`.
117
+
118
+ **Each override is given Hearth's own version as `children`.** Keep it and add to it, and Hearth's stays up to date
119
+ underneath the site's; leave it out, and the site's replaces it and falls behind. Reach for them in this order:
120
+
121
+ 1. **Add to a part**, keeping Hearth's: `({ children }) => <>{children}<Extra /></>`.
122
+ 2. **Replace a part**: the part's own markup, the rest of the block still Hearth's.
123
+ 3. **Add to a whole block**: something before or after it, inside its anchor.
124
+ 4. **Replace a whole block**, last.
125
+
126
+ ```tsx
127
+ // src/theme/overrides.tsx
128
+ import type { Overrides } from "@/hearth/overrides";
129
+ import { theme } from "@/theme";
130
+
131
+ export const parts: Overrides["parts"] = {
132
+ // A third link beside the hero's two, which stay Hearth's.
133
+ "hero.actions": ({ children }) => (
134
+ <>
135
+ {children}
136
+ <a href="tel:+15551234567" className={theme.button.secondary}>Call us</a>
137
+ </>
138
+ ),
139
+ // Each card as Hearth draws it, with a note under the ones without a link.
140
+ "cardGrid.item": ({ item, children }) => (
141
+ <>
142
+ {children}
143
+ {item.link ? null : <p className={theme.cardGrid.body}>Coming soon</p>}
144
+ </>
145
+ ),
146
+ };
147
+
148
+ export const blocks: Overrides["blocks"] = {
149
+ // Hearth's Stats block, with a source line under it.
150
+ stats: ({ children }) => (
151
+ <>
152
+ {children}
153
+ <p className={theme.section.inner}>Figures audited in 2026.</p>
154
+ </>
155
+ ),
156
+ };
157
+ ```
158
+
159
+ **Parts** (`parts`, by name; every one is in `PartProps` in `src/hearth/overrides.ts`, and `get_block_type` on Hearth's
160
+ MCP server lists a block's). A part is what's inside its element, which stays Hearth's with its slot and attributes:
161
+
162
+ | Part | Is | Blocks |
163
+ | --- | --- | --- |
164
+ | `<type>.heading` | The block's heading: eyebrow, heading and intro (the hero's and a CTA's: the text above the buttons) | Most blocks |
165
+ | `<type>.item` | One item's contents, inside its `<li>` (a stat's `<div>`, a question's `<div>` or `<details>`). Given `item`, `index` and, for ones with a title, `heading` (`"h2"` or `"h3"`) | `stats`, `cardGrid`, `projects`, `pricing`, `faq`, `gallery`, `people`, `logos`, `locations`, `schedule`, `collectionList` |
166
+ | `<type>.actions` | The links inside the button row. The row is drawn for the site's part even with no links in Hearth | `hero`, `mediaText`, `cta`, `eventSpotlight`, `fundraiserSpotlight` |
167
+ | `<type>.media` | The image (children is null without one, and the part can still draw something) | `hero`, `mediaText`, `cta`, `eventSpotlight`, `fundraiserSpotlight` |
168
+ | `location.details` | The name, address, hours and directions beside the map | `location` |
169
+
170
+ Every part is given the `block` too.
171
+
172
+ **The rest**, used everywhere the template draws them:
117
173
 
118
- | Export | Replaces | Given |
174
+ | Export | Is | Given |
119
175
  | --- | --- | --- |
120
- | `blocks` | A whole block's component, by type | `{ block }`. An anchor from Hearth keeps working around it |
121
- | `Logo` | The logo in the header, and in the footer once the `footer.logo` slot has classes | `{ site, place }` |
176
+ | `blocks` | A whole block, by type. An anchor from Hearth goes around it, so Hearth's in `children` has none | `{ block }` |
177
+ | `Logo` | The logo in the header, and in the footer once the `footer.logo` slot has classes. Hearth's has no component, so no `children` | `{ site, place }` |
122
178
  | `Footer` | The footer | `{ site }` |
123
179
  | `EventPage` | An event's page, at `/events/…` and in collections, so a site needs no route of its own | `{ event }` |
124
180
  | `FundraiserPage` | A fundraiser's page | `{ fundraiser }` |
125
- | `EventCard`, `FundraiserCard` | The cards in lists; each draws an `<li>`, and may be async | `{ event }` or `{ fundraiser }`, and `heading`: `"h2"` or `"h3"`, for its title |
181
+ | `EventCard`, `FundraiserCard` | The cards in lists; each draws an `<li>` (Hearth's in `children` is one too), and may be async | `{ event }` or `{ fundraiser }`, and `heading`: `"h2"` or `"h3"`, for its title |
126
182
  | `DonateForm` | The donation form | The amounts, `fundraiser`, `fundraisers` (active ones to choose from) and `submitLabel` |
127
183
 
184
+ `children` works in client components too: it's Hearth's version already drawn, not a function. To draw Hearth's with
185
+ different content, import its component and give it changed data:
186
+ `import { Stats } from "@/hearth/blocks/content"`, then `<Stats block={{ ...block, data: { ...block.data, heading: "…" } }} />`.
187
+ Not through `Blocks`, which would draw the site's override again.
188
+
189
+ **After an update**, `pnpm hearth:update` lists what's overridden here whose Hearth version changed, and parts or
190
+ blocks Hearth no longer has. `pnpm hearth:overrides` checks against the last commit any time (`--since <git ref>` for
191
+ another). Look at each in `pnpm dev`.
192
+
128
193
  `EventPage` and `FundraiserPage` have their structured data drawn around them. To build on Hearth's: `EventContent`,
129
194
  `EventRegisterSection` and `EventVolunteerSection` from `components/EventView.tsx`, `FundraiserContent` from
130
195
  `components/FundraiserView.tsx`. A site's own routes are for pages Hearth has no address for, not for an event's or
@@ -153,8 +218,8 @@ each site stays on the template and on current releases:
153
218
  file, or write a site's own data layer, SEO or forms. A bug or a gap in Hearth's code gets fixed in Hearth's
154
219
  template and reaches the site by an update; say so to the developer, and if it can't wait, bridge it with an override
155
220
  and note it in `SITE.md`.
156
- - **After an update, look through `SITE.md`'s overrides** and compare each with the component it replaced in
157
- `src/hearth/blocks`, since updates don't reach them. Delete any that the theme can now do.
221
+ - **After an update, check the overrides** that `pnpm hearth:update` lists as changed in Hearth (`pnpm hearth:overrides`
222
+ any time), and look through `SITE.md`'s. Delete any that the theme or a smaller override can now do.
158
223
 
159
224
  ## First steps in a new checkout
160
225
 
@@ -166,10 +231,15 @@ Do these before writing code, and tell the developer what you found:
166
231
  it. If it fails, its message says what to fix (missing key, wrong `HEARTH_SITE`, revoked key). Ask the developer to
167
232
  fix `.env.local`; don't guess values.
168
233
  2. **Get the full reference:** `pnpm hearth:docs` saves Hearth's API guide (every block and its fields, events,
169
- forms, previews, SEO) to `hearth-api.md`. Read the parts you need from it, and from
234
+ forms, previews, SEO) to `hearth-api.md`, and its onboarding guide to `hearth-onboarding.md`: how to set up,
235
+ design and build a site, step by step, with code samples. Read the parts you need from them, and from
170
236
  `node_modules/@the-smithy/hearth/llms.md` for the SDK. It also installs Drift and any skills Hearth offers this site
171
237
  (see "Skills" below), so run it again when they're missing. These aren't committed, so some assistants' search skips
172
238
  them: open them by their exact path, or from the terminal if your tool won't.
239
+ Hearth's MCP server (`/api/mcp` on Hearth, read-only, with the site's API key) answers the same questions without
240
+ the files, if the developer has connected it: each block's fields, slots and starting classes, the tokens, and the
241
+ published content. For design work, its `get_design_workflow` tool is the way to carry a design from Figma, and
242
+ `read_onboarding_guide` reads the onboarding guide.
173
243
  3. **"Nothing is published"** means every page is a 404 until editors publish in Hearth. That's not a code
174
244
  problem: say so, and don't add placeholder content to work around it.
175
245
  4. **Then style the site** as above, checking with `pnpm dev` against the published pages, on a phone width too.
@@ -51,9 +51,10 @@ Once your API key is in `.env.local`:
51
51
 
52
52
  1. **Check the connection yourself, or have the agent do it:** `pnpm hearth:check` confirms the key works and
53
53
  lists the published pages and the block types they use. It never prints the key.
54
- 2. **Give the agent the full reference:** `pnpm hearth:docs` saves Hearth's API guide to `hearth-api.md` (not
55
- committed), and installs Drift and any coding-assistant skills Hearth offers your site in `.agents/skills/` and
56
- `.claude/skills/`. The agent's instructions already point it at both.
54
+ 2. **Give the agent the full reference:** `pnpm hearth:docs` saves Hearth's API guide to `hearth-api.md` and its
55
+ onboarding guide to `hearth-onboarding.md` (neither committed), and installs Drift and any coding-assistant skills
56
+ Hearth offers your site in `.agents/skills/` and `.claude/skills/`. The agent's instructions already point it at
57
+ them.
57
58
  3. **Then tell it what to build.** Prompts that work well:
58
59
 
59
60
  - *"Run the first steps in AGENTS.md and tell me what's published and which blocks the site uses."*
@@ -64,6 +65,11 @@ Once your API key is in `.env.local`:
64
65
  - *"The gallery looks plain. Make it a masonry grid using its slots in the theme, keeping it accessible."*
65
66
  - *"Run `pnpm hearth:update`, then check every published page still looks right."*
66
67
 
68
+ **Without the code, or for design work:** Hearth also has an MCP server, so an agent can read the site's blocks, design
69
+ system and published content from anywhere, read-only. Hearth's **API & webhooks** page shows how to connect it. Ask a
70
+ connected agent to start with `get_design_workflow` to design the site in Figma and bring the design back into
71
+ `src/theme/`.
72
+
67
73
  Keep the API key out of chat: the agent never needs to see it, only `.env.local` does. If it asks for content
68
74
  (page text, menus, contact details), the answer is to add or publish it in Hearth, not to write it into the code.
69
75
 
@@ -101,7 +107,7 @@ Keep the API key out of chat: the agent never needs to see it, only `.env.local`
101
107
  | `/hearth/blocks` | Not a file: while `pnpm dev` runs, this address shows each block with your theme, one at a time, without setting up pages for them. `?block=hero` goes to one, and `?block=all` shows the whole list. Its **Unstyled** switch shows them with no design, and each element's slot name |
102
108
  | `src/theme/fonts.ts` | Web fonts (next/font) |
103
109
  | `src/theme/custom-blocks.tsx` | Components for the site's own block types (Settings → Custom blocks in Hearth) |
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`) |
110
+ | `src/theme/overrides.tsx` | Last resort: add to or replace a part of a block (its heading, items, buttons or image), a whole block, the logo, the footer, the event or fundraiser page, their cards or the donation form. Each is given Hearth's own as `children`, to keep and add to (see `AGENTS.md`) |
105
111
  | `SITE.md` | Notes about this site's design, for the next person or coding agent |
106
112
 
107
113
  A slot holds the classes of one element, e.g. `hero.title` or `cardGrid.card`. Set a string to replace the default,
@@ -169,6 +175,10 @@ packages are public on npm, so nothing else is needed to install them.
169
175
 
170
176
  ## Documentation
171
177
 
178
+ **New to Hearth? Start with the onboarding guide**, in Hearth under **Onboarding guide** (`/admin/docs/onboarding`):
179
+ setting up this site, designing it in code or in Figma, building on the SDK, and prompting coding agents at each stage,
180
+ with sample prompts and the skills above. `pnpm hearth:docs` saves it as `hearth-onboarding.md` for your agent.
181
+
172
182
  The full reference (API, SDK, every block and its fields, previews, forms) is in Hearth under **API docs**,
173
183
  with a Markdown version to give an AI coding assistant. The SDK also ships `node_modules/@the-smithy/hearth/llms.md`.
174
184
  Every block is at `/hearth/blocks` while `pnpm dev` runs, with your theme or unstyled. Hearth has the unstyled page under **Block library**.
@@ -186,4 +196,5 @@ with `pnpm add @the-smithy/<name>@latest`.
186
196
 
187
197
  Keep the site on the template: design goes in `src/theme/`, and bugs or gaps in `src/hearth/` get fixed in Hearth's
188
198
  template rather than here. If a site has to bridge a gap, its override goes in `src/theme/overrides.tsx` with a note
189
- in `SITE.md`.
199
+ in `SITE.md`. `src/hearth/` is collapsed in pull requests (`.gitattributes`), and after an update `pnpm hearth:update`
200
+ lists the overrides whose Hearth version changed (`pnpm hearth:overrides` any time).
package/template/SITE.md CHANGED
@@ -11,7 +11,7 @@ site's and stays as it is.
11
11
 
12
12
  ## Overrides
13
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.
14
+ What `src/theme/overrides.tsx` adds to or replaces, and why slots, tokens and CSS couldn't do it. After an update,
15
+ check the ones `pnpm hearth:update` lists as changed in Hearth.
16
16
 
17
17
  - None.
@@ -0,0 +1,2 @@
1
+ # Hearth's code, which pnpm hearth:update replaces: collapsed in pull requests, where the site's own changes matter.
2
+ src/hearth/** linguist-generated=true
@@ -11,8 +11,9 @@ next-env.d.ts
11
11
  .env*
12
12
  !.env.example
13
13
 
14
- # Hearth's API guide, downloaded by pnpm hearth:docs for AI assistants.
14
+ # Hearth's API and onboarding guides, downloaded by pnpm hearth:docs for AI assistants.
15
15
  hearth-api.md
16
+ hearth-onboarding.md
16
17
 
17
18
  # Drift's code index, rebuilt as needed.
18
19
  .drift-index.json
@@ -11,6 +11,7 @@
11
11
  "hearth:check": "node scripts/hearth.mjs check",
12
12
  "hearth:docs": "node scripts/hearth.mjs docs",
13
13
  "hearth:drift": "node scripts/hearth.mjs drift",
14
+ "hearth:overrides": "node scripts/hearth.mjs overrides",
14
15
  "hearth:update": "node scripts/hearth.mjs update"
15
16
  },
16
17
  "dependencies": {
@@ -1,11 +1,14 @@
1
1
  // Setup helpers for this site, for you or your coding agent. Both read .env.local and never print the API key.
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
- // 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, and Drift
4
+ // pnpm hearth:docs saves Hearth's full API guide to hearth-api.md and its onboarding guide to hearth-onboarding.md
5
+ // (neither committed) for an AI assistant to read, and installs any coding-assistant skills
6
+ // Hearth offers this site, and Drift
6
7
  // pnpm hearth:drift installs or updates Drift, which finds code for coding agents, from its latest GitHub release
7
8
  // pnpm hearth:update updates Hearth's code in this site (src/hearth and the other files its manifest lists) to the
8
9
  // latest release, and the packages it needs; src/theme and your own files stay as they are
10
+ // pnpm hearth:overrides lists what src/theme/overrides.tsx overrides whose Hearth version changed since the last
11
+ // commit (or --since <git ref>), which hearth:update runs too
9
12
  import { spawnSync } from "node:child_process";
10
13
  import { createHash } from "node:crypto";
11
14
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -158,11 +161,25 @@ async function docs() {
158
161
  if (!res.ok) fail(`Hearth didn't return the API guide (${res.status}). Check the API key with pnpm hearth:check.`);
159
162
  writeFileSync("hearth-api.md", await res.text());
160
163
  console.log("✓ Saved Hearth's full API guide to hearth-api.md (not committed). Point your coding assistant at it.");
164
+ const guide = await fetch(`${env.baseUrl}/admin/docs/onboarding.md`, { headers: { Authorization: `Bearer ${env.apiKey}` } });
165
+ if (guide.ok) {
166
+ writeFileSync("hearth-onboarding.md", await guide.text());
167
+ ignore("hearth-onboarding.md");
168
+ console.log("✓ Saved Hearth's onboarding guide to hearth-onboarding.md (not committed): setup, design, the SDK, prompts and skills.");
169
+ } else console.log(` Hearth didn't return the onboarding guide (${guide.status}); skipped it.`);
161
170
  await skills(env);
162
171
  // Hearth's guide and skills are what this command is for, so Drift failing doesn't fail it.
163
172
  await drift().catch((err) => console.log(`! Skipped Drift: ${err.message}`));
164
173
  }
165
174
 
175
+ /** Adds a downloaded file to the site's .gitignore if it isn't there (sites made before it was downloaded lack it). */
176
+ function ignore(file) {
177
+ const text = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
178
+ if (text.split(/\r?\n/).some((l) => l.trim().replace(/^\//, "") === file)) return;
179
+ const gap = text && !text.endsWith("\n") ? "\n\n" : text ? "\n" : "";
180
+ writeFileSync(".gitignore", `${text}${gap}# Downloaded by pnpm hearth:docs for AI assistants.\n${file}\n`);
181
+ }
182
+
166
183
  /** The skills a folder's .gitignore lists, which are the installed ones; anything else there is the site's own. */
167
184
  function listedSkills(dir) {
168
185
  const listFile = path.join(dir, ".gitignore");
@@ -374,6 +391,14 @@ async function update() {
374
391
  }
375
392
  writeFileSync(MANIFEST, JSON.stringify({ ...next, version, ...(own.length ? { own } : {}) }, null, 2) + "\n");
376
393
 
394
+ // Hearth's code collapsed in pull requests, as a new site has it; the rest of .gitattributes is the site's.
395
+ const attributes = existsSync(".gitattributes") ? readFileSync(".gitattributes", "utf8") : "";
396
+ if (!attributes.split(/\r?\n/).some((l) => l.startsWith("src/hearth/**"))) {
397
+ const gap = attributes && !attributes.endsWith("\n") ? "\n\n" : attributes ? "\n" : "";
398
+ writeFileSync(".gitattributes", `${attributes}${gap}${lf(files.get("_gitattributes")?.toString("utf8") ?? "src/hearth/** linguist-generated=true\n")}`);
399
+ changed.push(`${attributes ? "~" : "+"} .gitattributes: src/hearth/ collapsed in pull requests`);
400
+ }
401
+
377
402
  // The packages the code needs, at least at the release's versions, and its hearth:* scripts. The site's own stay.
378
403
  const pkg = JSON.parse(readFileSync("package.json", "utf8"));
379
404
  const theirs = JSON.parse(files.get("package.json").toString("utf8"));
@@ -395,16 +420,110 @@ async function update() {
395
420
  for (const line of changed) console.log(` ${line}`);
396
421
  console.log(`\nNext: pnpm install, then pnpm typecheck and pnpm build, and check the site with pnpm dev.`);
397
422
  console.log(` A type error in src/theme means a slot was renamed or removed: see src/hearth/theme/defaults.ts.`);
398
- console.log(` Components replaced in src/theme/overrides.tsx are the site's own: compare them with src/hearth/blocks.`);
399
- console.log(` What changed: CHANGELOG.md in ${PACKAGE}@${version}.`);
423
+ console.log(` What changed: CHANGELOG.md in ${PACKAGE}@${version}.\n`);
424
+ // The managed files were committed before the update (it checks), so the last commit has the old ones.
425
+ overridesChanged("HEAD");
426
+ }
427
+
428
+ const OVERRIDES = "src/theme/overrides.tsx";
429
+ /** Hearth's own version of each of the other overrides: its file and component. */
430
+ const HEARTHS = {
431
+ Footer: ["src/hearth/layout/SiteFooter.tsx", "SiteFooter"],
432
+ EventPage: ["src/hearth/components/EventView.tsx", "EventContent"],
433
+ FundraiserPage: ["src/hearth/components/FundraiserView.tsx", "FundraiserContent"],
434
+ EventCard: ["src/hearth/blocks/events.tsx", "EventCard"],
435
+ FundraiserCard: ["src/hearth/blocks/giving.tsx", "FundraiserCard"],
436
+ DonateForm: ["src/hearth/forms/DonateForm.tsx", "DonateForm"],
437
+ };
438
+
439
+ const lf = (text) => text.replace(/\r\n/g, "\n");
440
+
441
+ /** A file at a git ref, from where this site is in the repo (null if it wasn't there or there's no git). */
442
+ function atRef(ref, file) {
443
+ const res = spawnSync("git", ["show", `${ref}:./${file}`], { encoding: "utf8" });
444
+ return res.status === 0 ? lf(res.stdout) : null;
445
+ }
446
+
447
+ /** A top-level function's source, to its closing brace at the start of a line. */
448
+ function component(source, name) {
449
+ const start = source?.search(new RegExp(`^(?:export )?(?:async )?function ${name}\\b`, "m")) ?? -1;
450
+ if (start < 0) return null;
451
+ const end = source.indexOf("\n}\n", start);
452
+ return source.slice(start, end < 0 ? undefined : end + 3);
453
+ }
454
+
455
+ /** The keys of an exported object (`export const parts = { "hero.actions": …, stats, … }`): what's at its top level. */
456
+ function exportedKeys(source, name) {
457
+ const at = source.search(new RegExp(`^export const ${name}\\b[^=]*=\\s*\\{`, "m"));
458
+ if (at < 0) return [];
459
+ let depth = 0;
460
+ let top = "";
461
+ for (let i = source.indexOf("{", source.indexOf("=", at)); i < source.length; i++) {
462
+ const c = source[i];
463
+ if ("([{".includes(c)) depth++;
464
+ else if (")]}".includes(c) && --depth === 0) break;
465
+ if (depth === 1 && c !== "{") top += c;
466
+ else if (depth === 2 && "([{".includes(c)) top += " ";
467
+ }
468
+ // A key starts a line or the object (`stats: …` or shorthand `stats,`), or follows a comma with a colon after it.
469
+ // That leaves out most words in JSX text, which sits at this level too.
470
+ return [...top.matchAll(/(?:(?:^|\n)\s*(?:"([\w.]+)"|'([\w.]+)'|([A-Za-z_]\w*))\s*(?=[:,]|$)|,\s*(?:"([\w.]+)"|'([\w.]+)'|([A-Za-z_]\w*))\s*(?=:))/g)].map(
471
+ (m) => m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5] ?? m[6],
472
+ );
473
+ }
474
+
475
+ /**
476
+ * What src/theme/overrides.tsx overrides whose Hearth version changed since a git ref: the site's code adds to it or
477
+ * replaces it, so check each still fits. Also names parts and blocks Hearth no longer has.
478
+ */
479
+ function overridesChanged(ref) {
480
+ if (!existsSync(OVERRIDES)) return;
481
+ const source = lf(readFileSync(OVERRIDES, "utf8"));
482
+ const map = lf(readFileSync("src/hearth/blocks/index.tsx", "utf8"));
483
+ const typesOf = Object.fromEntries([...map.slice(map.indexOf("export const blocks")).matchAll(/^\s+(\w+): (\w+),$/gm)].map((m) => [m[1], m[2]]));
484
+ const partNames = new Set([...lf(readFileSync("src/hearth/overrides.ts", "utf8")).matchAll(/^\s+"(\w+\.\w+)":/gm)].map((m) => m[1]));
485
+ const blockFiles = readdirSync("src/hearth/blocks").map((f) => `src/hearth/blocks/${f}`);
486
+ const where = (name) => blockFiles.find((f) => component(lf(readFileSync(f, "utf8")), name)) ?? "src/hearth/blocks/index.tsx";
487
+
488
+ /** [what the site overrides, Hearth's file, Hearth's component] */
489
+ const checks = [];
490
+ const gone = [];
491
+ for (const type of exportedKeys(source, "blocks")) {
492
+ if (!typesOf[type]) gone.push(`blocks.${type}`);
493
+ else checks.push([`blocks.${type}`, where(typesOf[type]), typesOf[type]]);
494
+ }
495
+ for (const part of exportedKeys(source, "parts")) {
496
+ const name = typesOf[part.split(".")[0]];
497
+ if (!partNames.has(part) || !name) gone.push(`parts["${part}"]`);
498
+ else checks.push([`parts["${part}"]`, where(name), name]);
499
+ }
500
+ for (const [name, [file, hearths]] of Object.entries(HEARTHS)) {
501
+ const line = source.match(new RegExp(`^export (?:const|function|async function) ${name}\\b.*$`, "m"))?.[0];
502
+ if (line && !/=\s*null;$/.test(line)) checks.push([name, file, hearths]);
503
+ }
504
+ if (!checks.length && !gone.length) return console.log(`✓ ${OVERRIDES} overrides nothing of Hearth's.`);
505
+ if (spawnSync("git", ["rev-parse", "--verify", "--quiet", ref]).status !== 0) {
506
+ return console.log(`Couldn't read ${ref} from git, so compare what ${OVERRIDES} overrides with src/hearth yourself.`);
507
+ }
508
+
509
+ const changed = checks.filter(([, file, name]) => component(atRef(ref, file), name) !== component(lf(readFileSync(file, "utf8")), name));
510
+ if (gone.length) console.log(`✗ Hearth has none of these any more, so ${OVERRIDES} overrides nothing with them: ${gone.join(", ")}.`);
511
+ if (!changed.length) return console.log(`✓ Nothing ${OVERRIDES} overrides has changed in Hearth since ${ref}.`);
512
+ console.log(`Changed in Hearth since ${ref}, and overridden in ${OVERRIDES}. Check each still fits (pnpm dev):`);
513
+ for (const [what, file, name] of changed) console.log(` ${what}: ${name} in ${file}`);
514
+ console.log(` See the changes: git diff ${ref} -- ${[...new Set(changed.map(([, file]) => file))].join(" ")}`);
400
515
  }
401
516
 
402
517
  try {
403
518
  if (command === "check") await check();
404
519
  else if (command === "docs") await docs();
405
520
  else if (command === "update") await update();
521
+ else if (command === "overrides") {
522
+ const args = process.argv.slice(3);
523
+ overridesChanged(args.includes("--since") ? args[args.indexOf("--since") + 1] || fail("Give a git ref after --since, e.g. --since HEAD~1.") : "HEAD");
524
+ }
406
525
  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]");
526
+ else fail("Usage: node scripts/hearth.mjs check | docs | drift | update [--version x.y.z] [--force] | overrides [--since <git ref>]");
408
527
  } catch (err) {
409
528
  if (!(err instanceof Stop)) throw err;
410
529
  console.error(`\n✗ ${err.message}\n`);
@@ -2,33 +2,36 @@ import Link from "next/link";
2
2
  import type { BlockOf, CollectionEntry } from "@the-smithy/hearth";
3
3
  import { theme } from "@/theme";
4
4
  import { formatEntryDate } from "../format";
5
+ import { Part } from "../parts";
5
6
  import { fill, Img, Section, SectionHeading } from "../ui";
6
7
 
7
8
  const t = theme.collectionList;
8
9
 
9
- function Card({ entry, heading }: { entry: CollectionEntry; heading?: string }) {
10
- const Title = heading ? "h3" : "h2";
10
+ function Card({ block, entry, index }: { block: BlockOf<"collectionList">; entry: CollectionEntry; index: number }) {
11
+ const Title = block.data.heading ? "h3" : "h2";
11
12
  return (
12
13
  <li className={t.card}>
13
- {entry.image ? <Img image={entry.image} crop={16 / 10} className={t.image} /> : null}
14
- <div className={t.body}>
15
- {entry.date ? <p className={t.date}>{formatEntryDate(entry.date)}</p> : null}
16
- <Title className={t.title}>
17
- <Link href={entry.path} className={t.link}>
18
- {entry.title}
19
- </Link>
20
- </Title>
21
- {entry.summary ? <p className={t.summary}>{entry.summary}</p> : null}
22
- {entry.tags.length ? (
23
- <ul className={t.tags}>
24
- {entry.tags.map((tag) => (
25
- <li key={tag} className={t.tag}>
26
- {tag}
27
- </li>
28
- ))}
29
- </ul>
30
- ) : null}
31
- </div>
14
+ <Part name="collectionList.item" props={{ block, item: entry, index, heading: Title }}>
15
+ {entry.image ? <Img image={entry.image} crop={16 / 10} className={t.image} /> : null}
16
+ <div className={t.body}>
17
+ {entry.date ? <p className={t.date}>{formatEntryDate(entry.date)}</p> : null}
18
+ <Title className={t.title}>
19
+ <Link href={entry.path} className={t.link}>
20
+ {entry.title}
21
+ </Link>
22
+ </Title>
23
+ {entry.summary ? <p className={t.summary}>{entry.summary}</p> : null}
24
+ {entry.tags.length ? (
25
+ <ul className={t.tags}>
26
+ {entry.tags.map((tag) => (
27
+ <li key={tag} className={t.tag}>
28
+ {tag}
29
+ </li>
30
+ ))}
31
+ </ul>
32
+ ) : null}
33
+ </div>
34
+ </Part>
32
35
  </li>
33
36
  );
34
37
  }
@@ -38,30 +41,34 @@ export function CollectionList({ block }: { block: BlockOf<"collectionList"> })
38
41
  const { entries, collection, layout } = block.data;
39
42
  return (
40
43
  <Section block="collectionList" id={block.anchor}>
41
- <SectionHeading eyebrow={block.data.eyebrow} heading={block.data.heading} intro={block.data.intro} />
44
+ <Part name="collectionList.heading" props={{ block }}>
45
+ <SectionHeading eyebrow={block.data.eyebrow} heading={block.data.heading} intro={block.data.intro} />
46
+ </Part>
42
47
  {!entries.length ? (
43
48
  <p className={t.empty}>{theme.text.noEntries}</p>
44
49
  ) : layout === "list" ? (
45
50
  <ul className={t.list}>
46
- {entries.map((e) => (
51
+ {entries.map((e, i) => (
47
52
  <li key={e.path} className={t.listItem}>
48
- <Link href={e.path} className={t.listLink}>
49
- {e.title}
50
- </Link>
51
- {e.date || e.summary ? (
52
- <p className={t.listMeta}>
53
- {e.date ? <span className={t.listDate}>{formatEntryDate(e.date)}</span> : null}
54
- {e.date && e.summary ? " · " : null}
55
- {e.summary}
56
- </p>
57
- ) : null}
53
+ <Part name="collectionList.item" props={{ block, item: e, index: i, heading: block.data.heading ? "h3" : "h2" }}>
54
+ <Link href={e.path} className={t.listLink}>
55
+ {e.title}
56
+ </Link>
57
+ {e.date || e.summary ? (
58
+ <p className={t.listMeta}>
59
+ {e.date ? <span className={t.listDate}>{formatEntryDate(e.date)}</span> : null}
60
+ {e.date && e.summary ? " · " : null}
61
+ {e.summary}
62
+ </p>
63
+ ) : null}
64
+ </Part>
58
65
  </li>
59
66
  ))}
60
67
  </ul>
61
68
  ) : (
62
69
  <ul className={t.cards}>
63
- {entries.map((e) => (
64
- <Card key={e.path} entry={e} heading={block.data.heading} />
70
+ {entries.map((e, i) => (
71
+ <Card key={e.path} block={block} entry={e} index={i} />
65
72
  ))}
66
73
  </ul>
67
74
  )}