@the-smithy/create-hearth-site 0.0.0-stage → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +17 -0
- package/README.md +36 -2
- package/bin/create-hearth-site.js +120 -0
- package/package.json +34 -3
- package/template/.env.example +8 -0
- package/template/CLAUDE.md +37 -0
- package/template/README.md +68 -0
- package/template/_gitignore +11 -0
- package/template/_pnpm-workspace.yaml +2 -0
- package/template/netlify.toml +6 -0
- package/template/next.config.ts +27 -0
- package/template/package.json +32 -0
- package/template/postcss.config.mjs +3 -0
- package/template/src/app/[[...slug]]/page.tsx +54 -0
- package/template/src/app/actions.ts +43 -0
- package/template/src/app/api/preview/exit/route.ts +4 -0
- package/template/src/app/api/preview/route.ts +8 -0
- package/template/src/app/api/revalidate/route.ts +8 -0
- package/template/src/app/donate/page.tsx +27 -0
- package/template/src/app/donate/thank-you/page.tsx +22 -0
- package/template/src/app/events/[slug]/page.tsx +25 -0
- package/template/src/app/events/page.tsx +21 -0
- package/template/src/app/fundraisers/[slug]/page.tsx +55 -0
- package/template/src/app/fundraisers/page.tsx +29 -0
- package/template/src/app/globals.css +75 -0
- package/template/src/app/layout.tsx +39 -0
- package/template/src/app/not-found.tsx +14 -0
- package/template/src/app/registered/page.tsx +22 -0
- package/template/src/app/robots.ts +10 -0
- package/template/src/app/sitemap.ts +4 -0
- package/template/src/components/DonateForm.tsx +97 -0
- package/template/src/components/EventView.tsx +48 -0
- package/template/src/components/Registration.tsx +42 -0
- package/template/src/components/RegistrationForm.tsx +103 -0
- package/template/src/components/SiteFooter.tsx +61 -0
- package/template/src/components/SiteHeader.tsx +31 -0
- package/template/src/components/blocks/EmberFormBlock.tsx +38 -0
- package/template/src/components/blocks/Form.tsx +92 -0
- package/template/src/components/blocks/collections.tsx +75 -0
- package/template/src/components/blocks/content.tsx +257 -0
- package/template/src/components/blocks/custom.tsx +20 -0
- package/template/src/components/blocks/events.tsx +92 -0
- package/template/src/components/blocks/giving.tsx +120 -0
- package/template/src/components/blocks/index.tsx +65 -0
- package/template/src/components/blocks/info.tsx +52 -0
- package/template/src/components/blocks/media.tsx +107 -0
- package/template/src/components/ui.tsx +105 -0
- package/template/src/lib/hearth.ts +12 -0
- package/template/src/lib/site.ts +9 -0
- package/template/tsconfig.json +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Copyright (c) The Smithy. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This package is published publicly so it can be installed easily. Publishing it does not make it open source.
|
|
4
|
+
|
|
5
|
+
You may install and use this package, and modify copies of it, only to build, run and maintain software that
|
|
6
|
+
works with The Smithy's services (Hearth and Ember), and only under an active account with those services.
|
|
7
|
+
You may include it in the software you build for that purpose and distribute it as part of that software.
|
|
8
|
+
|
|
9
|
+
You may not use this package, or any part of it, to build or operate a product or service that competes with
|
|
10
|
+
The Smithy's services, and you may not distribute it on its own.
|
|
11
|
+
|
|
12
|
+
THE PACKAGE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
|
|
13
|
+
THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
14
|
+
SMITHY BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
15
|
+
ARISING FROM, OUT OF OR IN CONNECTION WITH THE PACKAGE OR THE USE OF OR OTHER DEALINGS IN THE PACKAGE.
|
|
16
|
+
|
|
17
|
+
Questions about licensing: hello@thesmithy.io
|
package/README.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @the-smithy/create-hearth-site
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Starts a new website powered by [Hearth](https://hearth.thesmithy.io), The Smithy's headless CMS: a Next.js
|
|
4
|
+
site with every content block rendered, SEO, forms, live preview and the publish webhook, ready to restyle.
|
|
5
|
+
|
|
6
|
+
**It needs API access to Hearth.** The site reads all of its content from a Hearth site with that site's API
|
|
7
|
+
key, so it shows nothing without a Hearth account. Developer access is by invitation: request it at
|
|
8
|
+
[hearth.thesmithy.io/developers](https://hearth.thesmithy.io/developers).
|
|
9
|
+
|
|
10
|
+
## Getting started
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
pnpm create @the-smithy/hearth-site my-site --site <your-site>
|
|
14
|
+
cd my-site
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Put a Delivery API key from your site in Hearth (Settings → Developers) in `.env.local`, then:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm dev
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
| Option | |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `<directory>` | The folder to create |
|
|
26
|
+
| `--site <slug>` | Your site's address in Hearth. You're asked for it if left out |
|
|
27
|
+
| `--skip-install` | Don't run `pnpm install` |
|
|
28
|
+
|
|
29
|
+
The new site's README covers local development and deploying.
|
|
30
|
+
|
|
31
|
+
## Documentation
|
|
32
|
+
|
|
33
|
+
Once you have developer access, the full documentation is in your Hearth account under **API docs**.
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
For use with Hearth. See [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* pnpm create @the-smithy/hearth-site <directory> [--site <slug>] [--skip-install]
|
|
4
|
+
*
|
|
5
|
+
* Copies the template next to this file into a new directory, names it, points it at
|
|
6
|
+
* the matching SDK version, and installs. No network calls besides `pnpm install`.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
|
10
|
+
import { cp, readFile, rename, writeFile } from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { createInterface } from "node:readline/promises";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { parseArgs } from "node:util";
|
|
15
|
+
|
|
16
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const templateDir = path.join(here, "..", "template");
|
|
18
|
+
const self = JSON.parse(await readFile(path.join(here, "..", "package.json"), "utf8"));
|
|
19
|
+
|
|
20
|
+
/** Files that exist in the template folder during development but never belong in a new site. */
|
|
21
|
+
// The template's lockfile is the Hearth workspace's (the SDK as a workspace link); a new site makes its own.
|
|
22
|
+
const SKIP = new Set(["node_modules", ".next", ".netlify", ".env.local", "next-env.d.ts", "tsconfig.tsbuildinfo", "pnpm-lock.yaml"]);
|
|
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" };
|
|
25
|
+
|
|
26
|
+
const usage = `Usage: pnpm create @the-smithy/hearth-site <directory> [--site <slug>] [--skip-install]
|
|
27
|
+
|
|
28
|
+
<directory> Folder to create (must be new or empty)
|
|
29
|
+
--site <slug> The site's address in Hearth (Site settings), e.g. acme
|
|
30
|
+
--skip-install Don't run pnpm install`;
|
|
31
|
+
|
|
32
|
+
export function packageName(dir) {
|
|
33
|
+
return (
|
|
34
|
+
path
|
|
35
|
+
.basename(path.resolve(dir))
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[^a-z0-9-~._]+/g, "-")
|
|
38
|
+
.replace(/^[-._]+|-+$/g, "") || "hearth-site"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const validSlug = (slug) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug);
|
|
43
|
+
|
|
44
|
+
async function main() {
|
|
45
|
+
const { values, positionals } = parseArgs({
|
|
46
|
+
allowPositionals: true,
|
|
47
|
+
options: { site: { type: "string" }, "skip-install": { type: "boolean" }, help: { type: "boolean", short: "h" } },
|
|
48
|
+
});
|
|
49
|
+
if (values.help) return console.log(usage);
|
|
50
|
+
|
|
51
|
+
let dir = positionals[0];
|
|
52
|
+
let site = values.site;
|
|
53
|
+
if ((!dir || !site) && process.stdin.isTTY) {
|
|
54
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
55
|
+
dir ||= (await rl.question("Folder for the new site: ")).trim();
|
|
56
|
+
site ||= (await rl.question("Site address in Hearth (e.g. acme): ")).trim();
|
|
57
|
+
rl.close();
|
|
58
|
+
}
|
|
59
|
+
if (!dir) {
|
|
60
|
+
console.error(usage);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
if (site && !validSlug(site)) {
|
|
64
|
+
console.error(`"${site}" isn't a Hearth site address: use lowercase letters, numbers and dashes.`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const target = path.resolve(dir);
|
|
69
|
+
if (existsSync(target) && readdirSync(target).length > 0) {
|
|
70
|
+
console.error(`${dir} already exists and isn't empty.`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await cp(templateDir, target, { recursive: true, filter: (src) => !SKIP.has(path.basename(src)) });
|
|
75
|
+
for (const [from, to] of Object.entries(RENAMES)) {
|
|
76
|
+
if (existsSync(path.join(target, from))) await rename(path.join(target, from), path.join(target, to));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Name the project and pin the SDK to the version released alongside this template.
|
|
80
|
+
const pkgPath = path.join(target, "package.json");
|
|
81
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
|
|
82
|
+
pkg.name = packageName(dir);
|
|
83
|
+
for (const deps of [pkg.dependencies, pkg.devDependencies]) {
|
|
84
|
+
for (const [name, range] of Object.entries(deps ?? {})) {
|
|
85
|
+
if (typeof range === "string" && range.startsWith("workspace:")) deps[name] = `^${self.version}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
89
|
+
|
|
90
|
+
const env = (await readFile(path.join(target, ".env.example"), "utf8")).replace(/^HEARTH_SITE=.*$/m, `HEARTH_SITE=${site ?? ""}`);
|
|
91
|
+
await writeFile(path.join(target, ".env.local"), env);
|
|
92
|
+
|
|
93
|
+
if (spawnSync("git", ["--version"], { stdio: "ignore" }).status === 0) {
|
|
94
|
+
spawnSync("git", ["init", "-q", "-b", "main"], { cwd: target, stdio: "ignore" });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let installed = false;
|
|
98
|
+
if (!values["skip-install"]) {
|
|
99
|
+
console.log("\nInstalling…");
|
|
100
|
+
installed = spawnSync("pnpm", ["install"], { cwd: target, stdio: "inherit", shell: process.platform === "win32" }).status === 0;
|
|
101
|
+
if (!installed) console.warn("\nInstall failed. Fix the error above, then run pnpm install in the new folder.");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rel = path.relative(process.cwd(), target) || ".";
|
|
105
|
+
console.log(`
|
|
106
|
+
Created ${pkg.name} in ${rel}.
|
|
107
|
+
|
|
108
|
+
Next:
|
|
109
|
+
cd ${rel}
|
|
110
|
+
${installed ? "" : " pnpm install\n"} Add the site's API key to .env.local (Hearth → the site → Settings → Developers)
|
|
111
|
+
pnpm dev
|
|
112
|
+
|
|
113
|
+
README.md covers deploying to Netlify and the publish webhook.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Run when executed (pnpm create runs it through a symlink), not when imported by tests.
|
|
117
|
+
const invoked = process.argv[1] && realpathSync(process.argv[1]);
|
|
118
|
+
if (invoked && invoked === realpathSync(fileURLToPath(import.meta.url))) {
|
|
119
|
+
await main();
|
|
120
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@the-smithy/create-hearth-site",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "Start a new client website powered by Hearth: pnpm create @the-smithy/hearth-site",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/The-Smithy/hearth.git",
|
|
10
|
+
"directory": "packages/create-hearth-site"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"create-hearth-site": "./bin/create-hearth-site.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"template",
|
|
21
|
+
"!template/node_modules",
|
|
22
|
+
"!template/.next",
|
|
23
|
+
"!template/.netlify",
|
|
24
|
+
"!template/.env.local",
|
|
25
|
+
"!template/next-env.d.ts",
|
|
26
|
+
"!template/tsconfig.tsbuildinfo",
|
|
27
|
+
"!template/pnpm-lock.yaml",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"vitest": "^5.0.3",
|
|
32
|
+
"@the-smithy/hearth-schema": "^0.13.0"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node --test \"test/**/*.test.js\" && vitest run test/blocks.test.ts"
|
|
36
|
+
}
|
|
6
37
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# The site's address in Hearth (shown in the admin's site switcher and URLs).
|
|
2
|
+
HEARTH_SITE=
|
|
3
|
+
# A Delivery API key (Hearth → Settings → Developers). Secret: server-side only.
|
|
4
|
+
HEARTH_API_KEY=
|
|
5
|
+
# Signing secret for the revalidation webhook (Hearth → Settings → Developers).
|
|
6
|
+
HEARTH_WEBHOOK_SECRET=
|
|
7
|
+
# Optional: point at a different Hearth (e.g. a staging deploy).
|
|
8
|
+
# HEARTH_BASE_URL=https://hearth.thesmithy.io
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Notes for Claude
|
|
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.
|
|
5
|
+
|
|
6
|
+
## Rules
|
|
7
|
+
|
|
8
|
+
- **Content comes from Hearth.** Don't hard-code page text, menus, contact details or SEO tags. They're edited
|
|
9
|
+
in Hearth and arrive through `src/lib/hearth.ts`. The code owns design and layout.
|
|
10
|
+
- **The API key is secret.** Only import `@/lib/hearth` from server components, route handlers and server
|
|
11
|
+
actions. Never from a `"use client"` file.
|
|
12
|
+
- **Use `hearthNext.*` for reads.** It caches and tags requests so the publish webhook can refresh them.
|
|
13
|
+
Calling `hearth.*` directly skips the cache.
|
|
14
|
+
- **Blocks are `{ id, type, data }`.** Each block type has one component in `src/components/blocks/`, typed
|
|
15
|
+
with `BlockOf<"type">`. Keep them working for every option the block offers: tones, image positions,
|
|
16
|
+
optional fields left empty.
|
|
17
|
+
- **Styling uses the tokens in `src/app/globals.css`** (`bg-accent`, `text-highlight`, …). Tones are semantic.
|
|
18
|
+
Check text contrast on every ground.
|
|
19
|
+
- **No bundled content.** Every page comes from Hearth; an address with no published page is a 404. Don't add
|
|
20
|
+
fallback or sample content to the code.
|
|
21
|
+
- **Previews come from the SDK.** `<HearthPreview />` in the root layout shows drafts and refreshes as editors
|
|
22
|
+
work in Hearth. Don't build a preview banner of your own.
|
|
23
|
+
- **Forms** post through `submitHearthForm` in `src/app/actions.ts`. Keep the hidden `_gotcha` field.
|
|
24
|
+
|
|
25
|
+
## Checks
|
|
26
|
+
|
|
27
|
+
`pnpm typecheck` and `pnpm build`. The build needs the `.env.local` keys, because it reads content from Hearth.
|
|
28
|
+
|
|
29
|
+
<!-- BEGIN:nextjs-agent-rules -->
|
|
30
|
+
|
|
31
|
+
# This is NOT the Next.js you know
|
|
32
|
+
|
|
33
|
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
|
34
|
+
|
|
35
|
+
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
|
36
|
+
|
|
37
|
+
<!-- END:nextjs-agent-rules -->
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Website powered by Hearth
|
|
2
|
+
|
|
3
|
+
A Next.js site whose content (pages, menu, footer, events and forms) is managed in
|
|
4
|
+
[Hearth](https://hearth.thesmithy.io). The code decides how things look. Hearth decides what's on the page.
|
|
5
|
+
|
|
6
|
+
## Setup
|
|
7
|
+
|
|
8
|
+
1. **Keys.** Copy `.env.example` to `.env.local` (the setup command already did, with `HEARTH_SITE` filled
|
|
9
|
+
in) and fill it in from the site's Settings → Developers page in Hearth:
|
|
10
|
+
- `HEARTH_SITE`: the site's address
|
|
11
|
+
- `HEARTH_API_KEY`: create one under Settings → Developers
|
|
12
|
+
- `HEARTH_WEBHOOK_SECRET`: from Revalidation webhook (only needed once the site is deployed)
|
|
13
|
+
2. `pnpm install`, then `pnpm dev`.
|
|
14
|
+
3. **Content.** Everything on the site comes from Hearth. Create and publish the homepage there (address
|
|
15
|
+
`home`), then the other pages. Until a page is published, its address shows "Page not found".
|
|
16
|
+
|
|
17
|
+
The full documentation (API, SDK, blocks) is in Hearth under **API docs**, with a Markdown version to give an
|
|
18
|
+
AI coding assistant.
|
|
19
|
+
|
|
20
|
+
## Developing locally
|
|
21
|
+
|
|
22
|
+
- **Seeing published changes.** Content is cached, and Hearth's publish webhook can't reach your computer. After
|
|
23
|
+
publishing, do a hard refresh (Ctrl+Shift+R, or Cmd+Shift+R on a Mac): in `pnpm dev` it fetches fresh content.
|
|
24
|
+
- **Previewing drafts.** In Hearth, set Settings → Developers → Preview address to
|
|
25
|
+
`http://localhost:3000/api/preview`. "Preview on the website" then opens your local site in draft mode, and
|
|
26
|
+
the page refreshes as you edit. Set it back to the live address once the site is deployed.
|
|
27
|
+
|
|
28
|
+
## How it fits together
|
|
29
|
+
|
|
30
|
+
| Where | What |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `src/lib/hearth.ts` | The Hearth client. Server-side only, because the key is secret |
|
|
33
|
+
| `src/app/[[...slug]]/page.tsx` | Every page in Hearth at its path (`home` is `/`) |
|
|
34
|
+
| `src/app/events/` | Event list and detail pages (sites with the Events feature) |
|
|
35
|
+
| `src/components/blocks/` | One component per Hearth block type. This is where the design lives |
|
|
36
|
+
| `src/app/globals.css` | Brand tokens: colors, font, content width |
|
|
37
|
+
| `src/app/api/revalidate/route.ts` | Hearth calls this on publish, so changes go live in seconds |
|
|
38
|
+
| `src/components/RegistrationForm.tsx`, `src/app/registered/` | On-site event registration (sites with Tickets): the form on an event's page and the page paid buyers return to |
|
|
39
|
+
| `src/app/fundraisers/`, `src/app/donate/` | Fundraisers and donations (sites with Giving): lists, a fundraiser's page with its donation form, a general donate page and the thank-you page |
|
|
40
|
+
| `src/app/api/preview/route.ts` | Opens drafts from Hearth's "Preview on the website" button (draft mode); `/api/preview/exit` ends it |
|
|
41
|
+
| `<HearthPreview />` in `src/app/layout.tsx` | The SDK's live preview: while an editor previews, the page refreshes as they edit in Hearth |
|
|
42
|
+
| `src/app/sitemap.ts`, `robots.ts` | Generated from Hearth. Search engines stay out until the domain is set |
|
|
43
|
+
|
|
44
|
+
Page titles, descriptions, canonical URLs, share images and structured data all come from Hearth's SEO
|
|
45
|
+
settings. Nothing to wire up per page.
|
|
46
|
+
|
|
47
|
+
## Making it the client's own
|
|
48
|
+
|
|
49
|
+
- Start with the tokens in `globals.css`, then restyle the block components.
|
|
50
|
+
- Headings can mark words with `*asterisks*` to highlight them (see `Emphasis` in `components/ui.tsx`).
|
|
51
|
+
- Every block uses semantic tones (`default`, `muted`, `inverse`, `accent`). Map them to the brand rather than
|
|
52
|
+
hard-coding colors in a block.
|
|
53
|
+
- To leave a block type out, delete its line in `components/blocks/index.tsx`, and turn it off for the site in
|
|
54
|
+
Hearth.
|
|
55
|
+
|
|
56
|
+
## Deploying to Netlify
|
|
57
|
+
|
|
58
|
+
1. Create the Netlify site from this repo. `netlify.toml` already sets the build.
|
|
59
|
+
2. Add the environment variables `HEARTH_SITE`, `HEARTH_API_KEY` and `HEARTH_WEBHOOK_SECRET`. The packages
|
|
60
|
+
come from npm, so the build needs no other credentials.
|
|
61
|
+
3. In Hearth, set the site's domain, then set the webhook URL to `https://<domain>/api/revalidate`.
|
|
62
|
+
|
|
63
|
+
Content is cached and refreshed by the webhook on publish, with an hourly refresh as a safety net.
|
|
64
|
+
|
|
65
|
+
## Updating the SDK
|
|
66
|
+
|
|
67
|
+
`pnpm up @the-smithy/hearth`. New block types show up as typed additions. Add a component for them when the
|
|
68
|
+
site needs them.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { NextConfig } from "next";
|
|
4
|
+
|
|
5
|
+
const securityHeaders = [
|
|
6
|
+
{ key: "X-Content-Type-Options", value: "nosniff" },
|
|
7
|
+
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
|
8
|
+
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
|
9
|
+
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
// The site's own folder is the project root, so Next.js never picks up files from a parent folder (another app's
|
|
13
|
+
// proxy or pages). The one exception is this template inside the Hearth repo, where the SDK it uses lives in a
|
|
14
|
+
// sibling folder: there the root is packages/.
|
|
15
|
+
const inHearthRepo = existsSync(path.join(__dirname, "../../sdk/package.json"));
|
|
16
|
+
const root = inHearthRepo ? path.join(__dirname, "../..") : __dirname;
|
|
17
|
+
|
|
18
|
+
const nextConfig: NextConfig = {
|
|
19
|
+
poweredByHeader: false,
|
|
20
|
+
turbopack: { root },
|
|
21
|
+
outputFileTracingRoot: root,
|
|
22
|
+
async headers() {
|
|
23
|
+
return [{ source: "/:path*", headers: securityHeaders }];
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default nextConfig;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hearth-site-template",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "next dev",
|
|
8
|
+
"build": "next build",
|
|
9
|
+
"start": "next start",
|
|
10
|
+
"typecheck": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@the-smithy/ember": "^0.3.1",
|
|
14
|
+
"@the-smithy/hearth": "workspace:^",
|
|
15
|
+
"next": "16.3.8",
|
|
16
|
+
"react": "19.2.8",
|
|
17
|
+
"react-dom": "19.2.8",
|
|
18
|
+
"react-markdown": "^10.1.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@tailwindcss/postcss": "^4",
|
|
22
|
+
"@types/node": "^22",
|
|
23
|
+
"@types/react": "^19",
|
|
24
|
+
"@types/react-dom": "^19",
|
|
25
|
+
"tailwindcss": "^4",
|
|
26
|
+
"typescript": "^5"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"packageManager": "pnpm@11.10.0"
|
|
32
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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 { notFound } from "next/navigation";
|
|
6
|
+
import { Blocks } from "@/components/blocks";
|
|
7
|
+
import { EventView } from "@/components/EventView";
|
|
8
|
+
import { hearthNext } from "@/lib/hearth";
|
|
9
|
+
import { hasFeature } from "@/lib/site";
|
|
10
|
+
|
|
11
|
+
type Props = { params: Promise<{ slug?: string[] }> };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* An event in an event collection, e.g. /talks/<event> for a "Talks" collection. Pages are checked first,
|
|
15
|
+
* so a page at the same address wins.
|
|
16
|
+
*/
|
|
17
|
+
async function collectionEvent(slug: string[] | undefined) {
|
|
18
|
+
if (!slug || slug.length < 2) return null;
|
|
19
|
+
const site = await hearthNext.getSite();
|
|
20
|
+
if (!hasFeature(site, "events")) return null;
|
|
21
|
+
const prefix = normalizeSlug(slug.slice(0, -1));
|
|
22
|
+
const collection = (await hearthNext.getCollections()).find((c) => c.kind === "events" && c.slug === prefix);
|
|
23
|
+
if (!collection) return null;
|
|
24
|
+
const event = await hearthNext.getEvent(normalizeSlug(slug.at(-1)));
|
|
25
|
+
return event?.collection?.id === collection.id ? event : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Every page in Hearth at its path ("/" is "home", "/about" is "about", "/blog/post" is a blog entry), plus events in collections. */
|
|
29
|
+
export default async function Page({ params }: Props) {
|
|
30
|
+
const slug = (await params).slug;
|
|
31
|
+
const page = await hearthNext.getPage(slug);
|
|
32
|
+
if (page) {
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
<JsonLd data={page.meta.jsonLd} />
|
|
36
|
+
{/* Pages usually open with a Hero (the visible title); this keeps a heading for the rest. */}
|
|
37
|
+
{page.blocks[0]?.type === "hero" ? null : <h1 className="sr-only">{page.title}</h1>}
|
|
38
|
+
<Blocks blocks={page.blocks} />
|
|
39
|
+
</>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const event = await collectionEvent(slug);
|
|
44
|
+
if (event) return <EventView event={event} />;
|
|
45
|
+
notFound();
|
|
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
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import type { FormResult } from "@the-smithy/hearth";
|
|
4
|
+
import { hearthNext } from "@/lib/hearth";
|
|
5
|
+
|
|
6
|
+
export type FormState = {
|
|
7
|
+
ok?: boolean;
|
|
8
|
+
message?: string;
|
|
9
|
+
fieldErrors?: Record<string, string>;
|
|
10
|
+
/** What the visitor typed, so the form keeps it after an error. */
|
|
11
|
+
values?: Record<string, string>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sends a Hearth form block's submission. Submissions land in the site's Forms inbox in Hearth,
|
|
16
|
+
* which also emails whoever the form notifies. Bots that fill the hidden `_gotcha` field are dropped there.
|
|
17
|
+
*/
|
|
18
|
+
export async function submitHearthForm(formKey: string, _prev: FormState, formData: FormData): Promise<FormState> {
|
|
19
|
+
const values = Object.fromEntries([...formData.entries()].filter(([k, v]) => typeof v === "string" && !k.startsWith("$") && k !== "_gotcha")) as Record<string, string>;
|
|
20
|
+
try {
|
|
21
|
+
const result = await hearthNext.submitForm(formKey, formData);
|
|
22
|
+
return result.ok ? { ok: true, message: result.message } : { message: result.message, fieldErrors: result.fieldErrors, values };
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.error("[hearth] form submission failed", err);
|
|
25
|
+
return { message: "Something went wrong sending your message. Please try again.", values };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Starts a donation (sites with Giving): Hearth checks the amount, records it and returns the payment page, where
|
|
31
|
+
* the donor is sent. Afterwards the payment provider brings them back to /donate/thank-you.
|
|
32
|
+
*/
|
|
33
|
+
export async function donate(prev: FormResult | null, formData: FormData): Promise<FormResult> {
|
|
34
|
+
return hearthNext.donateAction({ returnPath: "/donate/thank-you" })(prev, formData);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Registers for an event with on-site registration (sites with Tickets). Free tickets are confirmed straight away;
|
|
39
|
+
* paid ones redirect to the payment page, which brings the buyer back to /registered.
|
|
40
|
+
*/
|
|
41
|
+
export async function register(slug: string, prev: FormResult | null, formData: FormData): Promise<FormResult> {
|
|
42
|
+
return hearthNext.registerAction(slug, { returnPath: "/registered" })(prev, formData);
|
|
43
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { hearthNext } from "@/lib/hearth";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hearth's "Preview on the website" button opens this with a short-lived token. It checks the token with Hearth,
|
|
5
|
+
* turns on draft mode and opens the page, so editors see saved drafts until they exit or the preview ends.
|
|
6
|
+
* Hearth uses https://<domain>/api/preview unless the site has another preview address in Settings.
|
|
7
|
+
*/
|
|
8
|
+
export const GET = hearthNext.previewHandler();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { hearthNext } from "@/lib/hearth";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hearth calls this when anything on the site is published, so changes show up within seconds.
|
|
5
|
+
* Set the site's webhook URL in Hearth to https://<domain>/api/revalidate and copy the signing
|
|
6
|
+
* secret into HEARTH_WEBHOOK_SECRET. Unsigned or wrongly signed requests are rejected.
|
|
7
|
+
*/
|
|
8
|
+
export const POST = hearthNext.revalidateHandler();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { notFound } from "next/navigation";
|
|
3
|
+
import { DonateForm } from "@/components/DonateForm";
|
|
4
|
+
import { Section } from "@/components/ui";
|
|
5
|
+
import { hearthNext } from "@/lib/hearth";
|
|
6
|
+
import { hasFeature } from "@/lib/site";
|
|
7
|
+
|
|
8
|
+
export const metadata: Metadata = { title: "Donate", alternates: { canonical: "/donate" } };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A general donation page (sites with Giving). Ask staff to reserve "donate" on the site's Plan card in Hearth so
|
|
12
|
+
* no page takes this address. For a page built in Hearth instead, use the Donation form block there.
|
|
13
|
+
*/
|
|
14
|
+
export default async function DonatePage() {
|
|
15
|
+
const site = await hearthNext.getSite();
|
|
16
|
+
if (!hasFeature(site, "giving")) notFound();
|
|
17
|
+
const giving = site.settings.giving;
|
|
18
|
+
return (
|
|
19
|
+
<Section>
|
|
20
|
+
<div className="max-w-2xl">
|
|
21
|
+
<h1 className="mb-4 text-[clamp(32px,4vw,48px)] font-bold tracking-tight">Donate</h1>
|
|
22
|
+
<p className="mb-10 text-lg opacity-85">Your gift supports {giving.legalName || site.name}.</p>
|
|
23
|
+
<DonateForm presetsCents={giving.presetsCents} minCents={giving.minCents} maxCents={giving.maxCents} />
|
|
24
|
+
</div>
|
|
25
|
+
</Section>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import { Section } from "@/components/ui";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = { title: "Thank you", robots: { index: false, follow: false } };
|
|
6
|
+
|
|
7
|
+
/** Where the payment page sends donors back. The receipt is emailed once the payment completes. */
|
|
8
|
+
export default function ThankYouPage() {
|
|
9
|
+
return (
|
|
10
|
+
<Section>
|
|
11
|
+
<div className="max-w-2xl">
|
|
12
|
+
<h1 className="mb-4 text-[clamp(32px,4vw,48px)] font-bold tracking-tight">Thank you</h1>
|
|
13
|
+
<p className="text-lg opacity-85">Your gift makes a difference. If you gave your email, a receipt is on its way.</p>
|
|
14
|
+
<p className="mt-8">
|
|
15
|
+
<Link href="/" className="font-semibold underline underline-offset-4">
|
|
16
|
+
Back to the homepage
|
|
17
|
+
</Link>
|
|
18
|
+
</p>
|
|
19
|
+
</div>
|
|
20
|
+
</Section>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { metadataFromMeta } from "@the-smithy/hearth/next";
|
|
2
|
+
import { notFound, permanentRedirect } from "next/navigation";
|
|
3
|
+
import { EventView } from "@/components/EventView";
|
|
4
|
+
import { hearthNext } from "@/lib/hearth";
|
|
5
|
+
import { hasFeature } from "@/lib/site";
|
|
6
|
+
|
|
7
|
+
type Props = { params: Promise<{ slug: string }> };
|
|
8
|
+
|
|
9
|
+
async function load(slug: string) {
|
|
10
|
+
const site = await hearthNext.getSite();
|
|
11
|
+
return hasFeature(site, "events") ? hearthNext.getEvent(slug) : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function generateMetadata({ params }: Props) {
|
|
15
|
+
const event = await load((await params).slug);
|
|
16
|
+
return event ? metadataFromMeta(event.meta) : {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Events without a type live here. Events in a collection (e.g. Talks) live at their collection's address. */
|
|
20
|
+
export default async function EventPage({ params }: Props) {
|
|
21
|
+
const event = await load((await params).slug);
|
|
22
|
+
if (!event) notFound();
|
|
23
|
+
if (event.path !== `/events/${event.slug}`) permanentRedirect(event.path);
|
|
24
|
+
return <EventView event={event} />;
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { notFound } from "next/navigation";
|
|
3
|
+
import { EventList } from "@/components/blocks/events";
|
|
4
|
+
import { Section } from "@/components/ui";
|
|
5
|
+
import { hearthNext } from "@/lib/hearth";
|
|
6
|
+
import { hasFeature } from "@/lib/site";
|
|
7
|
+
|
|
8
|
+
export const metadata: Metadata = { title: "Events", alternates: { canonical: "/events" } };
|
|
9
|
+
|
|
10
|
+
/** Upcoming events (only on sites with the Events feature in Hearth). */
|
|
11
|
+
export default async function EventsPage() {
|
|
12
|
+
const site = await hearthNext.getSite();
|
|
13
|
+
if (!hasFeature(site, "events")) notFound();
|
|
14
|
+
const events = await hearthNext.getEvents({ when: "upcoming" });
|
|
15
|
+
return (
|
|
16
|
+
<Section>
|
|
17
|
+
<h1 className="mb-10 text-[clamp(32px,4vw,48px)] font-bold tracking-tight">Events</h1>
|
|
18
|
+
<EventList events={events} />
|
|
19
|
+
</Section>
|
|
20
|
+
);
|
|
21
|
+
}
|