@the-smithy/create-hearth-site 0.24.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/bin/create-hearth-site.js +1 -1
- package/package.json +4 -3
- package/template/AGENTS.md +191 -9
- package/template/CLAUDE.md +2 -124
- package/template/GEMINI.md +3 -0
- package/template/HOSTING.md +88 -0
- package/template/README.md +30 -29
- package/template/SITE.md +1 -1
- package/template/_gitignore +2 -1
- package/template/package.json +3 -2
- package/template/scripts/hearth.mjs +82 -31
- package/template/src/app/donate/thank-you/page.tsx +1 -1
- package/template/src/app/globals.css +1 -0
- package/template/src/app/layout.tsx +1 -1
- package/template/src/app/not-found.tsx +1 -1
- package/template/src/app/registered/page.tsx +1 -1
- package/template/src/hearth/base.css +66 -0
- package/template/src/hearth/blocks/collections.tsx +6 -5
- package/template/src/hearth/blocks/content.tsx +64 -28
- package/template/src/hearth/blocks/custom.tsx +9 -1
- package/template/src/hearth/blocks/events.tsx +25 -17
- package/template/src/hearth/blocks/giving.tsx +24 -20
- package/template/src/hearth/blocks/index.tsx +29 -4
- package/template/src/hearth/blocks/info.tsx +15 -13
- package/template/src/hearth/blocks/media.tsx +10 -9
- package/template/src/hearth/components/EventView.tsx +61 -29
- package/template/src/hearth/components/FundraiserView.tsx +77 -0
- package/template/src/hearth/components/NotHere.tsx +9 -0
- package/template/src/hearth/components/PageContent.tsx +23 -0
- package/template/src/hearth/components/Registration.tsx +3 -2
- package/template/src/hearth/components/Share.tsx +11 -11
- package/template/src/hearth/format.ts +5 -3
- package/template/src/hearth/forms/ContactForm.tsx +5 -3
- package/template/src/hearth/forms/DonateForm.tsx +49 -32
- package/template/src/hearth/forms/DonationForm.tsx +13 -0
- package/template/src/hearth/forms/RegistrationForm.tsx +16 -14
- package/template/src/hearth/forms/VolunteerForm.tsx +10 -8
- package/template/src/hearth/forms/fields.tsx +17 -5
- package/template/src/hearth/hearth.css +3 -47
- package/template/src/hearth/layout/MobileNav.tsx +1 -1
- package/template/src/hearth/layout/NavLink.tsx +6 -2
- package/template/src/hearth/layout/RootLayout.tsx +8 -32
- package/template/src/hearth/layout/SiteFooter.tsx +13 -6
- package/template/src/hearth/layout/SiteFrame.tsx +48 -0
- package/template/src/hearth/layout/SiteHeader.tsx +9 -4
- package/template/src/hearth/manifest.json +3 -1
- package/template/src/hearth/overrides.ts +48 -0
- package/template/src/hearth/pages/donate.tsx +24 -15
- package/template/src/hearth/pages/event.tsx +3 -2
- package/template/src/hearth/pages/events.tsx +22 -10
- package/template/src/hearth/pages/fundraiser.tsx +10 -63
- package/template/src/hearth/pages/fundraisers.tsx +21 -11
- package/template/src/hearth/pages/not-found.tsx +28 -8
- package/template/src/hearth/pages/page.tsx +9 -15
- package/template/src/hearth/pages/registered.tsx +23 -10
- package/template/src/hearth/pages/thank-you.tsx +23 -10
- package/template/src/hearth/theme/defaults.ts +125 -2
- package/template/src/hearth/ui.tsx +20 -5
- package/template/src/theme/overrides.tsx +19 -5
package/template/package.json
CHANGED
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
"hearth:update": "node scripts/hearth.mjs update"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@the-smithy/ember": "^0.
|
|
16
|
+
"@the-smithy/ember": "^0.5.0",
|
|
17
17
|
"@the-smithy/hearth": "workspace:^",
|
|
18
18
|
"maplibre-gl": "^6.16.0",
|
|
19
19
|
"next": "16.3.8",
|
|
20
20
|
"react": "19.2.8",
|
|
21
21
|
"react-dom": "19.2.8",
|
|
22
|
-
"react-markdown": "^10.1.0"
|
|
22
|
+
"react-markdown": "^10.1.0",
|
|
23
|
+
"remark-gfm": "^4.0.1"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
25
26
|
"@tailwindcss/postcss": "^4",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Setup helpers for this site, for you or your coding agent. Both read .env.local and never print the API key.
|
|
2
|
-
// pnpm hearth:check
|
|
2
|
+
// pnpm hearth:check says whether The Smithy has released anything newer than this site uses, checks .env.local and
|
|
3
|
+
// the connection to Hearth, then lists what's published
|
|
3
4
|
// pnpm hearth:docs saves Hearth's full API guide to hearth-api.md (not committed) for an AI assistant to read,
|
|
4
|
-
// and installs any coding-assistant skills Hearth offers this site
|
|
5
|
+
// and installs any coding-assistant skills Hearth offers this site
|
|
5
6
|
// pnpm hearth:update updates Hearth's code in this site (src/hearth and the other files its manifest lists) to the
|
|
6
7
|
// latest release, and the packages it needs; src/theme and your own files stay as they are
|
|
7
8
|
import { spawnSync } from "node:child_process";
|
|
@@ -16,6 +17,12 @@ const fail = (message) => {
|
|
|
16
17
|
throw new Stop(message);
|
|
17
18
|
};
|
|
18
19
|
|
|
20
|
+
const PACKAGE = "@the-smithy/create-hearth-site";
|
|
21
|
+
const SKILL_DIRS = [".agents/skills", ".claude/skills"];
|
|
22
|
+
const MANIFEST = "src/hearth/manifest.json";
|
|
23
|
+
/** The packages hearth:update raises; it leaves the site's other Smithy packages to it. */
|
|
24
|
+
const UPDATED_TOGETHER = [PACKAGE, "@the-smithy/hearth", "@the-smithy/hearth-schema"];
|
|
25
|
+
|
|
19
26
|
/** The SDK, loaded only by the commands that talk to Hearth (so hearth:update works before pnpm install). */
|
|
20
27
|
const sdk = () => import("@the-smithy/hearth");
|
|
21
28
|
|
|
@@ -45,7 +52,49 @@ function designedBlocks() {
|
|
|
45
52
|
return found.length ? new Set(found) : null;
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
/** The latest release of a package on npm, or null if npm can't be reached. */
|
|
56
|
+
async function latestRelease(name) {
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(`https://registry.npmjs.org/${name.replace("/", "%2f")}`, { headers: { Accept: "application/vnd.npm.install-v1+json" } });
|
|
59
|
+
return res.ok ? (await res.json())["dist-tags"]?.latest ?? null : null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Says which of The Smithy's packages have a newer release. Needs no API key, so it runs before anything else in hearth:check. */
|
|
66
|
+
async function checkReleases() {
|
|
67
|
+
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
|
68
|
+
const installed = (name, range) => {
|
|
69
|
+
const file = path.join("node_modules", ...name.split("/"), "package.json");
|
|
70
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")).version : range;
|
|
71
|
+
};
|
|
72
|
+
const used = Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })
|
|
73
|
+
.filter(([name, range]) => name.startsWith("@the-smithy/") && !String(range).startsWith("workspace:"))
|
|
74
|
+
.map(([name, range]) => ({ name, have: installed(name, range) }));
|
|
75
|
+
if (existsSync(MANIFEST)) used.unshift({ name: PACKAGE, have: JSON.parse(readFileSync(MANIFEST, "utf8")).version });
|
|
76
|
+
|
|
77
|
+
const behind = [];
|
|
78
|
+
let unknown = 0;
|
|
79
|
+
await Promise.all(
|
|
80
|
+
used.map(async ({ name, have }) => {
|
|
81
|
+
const latest = await latestRelease(name);
|
|
82
|
+
if (!latest) unknown++;
|
|
83
|
+
else if (lower(have, latest)) behind.push({ name, have, latest });
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
if (unknown) console.log(`! Couldn't check ${unknown} of The Smithy's packages on npm, so there may be newer releases than this lists.`);
|
|
87
|
+
if (!behind.length) return console.log(`✓ On the latest releases of The Smithy's packages (${used.map((u) => `${u.name.replace("@the-smithy/", "")} ${u.have}`).join(", ")})`);
|
|
88
|
+
console.log(`! The Smithy has released updates for this site:`);
|
|
89
|
+
for (const { name, have, latest } of behind) console.log(` ${name} ${have} → ${latest}`);
|
|
90
|
+
const others = behind.filter((b) => !UPDATED_TOGETHER.includes(b.name)).map((b) => `${b.name}@latest`);
|
|
91
|
+
if (others.length < behind.length) console.log(` Run pnpm hearth:update for Hearth's code and SDK.`);
|
|
92
|
+
if (others.length) console.log(` Run pnpm add ${others.join(" ")} for the rest, and read their changelogs.`);
|
|
93
|
+
}
|
|
94
|
+
|
|
48
95
|
async function check() {
|
|
96
|
+
await checkReleases();
|
|
97
|
+
console.log();
|
|
49
98
|
const env = await readEnv();
|
|
50
99
|
const { createHearthClient, isHearthError } = await sdk();
|
|
51
100
|
const hearth = createHearthClient(env);
|
|
@@ -103,40 +152,40 @@ async function docs() {
|
|
|
103
152
|
}
|
|
104
153
|
|
|
105
154
|
/**
|
|
106
|
-
* Installs the coding-assistant skills Hearth offers this site
|
|
107
|
-
*
|
|
108
|
-
*
|
|
155
|
+
* Installs the coding-assistant skills Hearth offers this site, replacing the ones it installed before, in
|
|
156
|
+
* .agents/skills/ (the shared place Codex, Gemini CLI, Cursor and Copilot look) and .claude/skills/ (the only one
|
|
157
|
+
* Claude Code reads). Their names are listed in each folder's .gitignore, so they're never committed and the site's
|
|
158
|
+
* own skills there are left alone.
|
|
109
159
|
*/
|
|
110
160
|
async function skills(env) {
|
|
111
161
|
const res = await fetch(`${env.baseUrl}/admin/docs/skills.json`, { headers: { Authorization: `Bearer ${env.apiKey}` } });
|
|
112
162
|
if (!res.ok) return console.log(` Hearth didn't return the coding-assistant skills (${res.status}); skipped them.`);
|
|
113
163
|
const { skills: offered = [] } = await res.json();
|
|
114
|
-
const dir = path.join(".claude", "skills");
|
|
115
|
-
const listFile = path.join(dir, ".gitignore");
|
|
116
|
-
const before = existsSync(listFile)
|
|
117
|
-
? readFileSync(listFile, "utf8").split(/\r?\n/).map((l) => l.replace(/^\/|\/$/g, "")).filter((l) => l && !l.startsWith("#") && l !== ".gitignore")
|
|
118
|
-
: [];
|
|
119
164
|
const safe = (p) => /^[a-z0-9][a-z0-9-]*$/.test(p);
|
|
120
165
|
const names = offered.map((s) => s.name).filter(safe);
|
|
121
|
-
for (const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
166
|
+
for (const dir of SKILL_DIRS) {
|
|
167
|
+
const listFile = path.join(dir, ".gitignore");
|
|
168
|
+
const before = existsSync(listFile)
|
|
169
|
+
? readFileSync(listFile, "utf8").split(/\r?\n/).map((l) => l.replace(/^\/|\/$/g, "")).filter((l) => l && !l.startsWith("#") && l !== ".gitignore")
|
|
170
|
+
: [];
|
|
171
|
+
for (const name of before) if (safe(name) && !names.includes(name)) rmSync(path.join(dir, name), { recursive: true, force: true });
|
|
172
|
+
for (const skill of offered.filter((s) => safe(s.name))) {
|
|
173
|
+
rmSync(path.join(dir, skill.name), { recursive: true, force: true });
|
|
174
|
+
for (const file of skill.files) {
|
|
175
|
+
if (!file.path.split("/").every((part) => /^[A-Za-z0-9._-]+$/.test(part) && part !== "..")) continue;
|
|
176
|
+
const target = path.join(dir, skill.name, ...file.path.split("/"));
|
|
177
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
178
|
+
writeFileSync(target, file.content);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (names.length || before.length) {
|
|
182
|
+
mkdirSync(dir, { recursive: true });
|
|
183
|
+
writeFileSync(listFile, ["# Installed by pnpm hearth:docs from Hearth; refreshed each run.", ".gitignore", ...names.map((n) => `/${n}/`)].join("\n") + "\n");
|
|
129
184
|
}
|
|
130
185
|
}
|
|
131
|
-
if (names.length
|
|
132
|
-
mkdirSync(dir, { recursive: true });
|
|
133
|
-
writeFileSync(listFile, ["# Installed by pnpm hearth:docs from Hearth; refreshed each run.", ".gitignore", ...names.map((n) => `/${n}/`)].join("\n") + "\n");
|
|
134
|
-
}
|
|
135
|
-
if (names.length) console.log(`✓ Installed ${names.length === 1 ? "a skill" : `${names.length} skills`} for your coding assistant in .claude/skills/: ${names.join(", ")}.`);
|
|
186
|
+
if (names.length) console.log(`✓ Installed ${names.length === 1 ? "a skill" : `${names.length} skills`} for your coding assistant in ${SKILL_DIRS.map((d) => `${d}/`).join(" and ")}: ${names.join(", ")}.`);
|
|
136
187
|
}
|
|
137
188
|
|
|
138
|
-
const PACKAGE = "@the-smithy/create-hearth-site";
|
|
139
|
-
const MANIFEST = "src/hearth/manifest.json";
|
|
140
189
|
|
|
141
190
|
/** The files in an npm package tarball (.tgz), by their path inside it ("package/…"). */
|
|
142
191
|
function untar(tgz) {
|
|
@@ -183,18 +232,20 @@ const lower = (a, b) => {
|
|
|
183
232
|
* Updates the code Hearth manages in this site to the latest release (or --version x.y.z): replaces the managed files
|
|
184
233
|
* listed in src/hearth/manifest.json, removes managed files the release dropped, adds seeded files that are new to
|
|
185
234
|
* 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
|
|
235
|
+
* aren't touched, nor are managed files the site's manifest lists under `own` (a site inside an app keeps its own root
|
|
236
|
+
* layout and route files, for example).
|
|
187
237
|
*/
|
|
188
238
|
async function update() {
|
|
189
239
|
const args = process.argv.slice(3);
|
|
190
240
|
const force = args.includes("--force");
|
|
191
241
|
const wanted = args.includes("--version") ? args[args.indexOf("--version") + 1] : "latest";
|
|
192
242
|
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
|
|
243
|
+
if (!existsSync(MANIFEST)) fail(`There's no ${MANIFEST}, so this site predates hearth:update. Move it onto the template's structure first (see AGENTS.md).`);
|
|
194
244
|
const old = JSON.parse(readFileSync(MANIFEST, "utf8"));
|
|
245
|
+
const own = old.own ?? [];
|
|
195
246
|
|
|
196
247
|
// Managed files edited here would be lost: styling belongs in src/theme.
|
|
197
|
-
const git = spawnSync("git", ["status", "--porcelain", "--", ...old.managed], { encoding: "utf8" });
|
|
248
|
+
const git = spawnSync("git", ["status", "--porcelain", "--", ...old.managed.filter((entry) => !own.includes(entry))], { encoding: "utf8" });
|
|
198
249
|
if (git.status === 0 && git.stdout.trim() && !force) {
|
|
199
250
|
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
251
|
}
|
|
@@ -225,12 +276,12 @@ async function update() {
|
|
|
225
276
|
const changed = [];
|
|
226
277
|
// Managed files the release no longer has.
|
|
227
278
|
for (const file of old.managed.flatMap(localFiles)) {
|
|
228
|
-
if (files.has(file) || file === MANIFEST) continue;
|
|
279
|
+
if (files.has(file) || file === MANIFEST || listed(own, file)) continue;
|
|
229
280
|
rmSync(file);
|
|
230
281
|
changed.push(`- ${file}`);
|
|
231
282
|
}
|
|
232
283
|
for (const [file, body] of files) {
|
|
233
|
-
if (file === MANIFEST) continue;
|
|
284
|
+
if (file === MANIFEST || listed(own, file)) continue;
|
|
234
285
|
const managed = listed(next.managed, file);
|
|
235
286
|
// Seeded files are the site's own: add only ones that are new to this release's list, and never over a file.
|
|
236
287
|
const newSeed = !managed && listed(next.seeded, file) && !listed(old.seeded ?? [], file) && !existsSync(file);
|
|
@@ -241,7 +292,7 @@ async function update() {
|
|
|
241
292
|
writeFileSync(file, body);
|
|
242
293
|
changed.push(`${existed ? "~" : "+"} ${file}`);
|
|
243
294
|
}
|
|
244
|
-
writeFileSync(MANIFEST, JSON.stringify({ ...next, version }, null, 2) + "\n");
|
|
295
|
+
writeFileSync(MANIFEST, JSON.stringify({ ...next, version, ...(own.length ? { own } : {}) }, null, 2) + "\n");
|
|
245
296
|
|
|
246
297
|
// The packages the code needs, at least at the release's versions, and its hearth:* scripts. The site's own stay.
|
|
247
298
|
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Managed by Hearth (pnpm hearth:update replaces it). Style it in src/theme; the page is src/hearth/pages/thank-you.tsx.
|
|
2
|
-
export { default,
|
|
2
|
+
export { default, generateMetadata } from "@/hearth/pages/thank-you";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Managed by Hearth (pnpm hearth:update replaces it): the layout is src/hearth/layout/RootLayout.tsx, styled in src/theme.
|
|
2
2
|
import "./globals.css";
|
|
3
3
|
|
|
4
|
-
export { default, generateMetadata } from "@/hearth/layout/RootLayout";
|
|
4
|
+
export { default, generateMetadata, viewport } from "@/hearth/layout/RootLayout";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Managed by Hearth (pnpm hearth:update replaces it). Style it in src/theme; the page is src/hearth/pages/not-found.tsx.
|
|
2
|
-
export { default } from "@/hearth/pages/not-found";
|
|
2
|
+
export { default, generateMetadata } from "@/hearth/pages/not-found";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Managed by Hearth (pnpm hearth:update replaces it). Style it in src/theme; the page is src/hearth/pages/registered.tsx.
|
|
2
|
-
export { default,
|
|
2
|
+
export { default, generateMetadata } from "@/hearth/pages/registered";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Hearth's rich text and focus rings, shared by every site: src/app/globals.css loads it. Don't edit it in a site: the
|
|
3
|
+
* site's own CSS goes in src/theme/theme.css, which is loaded after this file and can override anything here.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* Markdown from Hearth (rich text, FAQ answers, media + text). Unlayered, so its line height beats text-* sizes. */
|
|
7
|
+
.prose {
|
|
8
|
+
line-height: 1.7;
|
|
9
|
+
}
|
|
10
|
+
.prose > * + * {
|
|
11
|
+
margin-top: 1em;
|
|
12
|
+
}
|
|
13
|
+
.prose h2 {
|
|
14
|
+
font-size: 1.75rem;
|
|
15
|
+
font-weight: 700;
|
|
16
|
+
line-height: 1.25;
|
|
17
|
+
margin-top: 1.6em;
|
|
18
|
+
}
|
|
19
|
+
.prose h3 {
|
|
20
|
+
font-size: 1.35rem;
|
|
21
|
+
font-weight: 700;
|
|
22
|
+
margin-top: 1.4em;
|
|
23
|
+
}
|
|
24
|
+
.prose a {
|
|
25
|
+
text-decoration: underline;
|
|
26
|
+
text-underline-offset: 3px;
|
|
27
|
+
}
|
|
28
|
+
.prose ul {
|
|
29
|
+
list-style: disc;
|
|
30
|
+
padding-left: 1.4em;
|
|
31
|
+
}
|
|
32
|
+
.prose ol {
|
|
33
|
+
list-style: decimal;
|
|
34
|
+
padding-left: 1.4em;
|
|
35
|
+
}
|
|
36
|
+
.prose blockquote {
|
|
37
|
+
border-left: 3px solid var(--color-line);
|
|
38
|
+
padding-left: 1em;
|
|
39
|
+
font-style: italic;
|
|
40
|
+
}
|
|
41
|
+
.prose img {
|
|
42
|
+
border-radius: 0.75rem;
|
|
43
|
+
}
|
|
44
|
+
/* A wide table scrolls inside the text rather than widening the page. */
|
|
45
|
+
.prose table {
|
|
46
|
+
display: block;
|
|
47
|
+
max-width: 100%;
|
|
48
|
+
overflow-x: auto;
|
|
49
|
+
border-collapse: collapse;
|
|
50
|
+
}
|
|
51
|
+
.prose th,
|
|
52
|
+
.prose td {
|
|
53
|
+
border-bottom: 1px solid var(--color-line);
|
|
54
|
+
padding: 0.5em 1em 0.5em 0;
|
|
55
|
+
text-align: left;
|
|
56
|
+
vertical-align: top;
|
|
57
|
+
}
|
|
58
|
+
.prose th {
|
|
59
|
+
font-weight: 700;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Unlayered, so it beats utilities like focus:outline-none: keyboard focus always shows. */
|
|
63
|
+
:focus-visible {
|
|
64
|
+
outline: 2px solid var(--color-accent);
|
|
65
|
+
outline-offset: 2px;
|
|
66
|
+
}
|
|
@@ -6,17 +6,18 @@ import { fill, Img, Section, SectionHeading } from "../ui";
|
|
|
6
6
|
|
|
7
7
|
const t = theme.collectionList;
|
|
8
8
|
|
|
9
|
-
function Card({ entry }: { entry: CollectionEntry }) {
|
|
9
|
+
function Card({ entry, heading }: { entry: CollectionEntry; heading?: string }) {
|
|
10
|
+
const Title = heading ? "h3" : "h2";
|
|
10
11
|
return (
|
|
11
12
|
<li className={t.card}>
|
|
12
13
|
{entry.image ? <Img image={entry.image} crop={16 / 10} className={t.image} /> : null}
|
|
13
14
|
<div className={t.body}>
|
|
14
15
|
{entry.date ? <p className={t.date}>{formatEntryDate(entry.date)}</p> : null}
|
|
15
|
-
<
|
|
16
|
+
<Title className={t.title}>
|
|
16
17
|
<Link href={entry.path} className={t.link}>
|
|
17
18
|
{entry.title}
|
|
18
19
|
</Link>
|
|
19
|
-
</
|
|
20
|
+
</Title>
|
|
20
21
|
{entry.summary ? <p className={t.summary}>{entry.summary}</p> : null}
|
|
21
22
|
{entry.tags.length ? (
|
|
22
23
|
<ul className={t.tags}>
|
|
@@ -36,7 +37,7 @@ function Card({ entry }: { entry: CollectionEntry }) {
|
|
|
36
37
|
export function CollectionList({ block }: { block: BlockOf<"collectionList"> }) {
|
|
37
38
|
const { entries, collection, layout } = block.data;
|
|
38
39
|
return (
|
|
39
|
-
<Section block="collectionList">
|
|
40
|
+
<Section block="collectionList" id={block.anchor}>
|
|
40
41
|
<SectionHeading eyebrow={block.data.eyebrow} heading={block.data.heading} intro={block.data.intro} />
|
|
41
42
|
{!entries.length ? (
|
|
42
43
|
<p className={t.empty}>{theme.text.noEntries}</p>
|
|
@@ -60,7 +61,7 @@ export function CollectionList({ block }: { block: BlockOf<"collectionList"> })
|
|
|
60
61
|
) : (
|
|
61
62
|
<ul className={t.cards}>
|
|
62
63
|
{entries.map((e) => (
|
|
63
|
-
<Card key={e.path} entry={e} />
|
|
64
|
+
<Card key={e.path} entry={e} heading={block.data.heading} />
|
|
64
65
|
))}
|
|
65
66
|
</ul>
|
|
66
67
|
)}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { BlockOf } from "@the-smithy/hearth";
|
|
2
2
|
import { theme } from "@/theme";
|
|
3
|
-
import {
|
|
3
|
+
import { cx } from "../theme";
|
|
4
|
+
import { Button, Emphasis, fill, ground, Img, Markdown, plain, Section, SectionHeading, SmartLink, toTone } from "../ui";
|
|
4
5
|
|
|
5
6
|
export function Hero({ block }: { block: BlockOf<"hero"> }) {
|
|
6
7
|
const d = block.data;
|
|
7
8
|
const t = theme.hero;
|
|
9
|
+
const paragraphs = d.body?.split(/\n\s*\n/).filter((p) => p.trim()) ?? [];
|
|
10
|
+
// Without a tone it's the design's own ground, dark, in `hero.root`. A tone sets the ground, which turns on-dark: on or off.
|
|
8
11
|
return (
|
|
9
|
-
<section data-block="hero" {...ground("inverse")} className={t.root}>
|
|
12
|
+
<section id={block.anchor} data-block="hero" data-size={d.size} {...ground(d.tone ?? "inverse")} className={cx(d.tone ? `${theme.tones[d.tone]} ${t.toned}` : t.root, block.anchor && theme.section.anchored)}>
|
|
10
13
|
{d.image ? <Img image={d.image} crop priority className={t.image} /> : null}
|
|
11
14
|
<div className={t.inner} data-size={d.size}>
|
|
12
15
|
{d.logo ? <Img image={d.logo} priority className={t.logo} /> : null}
|
|
@@ -14,7 +17,15 @@ export function Hero({ block }: { block: BlockOf<"hero"> }) {
|
|
|
14
17
|
<h1 className={t.title}>
|
|
15
18
|
<Emphasis text={d.heading} className={t.emphasis} />
|
|
16
19
|
</h1>
|
|
17
|
-
{
|
|
20
|
+
{paragraphs.length > 1 ? (
|
|
21
|
+
paragraphs.map((p, i) => (
|
|
22
|
+
<p key={i} className={t.body}>
|
|
23
|
+
{p}
|
|
24
|
+
</p>
|
|
25
|
+
))
|
|
26
|
+
) : d.body ? (
|
|
27
|
+
<p className={t.body}>{d.body}</p>
|
|
28
|
+
) : null}
|
|
18
29
|
{d.primaryCta || d.secondaryCta ? (
|
|
19
30
|
<div className={t.actions}>
|
|
20
31
|
<Button link={d.primaryCta} />
|
|
@@ -28,7 +39,7 @@ export function Hero({ block }: { block: BlockOf<"hero"> }) {
|
|
|
28
39
|
|
|
29
40
|
export function RichText({ block }: { block: BlockOf<"richText"> }) {
|
|
30
41
|
return (
|
|
31
|
-
<Section block="richText">
|
|
42
|
+
<Section block="richText" id={block.anchor}>
|
|
32
43
|
<Markdown className={theme.richText.body} data-width={block.data.width}>
|
|
33
44
|
{block.data.markdown}
|
|
34
45
|
</Markdown>
|
|
@@ -40,7 +51,7 @@ export function MediaText({ block }: { block: BlockOf<"mediaText"> }) {
|
|
|
40
51
|
const d = block.data;
|
|
41
52
|
const t = theme.mediaText;
|
|
42
53
|
return (
|
|
43
|
-
<Section block="mediaText">
|
|
54
|
+
<Section block="mediaText" id={block.anchor}>
|
|
44
55
|
<div className={t.grid} data-image={d.imagePosition}>
|
|
45
56
|
<div className={t.text}>
|
|
46
57
|
<SectionHeading eyebrow={d.eyebrow} heading={d.heading} />
|
|
@@ -60,7 +71,7 @@ export function MediaText({ block }: { block: BlockOf<"mediaText"> }) {
|
|
|
60
71
|
export function Stats({ block }: { block: BlockOf<"stats"> }) {
|
|
61
72
|
const t = theme.stats;
|
|
62
73
|
return (
|
|
63
|
-
<Section block="stats" tone={toTone(t.tone)}>
|
|
74
|
+
<Section block="stats" id={block.anchor} tone={toTone(t.tone)}>
|
|
64
75
|
<SectionHeading heading={block.data.heading} />
|
|
65
76
|
<dl className={t.list}>
|
|
66
77
|
{block.data.items.map((s, i) => (
|
|
@@ -74,17 +85,27 @@ export function Stats({ block }: { block: BlockOf<"stats"> }) {
|
|
|
74
85
|
);
|
|
75
86
|
}
|
|
76
87
|
|
|
88
|
+
/** A card's title as a key ("Our *history*" is "our-history"), for CSS that targets one card: [data-card="our-history"]. */
|
|
89
|
+
const cardKey = (title: string) =>
|
|
90
|
+
plain(title)
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
93
|
+
.replace(/^-|-$/g, "") || undefined;
|
|
94
|
+
|
|
77
95
|
export function CardGrid({ block }: { block: BlockOf<"cardGrid"> }) {
|
|
78
96
|
const d = block.data;
|
|
79
97
|
const t = theme.cardGrid;
|
|
98
|
+
const Title = d.heading ? "h3" : "h2";
|
|
80
99
|
return (
|
|
81
|
-
<Section block="cardGrid" tone={d.tone}>
|
|
100
|
+
<Section block="cardGrid" id={block.anchor} tone={d.tone}>
|
|
82
101
|
<SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
|
|
83
102
|
<ul className={t.list}>
|
|
84
103
|
{d.items.map((c, i) => (
|
|
85
|
-
<li key={i} className={t.card}>
|
|
104
|
+
<li key={i} className={t.card} data-card={cardKey(c.title)} data-icon={c.icon}>
|
|
86
105
|
{c.image ? <Img image={c.image} crop={16 / 10} className={t.image} /> : null}
|
|
87
|
-
<
|
|
106
|
+
<Title className={t.title}>
|
|
107
|
+
<Emphasis text={c.title} />
|
|
108
|
+
</Title>
|
|
88
109
|
{c.body ? <p className={t.body}>{c.body}</p> : null}
|
|
89
110
|
{c.link ? (
|
|
90
111
|
<SmartLink href={c.link.href} className={t.link}>
|
|
@@ -102,8 +123,9 @@ export function Projects({ block }: { block: BlockOf<"projects"> }) {
|
|
|
102
123
|
const d = block.data;
|
|
103
124
|
const t = theme.projects;
|
|
104
125
|
const grid = d.layout === "grid";
|
|
126
|
+
const Title = d.heading ? "h3" : "h2";
|
|
105
127
|
return (
|
|
106
|
-
<Section block="projects" tone={d.tone}>
|
|
128
|
+
<Section block="projects" id={block.anchor} tone={d.tone}>
|
|
107
129
|
<SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
|
|
108
130
|
<ul className={grid ? t.gridList : t.showcaseList} data-layout={d.layout}>
|
|
109
131
|
{d.items.map((p, i) => (
|
|
@@ -111,7 +133,7 @@ export function Projects({ block }: { block: BlockOf<"projects"> }) {
|
|
|
111
133
|
{p.image ? <Img image={p.image} crop={16 / 10} className={t.image} /> : null}
|
|
112
134
|
<div className={t.text}>
|
|
113
135
|
<div className={t.top}>
|
|
114
|
-
<
|
|
136
|
+
<Title className={t.name}>{p.name}</Title>
|
|
115
137
|
{p.status ? <span className={t.status}>{p.status}</span> : null}
|
|
116
138
|
</div>
|
|
117
139
|
{p.role ? <p className={t.role}>{p.role}</p> : null}
|
|
@@ -141,14 +163,15 @@ export function Projects({ block }: { block: BlockOf<"projects"> }) {
|
|
|
141
163
|
export function Pricing({ block }: { block: BlockOf<"pricing"> }) {
|
|
142
164
|
const d = block.data;
|
|
143
165
|
const t = theme.pricing;
|
|
166
|
+
const Title = d.heading ? "h3" : "h2";
|
|
144
167
|
return (
|
|
145
|
-
<Section block="pricing" tone={d.tone}>
|
|
168
|
+
<Section block="pricing" id={block.anchor} tone={d.tone}>
|
|
146
169
|
<SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
|
|
147
170
|
<ul className={t.list} data-count={d.plans.length >= 4 ? "many" : "few"}>
|
|
148
171
|
{d.plans.map((p, i) => (
|
|
149
172
|
<li key={i} className={t.plan} data-highlight={p.highlight ? "" : undefined}>
|
|
150
173
|
{p.highlight ? <p className={t.highlight}>{p.highlight}</p> : null}
|
|
151
|
-
<
|
|
174
|
+
<Title className={t.name}>{p.name}</Title>
|
|
152
175
|
<p className={t.price}>
|
|
153
176
|
<span className={t.amount}>{p.price}</span>
|
|
154
177
|
{p.period ? <span className={t.period}>{p.period}</span> : null}
|
|
@@ -196,12 +219,12 @@ export function CallToAction({ block }: { block: BlockOf<"cta"> }) {
|
|
|
196
219
|
);
|
|
197
220
|
if (!d.image)
|
|
198
221
|
return (
|
|
199
|
-
<Section block="cta" tone={d.tone}>
|
|
222
|
+
<Section block="cta" id={block.anchor} tone={d.tone}>
|
|
200
223
|
{content}
|
|
201
224
|
</Section>
|
|
202
225
|
);
|
|
203
226
|
return (
|
|
204
|
-
<section data-block="cta" {...ground("inverse")} className={t.imageRoot}>
|
|
227
|
+
<section id={block.anchor} data-block="cta" {...ground("inverse")} className={cx(t.imageRoot, block.anchor && theme.section.anchored)}>
|
|
205
228
|
<Img image={d.image} crop className={t.image} />
|
|
206
229
|
<div className={t.overlay}>
|
|
207
230
|
<div className={t.overlayInner}>{content}</div>
|
|
@@ -213,19 +236,32 @@ export function CallToAction({ block }: { block: BlockOf<"cta"> }) {
|
|
|
213
236
|
export function Faq({ block }: { block: BlockOf<"faq"> }) {
|
|
214
237
|
const t = theme.faq;
|
|
215
238
|
return (
|
|
216
|
-
<Section block="faq">
|
|
239
|
+
<Section block="faq" id={block.anchor}>
|
|
217
240
|
<SectionHeading heading={block.data.heading} />
|
|
218
|
-
|
|
219
|
-
{
|
|
220
|
-
|
|
221
|
-
<
|
|
222
|
-
{q.question}
|
|
223
|
-
<
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
241
|
+
{block.data.display === "list" ? (
|
|
242
|
+
<dl className={t.terms}>
|
|
243
|
+
{block.data.items.map((q, i) => (
|
|
244
|
+
<div key={i} className={t.term}>
|
|
245
|
+
<dt className={t.termQuestion}>{q.question}</dt>
|
|
246
|
+
<dd>
|
|
247
|
+
<Markdown className={t.termAnswer}>{q.answer}</Markdown>
|
|
248
|
+
</dd>
|
|
249
|
+
</div>
|
|
250
|
+
))}
|
|
251
|
+
</dl>
|
|
252
|
+
) : (
|
|
253
|
+
<div className={t.list}>
|
|
254
|
+
{block.data.items.map((q, i) => (
|
|
255
|
+
<details key={i} className={t.item}>
|
|
256
|
+
<summary className={t.question}>
|
|
257
|
+
{q.question}
|
|
258
|
+
<span aria-hidden className={t.icon} />
|
|
259
|
+
</summary>
|
|
260
|
+
<Markdown className={t.answer}>{q.answer}</Markdown>
|
|
261
|
+
</details>
|
|
262
|
+
))}
|
|
263
|
+
</div>
|
|
264
|
+
)}
|
|
229
265
|
</Section>
|
|
230
266
|
);
|
|
231
267
|
}
|
|
@@ -234,7 +270,7 @@ export function Quote({ block }: { block: BlockOf<"quote"> }) {
|
|
|
234
270
|
const d = block.data;
|
|
235
271
|
const t = theme.quote;
|
|
236
272
|
return (
|
|
237
|
-
<Section block="quote" tone={d.tone}>
|
|
273
|
+
<Section block="quote" id={block.anchor} tone={d.tone}>
|
|
238
274
|
<figure className={t.figure}>
|
|
239
275
|
<blockquote className={t.text}>{d.quote}</blockquote>
|
|
240
276
|
{d.name ? (
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BlockOf } from "@the-smithy/hearth";
|
|
2
|
+
import { theme } from "@/theme";
|
|
2
3
|
import { customBlocks } from "@/theme/custom-blocks";
|
|
3
4
|
|
|
4
5
|
/** A block type of this site's own (Settings → Custom blocks in Hearth), drawn by its component in src/theme/custom-blocks.tsx. */
|
|
@@ -8,5 +9,12 @@ export function CustomBlock({ block }: { block: BlockOf<"custom"> }) {
|
|
|
8
9
|
if (process.env.NODE_ENV !== "production") console.warn(`[hearth] No component for custom block "${block.data.block}"; add one in src/theme/custom-blocks.tsx.`);
|
|
9
10
|
return null;
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
+
// Custom blocks draw their own markup, so a link's target goes around them.
|
|
13
|
+
return block.anchor ? (
|
|
14
|
+
<div id={block.anchor} className={theme.section.anchored}>
|
|
15
|
+
<Component values={block.data.values} />
|
|
16
|
+
</div>
|
|
17
|
+
) : (
|
|
18
|
+
<Component values={block.data.values} />
|
|
19
|
+
);
|
|
12
20
|
}
|