@the-smithy/create-hearth-site 0.21.0 → 0.23.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.21.0",
3
+ "version": "0.23.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.21.0"
32
+ "@the-smithy/hearth-schema": "^0.23.0"
33
33
  },
34
34
  "scripts": {
35
35
  "test": "node --test \"test/**/*.test.js\" && vitest run test/blocks.test.ts"
@@ -72,8 +72,9 @@ Keep the API key out of chat: the agent never needs to see it, only `.env.local`
72
72
  - Headings can mark words with `*asterisks*` to highlight them (the `emphasis` slot, and `hero.emphasis`).
73
73
  - To leave a block type out, ask for it to be turned off for the site in Hearth.
74
74
  - **SEO comes from Hearth.** Titles, descriptions, canonical addresses, share images, structured data, the
75
- sitemap, robots.txt, redirects for old addresses, Search Console verification and the page's language all come
76
- from Hearth (`siteMetadata`, `generatePageMetadata`, `sitemapWith`, `robots`, `redirectOrNotFound`). Preview deploys
75
+ sitemap, robots.txt, llms.txt (the summary AI tools read), redirects for old addresses, Search Console verification
76
+ and the page's language all come from Hearth (`siteMetadata`, `generatePageMetadata`, `sitemapWith`, `robots`,
77
+ `llmsTxtHandler`, `redirectOrNotFound`). Preview deploys
77
78
  and sites hidden in Hearth are kept out of search engines. What's still yours:
78
79
  - Pages your own code serves (like `/events`) need their own `generateMetadata` and a place in `src/app/sitemap.ts`.
79
80
  - Use `<Img priority>` for the image at the top of a page, and keep one `<h1>` per page (the Hero block renders it).
@@ -119,6 +120,7 @@ States are Tailwind variants inside the slot: `on-dark:` for blocks on an invers
119
120
  | `src/app/api/revalidate/route.ts` | Hearth calls this when something is published, so changes go live in seconds |
120
121
  | `src/app/api/preview/route.ts` | Opens drafts from Hearth's "Preview on the website"; `/api/preview/exit` ends it |
121
122
  | `src/app/sitemap.ts`, `robots.ts` | Generated from Hearth. Search engines stay out until the site's web address is set |
123
+ | `src/app/llms.txt/route.ts` | The site's summary for AI tools, generated from Hearth (not found until the site is live) |
122
124
 
123
125
  ## Deploying to Netlify
124
126
 
@@ -0,0 +1,7 @@
1
+ import { hearthNext } from "@/hearth/client";
2
+
3
+ /**
4
+ * The summary AI tools read (https://llmstxt.org): the organization, its contact details and hours, and every
5
+ * page search engines may list, with a line on each. Not found on preview deploys and while the site is hidden.
6
+ */
7
+ export const GET = hearthNext.llmsTxtHandler();
@@ -1,19 +1,20 @@
1
1
  "use client";
2
2
 
3
3
  import { usePathname } from "next/navigation";
4
- import { useEffect, useRef } from "react";
4
+ import { useEffect, useRef, type ReactNode } from "react";
5
5
  import { theme } from "@/theme";
6
+ import { NavDropdown } from "./NavDropdown";
6
7
  import { NavLink } from "./NavLink";
7
8
 
8
- type Link = { label: string; href: string };
9
+ type Link = { label: string; href: string; dropdown?: string };
9
10
 
10
11
  const t = theme.mobileNav;
11
12
 
12
13
  /**
13
14
  * The menu on phones: a button that opens the links full screen, in a dialog (so focus stays in it and Escape closes
14
- * it). It closes when a link is followed.
15
+ * it). It closes when a link is followed. Items with a dropdown show its global block below them when opened.
15
16
  */
