@the-smithy/create-hearth-site 0.15.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-smithy/create-hearth-site",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Start a new client website powered by Hearth: pnpm create @the-smithy/hearth-site",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -29,7 +29,7 @@
29
29
  ],
30
30
  "devDependencies": {
31
31
  "vitest": "^5.0.3",
32
- "@the-smithy/hearth-schema": "^0.15.0"
32
+ "@the-smithy/hearth-schema": "^0.16.0"
33
33
  },
34
34
  "scripts": {
35
35
  "test": "node --test \"test/**/*.test.js\" && vitest run test/blocks.test.ts"
@@ -67,8 +67,14 @@ Keep the API key out of chat: the agent never needs to see it, only `.env.local`
67
67
  - Headings can mark words with `*asterisks*` to highlight them (see `Emphasis` in `src/components/ui.tsx`).
68
68
  - To leave a block type out, delete its line in `src/components/blocks/index.tsx` and ask for it to be turned
69
69
  off for the site in Hearth.
70
- - **SEO is already handled.** Page titles, descriptions, canonical URLs, share images and structured data all
71
- come from Hearth's SEO settings. There's nothing to wire up per page.
70
+ - **SEO comes from Hearth.** Titles, descriptions, canonical addresses, share images, structured data, the
71
+ sitemap, robots.txt, redirects for old addresses, Search Console verification and the page's language all come
72
+ from Hearth (`siteMetadata`, `generatePageMetadata`, `sitemapWith`, `robots`, `redirectOrNotFound`). Preview deploys
73
+ and sites hidden in Hearth are kept out of search engines. What's still yours:
74
+ - Pages your own code serves (like `/events`) need their own `generateMetadata` and a place in `src/app/sitemap.ts`.
75
+ - Use `<Img priority>` for the image at the top of a page, and keep one `<h1>` per page (the Hero block renders it).
76
+ - Add both the bare domain and `www` to the site in Netlify, with the one Hearth uses as primary: Netlify then
77
+ redirects the other to it.
72
78
 
73
79
  ### Where things live
74
80
 
@@ -2,7 +2,6 @@ import { normalizeSlug } from "@the-smithy/hearth";
2
2
  import { metadataFromMeta } from "@the-smithy/hearth/next";
3
3
  import { JsonLd } from "@the-smithy/hearth/react";
4
4
  import type { Metadata } from "next";
5
- import { notFound } from "next/navigation";
6
5
  import { Blocks } from "@/components/blocks";
7
6
  import { EventView } from "@/components/EventView";
8
7
  import { hearthNext } from "@/lib/hearth";
@@ -42,7 +41,8 @@ export default async function Page({ params }: Props) {
42
41
 
43
42
  const event = await collectionEvent(slug);
44
43
  if (event) return <EventView event={event} />;
45
- notFound();
44
+ // An old address (a renamed page, or the site before it moved to Hearth) redirects; anything else is a 404.
45
+ return hearthNext.redirectOrNotFound(slug);
46
46
  }
47
47
 
48
48
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
@@ -0,0 +1,31 @@
1
+ import { ImageResponse } from "next/og";
2
+ import { hearthNext } from "@/lib/hearth";
3
+
4
+ export const size = { width: 180, height: 180 };
5
+ export const contentType = "image/png";
6
+
7
+ /** The home-screen icon on iPhones and iPads: like icon.tsx, larger. Replace with apple-icon.png when there's a real one. */
8
+ export default async function AppleIcon() {
9
+ const site = await hearthNext.getSite();
10
+ const seo = site.settings.seo;
11
+ return new ImageResponse(
12
+ (
13
+ <div
14
+ style={{
15
+ width: "100%",
16
+ height: "100%",
17
+ display: "flex",
18
+ alignItems: "center",
19
+ justifyContent: "center",
20
+ background: seo.ogBackground ?? "#141413",
21
+ color: seo.ogAccent ?? seo.ogText ?? "#FFFFFF",
22
+ fontSize: 110,
23
+ fontWeight: 700,
24
+ }}
25
+ >
26
+ {site.name.trim().charAt(0).toUpperCase() || "•"}
27
+ </div>
28
+ ),
29
+ size,
30
+ );
31
+ }
@@ -5,7 +5,15 @@ import { Section } from "@/components/ui";
5
5
  import { hearthNext } from "@/lib/hearth";
6
6
  import { hasFeature } from "@/lib/site";
7
7
 
8
- export const metadata: Metadata = { title: "Donate", alternates: { canonical: "/donate" } };
8
+ export async function generateMetadata(): Promise<Metadata> {
9
+ const site = await hearthNext.getSite();
10
+ return {
11
+ title: "Donate",
12
+ description: `Support ${site.name} with a donation.`,
13
+ alternates: { canonical: "/donate" },
14
+ openGraph: { title: "Donate", url: "/donate" },
15
+ };
16
+ }
9
17
 
10
18
  /**
11
19
  * A general donation page (sites with Giving). Ask staff to reserve "donate" on the site's Plan card in Hearth so
@@ -1,5 +1,5 @@
1
1
  import { metadataFromMeta } from "@the-smithy/hearth/next";
2
- import { notFound, permanentRedirect } from "next/navigation";
2
+ import { permanentRedirect } from "next/navigation";
3
3
  import { EventView } from "@/components/EventView";
4
4
  import { hearthNext } from "@/lib/hearth";
5
5
  import { hasFeature } from "@/lib/site";
@@ -18,8 +18,9 @@ export async function generateMetadata({ params }: Props) {
18
18
 
19
19
  /** Events without a type live here. Events in a collection (e.g. Talks) live at their collection's address. */
20
20
  export default async function EventPage({ params }: Props) {
21
- const event = await load((await params).slug);
22
- if (!event) notFound();
21
+ const slug = (await params).slug;
22
+ const event = await load(slug);
23
+ if (!event) return hearthNext.redirectOrNotFound(`/events/${slug}`);
23
24
  if (event.path !== `/events/${event.slug}`) permanentRedirect(event.path);
24
25
  return <EventView event={event} />;
25
26
  }
@@ -5,7 +5,15 @@ import { Section } from "@/components/ui";
5
5
  import { hearthNext } from "@/lib/hearth";
6
6
  import { hasFeature } from "@/lib/site";
7
7
 
8
- export const metadata: Metadata = { title: "Events", alternates: { canonical: "/events" } };
8
+ export async function generateMetadata(): Promise<Metadata> {
9
+ const site = await hearthNext.getSite();
10
+ return {
11
+ title: "Events",
12
+ description: `Upcoming events from ${site.name}.`,
13
+ alternates: { canonical: "/events" },
14
+ openGraph: { title: "Events", url: "/events" },
15
+ };
16
+ }
9
17
 
10
18
  /** Upcoming events (only on sites with the Events feature in Hearth). */
11
19
  export default async function EventsPage() {
@@ -1,6 +1,5 @@
1
1
  import { metadataFromMeta } from "@the-smithy/hearth/next";
2
2
  import { JsonLd } from "@the-smithy/hearth/react";
3
- import { notFound } from "next/navigation";
4
3
  import { Blocks } from "@/components/blocks";
5
4
  import { Progress } from "@/components/blocks/giving";
6
5
  import { DonateForm } from "@/components/DonateForm";
@@ -22,8 +21,9 @@ export async function generateMetadata({ params }: Props) {
22
21
 
23
22
  /** A fundraiser's page: progress and a donation form, then its story blocks. */
24
23
  export default async function FundraiserPage({ params }: Props) {
25
- const loaded = await load((await params).slug);
26
- if (!loaded?.fundraiser) notFound();
24
+ const slug = (await params).slug;
25
+ const loaded = await load(slug);
26
+ if (!loaded?.fundraiser) return hearthNext.redirectOrNotFound(`/fundraisers/${slug}`);
27
27
  const { site, fundraiser: f } = loaded;
28
28
  const giving = site.settings.giving;
29
29
 
@@ -5,7 +5,15 @@ import { Section } from "@/components/ui";
5
5
  import { hearthNext } from "@/lib/hearth";
6
6
  import { hasFeature } from "@/lib/site";
7
7
 
8
- export const metadata: Metadata = { title: "Fundraisers", alternates: { canonical: "/fundraisers" } };
8
+ export async function generateMetadata(): Promise<Metadata> {
9
+ const site = await hearthNext.getSite();
10
+ return {
11
+ title: "Fundraisers",
12
+ description: `Fundraisers and campaigns from ${site.name}: see how each is doing and give.`,
13
+ alternates: { canonical: "/fundraisers" },
14
+ openGraph: { title: "Fundraisers", url: "/fundraisers" },
15
+ };
16
+ }
9
17
 
10
18
  /** Active fundraisers, then completed ones (only on sites with the Giving feature in Hearth). */
11
19
  export default async function FundraisersPage() {
@@ -0,0 +1,35 @@
1
+ import { ImageResponse } from "next/og";
2
+ import { hearthNext } from "@/lib/hearth";
3
+
4
+ export const size = { width: 64, height: 64 };
5
+ export const contentType = "image/png";
6
+
7
+ /**
8
+ * The browser tab icon: the site's first letter in its share-image colors from Hearth. Replace this file with an
9
+ * icon.png (or favicon.ico) once the client has a proper one.
10
+ */
11
+ export default async function Icon() {
12
+ const site = await hearthNext.getSite();
13
+ const seo = site.settings.seo;
14
+ return new ImageResponse(
15
+ (
16
+ <div
17
+ style={{
18
+ width: "100%",
19
+ height: "100%",
20
+ display: "flex",
21
+ alignItems: "center",
22
+ justifyContent: "center",
23
+ borderRadius: 14,
24
+ background: seo.ogBackground ?? "#141413",
25
+ color: seo.ogAccent ?? seo.ogText ?? "#FFFFFF",
26
+ fontSize: 40,
27
+ fontWeight: 700,
28
+ }}
29
+ >
30
+ {site.name.trim().charAt(0).toUpperCase() || "•"}
31
+ </div>
32
+ ),
33
+ size,
34
+ );
35
+ }
@@ -1,27 +1,22 @@
1
- import type { Metadata } from "next";
2
1
  import type { ReactNode } from "react";
2
+ import { siteLang } from "@the-smithy/hearth/next";
3
3
  import { HearthPreview } from "@the-smithy/hearth/preview";
4
4
  import { SiteFooter } from "@/components/SiteFooter";
5
5
  import { SiteHeader } from "@/components/SiteHeader";
6
6
  import { hearthNext } from "@/lib/hearth";
7
- import { siteUrl } from "@/lib/site";
8
7
  import "./globals.css";
9
8
 
10
- export async function generateMetadata(): Promise<Metadata> {
11
- const site = await hearthNext.getSite();
12
- const base = siteUrl(site);
13
- return {
14
- // Pages set their own full title, description, canonical and share image from Hearth.
15
- title: { default: site.name, template: site.settings.seo.titleTemplate ?? `%s | ${site.name}` },
16
- description: site.settings.seo.description,
17
- ...(base ? { metadataBase: new URL(base) } : {}),
18
- };
19
- }
9
+ /**
10
+ * What every page starts from: full links, the title pattern, the default description and share image, the site's
11
+ * language, Google and Bing verification, and no indexing on preview deploys or while the site is hidden in Hearth.
12
+ * Pages from Hearth then set their own title, description, canonical address and share image.
13
+ */
14
+ export const generateMetadata = () => hearthNext.siteMetadata();
20
15
 
21
16
  export default async function RootLayout({ children }: { children: ReactNode }) {
22
17
  const site = await hearthNext.getSite();
23
18
  return (
24
- <html lang="en">
19
+ <html lang={siteLang(site)}>
25
20
  <body className="flex min-h-dvh flex-col">
26
21
  <a href="#main" className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-surface focus:px-4 focus:py-2">
27
22
  Skip to content
@@ -1,10 +1,7 @@
1
- import type { MetadataRoute } from "next";
2
1
  import { hearthNext } from "@/lib/hearth";
3
- import { siteUrl } from "@/lib/site";
4
2
 
5
- export default async function robots(): Promise<MetadataRoute.Robots> {
6
- const base = siteUrl(await hearthNext.getSite());
7
- // Until the site's domain is set in Hearth (e.g. on a preview URL), keep search engines out.
8
- if (!base) return { rules: { userAgent: "*", disallow: "/" } };
9
- return { rules: { userAgent: "*", allow: "/", disallow: "/api/" }, sitemap: `${base}/sitemap.xml` };
10
- }
3
+ /**
4
+ * Keeps crawlers out until the site has a domain in Hearth, while it's hidden from search engines there, and on
5
+ * preview deploys; otherwise allows everything and points at the sitemap.
6
+ */
7
+ export default hearthNext.robots;
@@ -1,4 +1,9 @@
1
1
  import { hearthNext } from "@/lib/hearth";
2
+ import { hasFeature } from "@/lib/site";
2
3
 
3
- /** Every indexable page and event, as listed by Hearth. */
4
- export default hearthNext.sitemap;
4
+ /** Every indexable page, event and fundraiser in Hearth, plus the pages this site's own code serves. */
5
+ export default async function sitemap() {
6
+ const site = await hearthNext.getSite();
7
+ const own = [...(hasFeature(site, "events") ? ["/events"] : []), ...(hasFeature(site, "giving") ? ["/fundraisers", "/donate"] : [])];
8
+ return hearthNext.sitemapWith(own)();
9
+ }
@@ -5,9 +5,9 @@ export function Hero({ block }: { block: BlockOf<"hero"> }) {
5
5
  const d = block.data;
6
6
  return (
7
7
  <section className="relative overflow-hidden bg-inverse text-inverse-ink">
8
- {d.image ? <Img image={d.image} className="absolute inset-0 size-full object-cover opacity-35" /> : null}
8
+ {d.image ? <Img image={d.image} priority className="absolute inset-0 size-full object-cover opacity-35" /> : null}
9
9
  <div className={`relative mx-auto max-w-site px-4 sm:px-6 ${d.size === "full" ? "py-28 sm:py-36" : "py-16 sm:py-20"}`}>
10
- {d.logo ? <Img image={d.logo} className="mb-6 h-20 w-auto sm:h-24" /> : null}
10
+ {d.logo ? <Img image={d.logo} priority className="mb-6 h-20 w-auto sm:h-24" /> : null}
11
11
  {d.eyebrow ? <p className="mb-4 text-sm font-semibold uppercase tracking-[0.18em] opacity-80">{d.eyebrow}</p> : null}
12
12
  <h1 className="max-w-4xl text-[clamp(36px,5vw,64px)] font-bold leading-[1.05] tracking-tight">
13
13
  <Emphasis text={d.heading} className="underline decoration-2 underline-offset-[6px]" />
@@ -99,7 +99,30 @@ export function Button({ link, variant = "primary", dark }: { link?: LinkRef; va
99
99
  );
100
100
  }
101
101
 
102
+ /**
103
+ * An image from Hearth. Its width and height (from Hearth's media library) reserve its space, so the page doesn't jump
104
+ * as it loads. `priority` loads it first: use it for the image at the top of the page (the hero), which is usually
105
+ * the largest thing on screen; everything else loads as it scrolls into view.
106
+ */
102
107
  /* eslint-disable @next/next/no-img-element -- Hearth serves media from its CDN at the uploaded size */
103
- export function Img({ image, className = "" }: { image: { src: string; alt: string }; className?: string }) {
104
- return <img src={image.src} alt={image.alt} loading="lazy" decoding="async" className={className} />;
108
+ export function Img({
109
+ image,
110
+ className = "",
111
+ priority = false,
112
+ }: {
113
+ image: { src: string; alt: string; width?: number; height?: number };
114
+ className?: string;
115
+ priority?: boolean;
116
+ }) {
117
+ return (
118
+ <img
119
+ src={image.src}
120
+ alt={image.alt}
121
+ {...(image.width && image.height ? { width: image.width, height: image.height } : {})}
122
+ loading={priority ? "eager" : "lazy"}
123
+ fetchPriority={priority ? "high" : "auto"}
124
+ decoding={priority ? "sync" : "async"}
125
+ className={className}
126
+ />
127
+ );
105
128
  }