16
- export function MobileNav({ siteName, links, cta }: { siteName: string; links: Link[]; cta?: Link }) {
17
+ export function MobileNav({ siteName, links, cta, dropdowns = {} }: { siteName: string; links: Link[]; cta?: Link; dropdowns?: Record<string, ReactNode> }) {
17
18
  const dialog = useRef<HTMLDialogElement>(null);
18
19
  const pathname = usePathname();
19
20
  useEffect(() => dialog.current?.close(), [pathname]);
@@ -32,11 +33,23 @@ export function MobileNav({ siteName, links, cta }: { siteName: string; links: L
32
33
  </button>
33
34
  </div>
34
35
  <nav aria-label="Main" className={t.nav}>
35
- {links.map((item) => (
36
- <NavLink key={item.href + item.label} href={item.href} className={t.link} onClick={close}>
37
- {item.label}
38
- </NavLink>
39
- ))}
36
+ {links.map((item) =>
37
+ item.dropdown && dropdowns[item.dropdown] ? (
38
+ <NavDropdown
39
+ key={item.href + item.label}
40
+ href={item.href}
41
+ label={item.label}
42
+ onNavigate={close}
43
+ slots={{ root: t.dropdown, link: t.dropdownLink, toggle: t.dropdownToggle, panel: t.dropdownPanel }}
44
+ >
45
+ {dropdowns[item.dropdown]}
46
+ </NavDropdown>
47
+ ) : (
48
+ <NavLink key={item.href + item.label} href={item.href} className={t.link} onClick={close}>
49
+ {item.label}
50
+ </NavLink>
51
+ ),
52
+ )}
40
53
  {cta ? (
41
54
  <NavLink href={cta.href} className={t.cta} onClick={close}>
42
55
  {cta.label}
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import { usePathname } from "next/navigation";
4
+ import { useEffect, useId, useRef, useState, type ReactNode } from "react";
5
+ import { theme } from "@/theme";
6
+ import { NavLink } from "./NavLink";
7
+
8
+ /** Classes for one place the dropdown is drawn (the header, or the phone menu). */
9
+ export type DropdownSlots = { root: string; link: string; toggle: string; panel: string };
10
+
11
+ /**
12
+ * A main menu item with a dropdown (Navigation in Hearth: the item's Dropdown is a global block): the link, and a
13
+ * button beside it that shows the block in a panel. Escape, a click elsewhere or following a link closes it.
14
+ */
15
+ export function NavDropdown({ href, label, slots, onNavigate, children }: { href: string; label: string; slots: DropdownSlots; onNavigate?: () => void; children: ReactNode }) {
16
+ const pathname = usePathname();
17
+ // Open on the page it was opened on: following a link closes it.
18
+ const [openOn, setOpenOn] = useState<string | null>(null);
19
+ const open = openOn === pathname;
20
+ const setOpen = (next: boolean) => setOpenOn(next ? pathname : null);
21
+ const root = useRef<HTMLDivElement>(null);
22
+ const panelId = useId();
23
+ useEffect(() => {
24
+ if (!open) return;
25
+ // On click, not pointerdown: in the phone menu the panel collapsing mid-tap would move the item being tapped.
26
+ const away = (e: MouseEvent) => {
27
+ if (!root.current?.contains(e.target as Node)) setOpenOn(null);
28
+ };
29
+ document.addEventListener("click", away, true);
30
+ return () => document.removeEventListener("click", away, true);
31
+ }, [open]);
32
+
33
+ return (
34
+ <div
35
+ ref={root}
36
+ className={slots.root}
37
+ onKeyDown={(e) => {
38
+ if (e.key === "Escape" && open) {
39
+ setOpen(false);
40
+ root.current?.querySelector("button")?.focus();
41
+ }
42
+ }}
43
+ >
44
+ <NavLink href={href} className={slots.link} onClick={onNavigate}>
45
+ {label}
46
+ </NavLink>
47
+ <button
48
+ type="button"
49
+ className={slots.toggle}
50
+ aria-expanded={open}
51
+ aria-controls={panelId}
52
+ aria-label={theme.text.more.replace("{label}", label)}
53
+ onClick={() => setOpen(!open)}
54
+ >
55
+ <svg aria-hidden="true" viewBox="0 0 20 20" fill="currentColor" className="size-4">
56
+ <path d="M5.2 7.2a.75.75 0 0 1 1.06.02L10 11.1l3.74-3.88a.75.75 0 1 1 1.08 1.04l-4.28 4.44a.75.75 0 0 1-1.08 0L5.18 8.26a.75.75 0 0 1 .02-1.06Z" />
57
+ </svg>
58
+ </button>
59
+ <div id={panelId} hidden={!open} className={slots.panel}>
60
+ {children}
61
+ </div>
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import { usePathname } from "next/navigation";
4
+ import { useEffect, useId, useRef, type ReactNode } from "react";
5
+ import { theme } from "@/theme";
6
+
7
+ const t = theme.popup;
8
+
9
+ /**
10
+ * A global block made a pop-up in Hearth, in a dialog (focus stays in it, Escape closes it). Any link to
11
+ * `#popup-<key>` opens it (editors pick it under Pop-ups when they choose where a link goes), as does arriving at an
12
+ * address ending in it; `button` adds a floating button that opens it on every page.
13
+ */
14
+ export function Popup({ popupKey, name, title, button, children }: { popupKey: string; name: string; title?: string; button?: string; children: ReactNode }) {
15
+ const dialog = useRef<HTMLDialogElement>(null);
16
+ const titleId = useId();
17
+ const pathname = usePathname();
18
+ const href = `#popup-${popupKey}`;
19
+ const open = () => {
20
+ if (dialog.current && !dialog.current.open) dialog.current.showModal();
21
+ };
22
+
23
+ useEffect(() => {
24
+ // Capturing, so it runs before the link's own navigation.
25
+ const onClick = (e: MouseEvent) => {
26
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
27
+ const link = e.target instanceof Element ? e.target.closest("a") : null;
28
+ if (link?.getAttribute("href") !== href) return;
29
+ e.preventDefault();
30
+ e.stopPropagation();
31
+ if (dialog.current && !dialog.current.open) dialog.current.showModal();
32
+ };
33
+ document.addEventListener("click", onClick, true);
34
+ if (window.location.hash === href && dialog.current && !dialog.current.open) dialog.current.showModal();
35
+ return () => document.removeEventListener("click", onClick, true);
36
+ }, [href]);
37
+ // Following a link inside it closes it (only a change of page: it may have just opened from the address).
38
+ const shownOn = useRef(pathname);
39
+ useEffect(() => {
40
+ if (shownOn.current !== pathname) dialog.current?.close();
41
+ shownOn.current = pathname;
42
+ }, [pathname]);
43
+
44
+ return (
45
+ <>
46
+ <dialog
47
+ ref={dialog}
48
+ aria-labelledby={title ? titleId : undefined}
49
+ aria-label={title ? undefined : name}
50
+ className={t.dialog}
51
+ // A click on the backdrop lands on the dialog itself.
52
+ onClick={(e) => {
53
+ if (e.target === e.currentTarget) e.currentTarget.close();
54
+ }}
55
+ >
56
+ <div className={t.top}>
57
+ {title ? (
58
+ <h2 id={titleId} className={t.title}>
59
+ {title}
60
+ </h2>
61
+ ) : (
62
+ <span />
63
+ )}
64
+ <button type="button" className={t.close} onClick={() => dialog.current?.close()}>
65
+ {theme.text.close}
66
+ </button>
67
+ </div>
68
+ <div className={t.body}>{children}</div>
69
+ </dialog>
70
+ {button ? (
71
+ <button type="button" aria-haspopup="dialog" className={t.floating} onClick={open}>
72
+ {button}
73
+ </button>
74
+ ) : null}
75
+ </>
76
+ );
77
+ }
@@ -3,8 +3,10 @@ import { HearthAnalytics } from "@the-smithy/hearth/analytics";
3
3
  import { siteLang } from "@the-smithy/hearth/next";
4
4
  import { HearthPreview } from "@the-smithy/hearth/preview";
5
5
  import { theme } from "@/theme";
6
+ import { Blocks } from "../blocks";
6
7
  import { fontVariables } from "@/theme/fonts";
7
8
  import { hearthNext } from "../client";
9
+ import { Popup } from "./Popup";
8
10
  import { SiteFooter } from "./SiteFooter";
9
11
  import { SiteHeader } from "./SiteHeader";
10
12
 
@@ -16,9 +18,12 @@ import { SiteHeader } from "./SiteHeader";
16
18
  export const generateMetadata = () => hearthNext.siteMetadata();
17
19
 
18
20
  export default async function RootLayout({ children }: { children: ReactNode }) {
19
- const site = await hearthNext.getSite();
20
- const preview = await hearthNext.isPreview();
21
+ const [site, preview, globals] = await Promise.all([hearthNext.getSite(), hearthNext.isPreview(), hearthNext.getGlobalBlocks()]);
21
22
  const t = theme.page;
23
+ // Global blocks (Global blocks in Hearth) shown outside pages: under menu items, and as pop-ups.
24
+ const dropdowns = Object.fromEntries(
25
+ globals.filter((g) => site.settings.navigation.some((n) => n.dropdown === g.key)).map((g) => [g.key, <Blocks key={g.key} blocks={[g.block]} />]),
26
+ );
22
27
  return (
23
28
  <html lang={siteLang(site)} className={fontVariables || undefined}>
24
29
  <body className={t.body}>
@@ -27,11 +32,18 @@ export default async function RootLayout({ children }: { children: ReactNode })
27
32
  </a>
28
33
  {/* Live draft preview from Hearth: shows while an editor previews, refreshes as they edit. */}
29
34
  {preview ? <HearthPreview /> : null}
30
- <SiteHeader site={site} />
35
+ <SiteHeader site={site} dropdowns={dropdowns} />
31
36
  <main id="main" className={t.main}>
32
37
  {children}
33
38
  </main>
34
39
  <SiteFooter site={site} />
40
+ {globals.map((g) =>
41
+ g.popup ? (
42
+ <Popup key={g.key} popupKey={g.key} name={g.name} title={g.popup.title} button={g.popup.button}>
43
+ <Blocks blocks={[g.block]} />
44
+ </Popup>
45
+ ) : null,
46
+ )}
35
47
  {/* Hearth's built-in visitor analytics (cookieless, no banner needed): off in draft preview and when the site turns it off. */}
36
48
  {site.analytics && !preview ? <HearthAnalytics config={site.analytics} /> : null}
37
49
  </body>
@@ -1,14 +1,19 @@
1
1
  import Link from "next/link";
2
+ import type { ReactNode } from "react";
2
3
  import type { Site } from "@the-smithy/hearth";
3
4
  import { theme } from "@/theme";
4
5
  import { Logo } from "@/theme/overrides";
5
6
  import { cx } from "../theme";
6
7
  import { ground, Img, toTone } from "../ui";
7
8
  import { MobileNav } from "./MobileNav";
9
+ import { NavDropdown } from "./NavDropdown";
8
10
  import { NavLink } from "./NavLink";
9
11
 
10
- /** Logo, menu, menu button and announcement banner, all edited in Hearth (Settings → General, and Navigation). */
11
- export function SiteHeader({ site }: { site: Site }) {
12
+ /**
13
+ * Logo, menu, menu button and announcement banner, all edited in Hearth (Settings → General, and Navigation).
14
+ * `dropdowns` holds the global blocks menu items open in a dropdown, drawn, by key.
15
+ */
16
+ export function SiteHeader({ site, dropdowns = {} }: { site: Site; dropdowns?: Record<string, ReactNode> }) {
12
17
  const { announcement, seo, tagline, navigation, navigationCta } = site.settings;
13
18
  const t = theme.header;
14
19
  const tone = toTone(t.tone);
@@ -32,11 +37,22 @@ export function SiteHeader({ site }: { site: Site }) {
32
37
  {navigation.length || navigationCta ? (
33
38
  <>
34
39
  <nav aria-label="Main" className={t.nav}>
35
- {navigation.map((item) => (
36
- <NavLink key={item.href + item.label} href={item.href} className={t.link}>
37
- {item.label}
38
- </NavLink>
39
- ))}
40
+ {navigation.map((item) =>
41
+ item.dropdown && dropdowns[item.dropdown] ? (
42
+ <NavDropdown
43
+ key={item.href + item.label}
44
+ href={item.href}
45
+ label={item.label}
46
+ slots={{ root: t.dropdown, link: t.dropdownLink, toggle: t.dropdownToggle, panel: t.dropdownPanel }}
47
+ >
48
+ {dropdowns[item.dropdown]}
49
+ </NavDropdown>
50
+ ) : (
51
+ <NavLink key={item.href + item.label} href={item.href} className={t.link}>
52
+ {item.label}
53
+ </NavLink>
54
+ ),
55
+ )}
40
56
  {/* The menu button (Navigation → Menu button in Hearth): one action that stands out from the links. */}
41
57
  {navigationCta ? (
42
58
  <NavLink href={navigationCta.href} className={t.cta}>
@@ -44,7 +60,7 @@ export function SiteHeader({ site }: { site: Site }) {
44
60
  </NavLink>
45
61
  ) : null}
46
62
  </nav>
47
- <MobileNav siteName={site.name} links={navigation} cta={navigationCta} />
63
+ <MobileNav siteName={site.name} links={navigation} cta={navigationCta} dropdowns={dropdowns} />
48
64
  </>
49
65
  ) : null}
50
66
  </div>
@@ -16,6 +16,7 @@
16
16
  "src/app/registered/page.tsx",
17
17
  "src/app/robots.ts",
18
18
  "src/app/sitemap.ts",
19
+ "src/app/llms.txt/route.ts",
19
20
  "src/app/api/preview/route.ts",
20
21
  "src/app/api/preview/exit/route.ts",
21
22
  "src/app/api/revalidate/route.ts",
@@ -52,6 +52,12 @@ export const defaults = {
52
52
  nav: "hidden flex-wrap items-center justify-end gap-x-6 gap-y-1 font-medium md:flex",
53
53
  link: "py-2 hover:text-highlight aria-[current=page]:text-highlight",
54
54
  cta: "rounded-lg bg-accent px-4 py-2 font-semibold text-accent-ink hover:opacity-90",
55
+ /** A menu item with a dropdown (a global block in a panel): the item, its link, the button that opens it, the panel. */
56
+ dropdown: "relative flex items-center gap-0.5",
57
+ dropdownLink: "py-2 hover:text-highlight aria-[current=page]:text-highlight",
58
+ dropdownToggle: "inline-flex size-8 items-center justify-center rounded-md hover:text-highlight aria-expanded:rotate-180",
59
+ dropdownPanel:
60
+ "absolute right-0 top-full z-40 mt-2 max-h-[80dvh] w-[min(32rem,calc(100vw-2rem))] overflow-y-auto rounded-2xl text-ink shadow-xl ring-1 ring-line [&_h2]:text-2xl [&_section>div]:px-6 [&_section>div]:py-6",
55
61
  },
56
62
  /** The menu on phones: a button that opens the links full screen. */
57
63
  mobileNav: {
@@ -64,6 +70,20 @@ export const defaults = {
64
70
  nav: "flex flex-col px-4 py-4",
65
71
  link: "border-b border-line py-4 text-2xl font-semibold aria-[current=page]:text-highlight",
66
72
  cta: "mt-8 inline-block rounded-lg bg-accent px-6 py-3.5 text-center font-semibold text-accent-ink",
73
+ /** A menu item with a dropdown: its link and button on one row, the global block below when opened. */
74
+ dropdown: "flex flex-wrap items-center justify-between border-b border-line",
75
+ dropdownLink: "py-4 text-2xl font-semibold aria-[current=page]:text-highlight",
76
+ dropdownToggle: "inline-flex size-11 items-center justify-center rounded-lg ring-1 ring-line aria-expanded:rotate-180",
77
+ dropdownPanel: "basis-full pb-4 [&_section>div]:px-0 [&_section>div]:py-4",
78
+ },
79
+ /** A global block made a pop-up in Hearth: the dialog, its top bar (heading, close), the block, and the floating button. */
80
+ popup: {
81
+ dialog: "m-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-2xl overflow-y-auto rounded-2xl bg-bg p-0 text-ink shadow-2xl backdrop:bg-black/50",
82
+ top: "sticky top-0 z-10 flex items-center justify-between gap-4 border-b border-line bg-bg px-6 py-4",
83
+ title: "text-xl font-bold",
84
+ close: "inline-flex min-h-11 items-center rounded-lg px-4 font-semibold ring-1 ring-line",
85
+ body: "[&_section>div]:px-6 [&_section>div]:py-8",
86
+ floating: "fixed bottom-4 right-4 z-40 rounded-full bg-accent px-6 py-3.5 font-semibold text-accent-ink shadow-lg hover:opacity-90 sm:bottom-6 sm:right-6",
67
87
  },
68
88
  footer: {
69
89
  /** The footer's ground (it sets on-dark: for everything in it). */
@@ -503,6 +523,8 @@ export const defaults = {
503
523
  completed: "Completed",
504
524
  menu: "Menu",
505
525
  close: "Close",
526
+ /** The button beside a menu item that opens its dropdown (read out, not shown). */
527
+ more: "More under {label}",
506
528
  share: "Share",
507
529
  visitProject: "Visit {name}",
508
530
  mapFailed: "The map couldn't load.",