@the-smithy/hearth 0.0.0-stage → 0.12.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/dist/next.d.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { H as HearthCore, b as CollectionPage, E as EventCollectionPage, F as FormResult, c as FormValues } from './client-DUEmHcTL.js';
2
+ import * as _the_smithy_hearth_schema from '@the-smithy/hearth-schema';
3
+ import { PageMeta } from '@the-smithy/hearth-schema';
4
+ import { Metadata, MetadataRoute } from 'next';
5
+
6
+ /** Holds the preview token while Next.js draft mode is on. */
7
+ declare const PREVIEW_COOKIE = "hearth-preview";
8
+ /** Next.js metadata from a page's or event's `meta`. */
9
+ declare function metadataFromMeta(meta: PageMeta): Metadata;
10
+ type HearthNextOptions = {
11
+ /** Base cache tag; every request is tagged with it (default "hearth"). */
12
+ tag?: string;
13
+ /**
14
+ * Longest time (seconds) content stays cached if a publish webhook is missed. Publishing
15
+ * refreshes the cache straight away through the webhook. Default 3600; false caches until then.
16
+ */
17
+ revalidate?: number | false;
18
+ };
19
+ /**
20
+ * Next.js helpers bound to a client: tagged reads (so publish webhooks refresh exactly
21
+ * what changed), metadata, sitemap, the revalidation route, and form submission.
22
+ */
23
+ declare function createHearthNext(client: HearthCore, { tag, revalidate }?: HearthNextOptions): {
24
+ getSite: () => Promise<_the_smithy_hearth_schema.Site>;
25
+ getPages: () => Promise<_the_smithy_hearth_schema.PageSummary[]>;
26
+ getPage: (slug?: string | string[] | null) => Promise<_the_smithy_hearth_schema.Page | null>;
27
+ getEvents: (opts?: {
28
+ when?: "upcoming" | "past";
29
+ limit?: number;
30
+ collection?: string;
31
+ category?: string;
32
+ }) => Promise<_the_smithy_hearth_schema.HearthEvent[]>;
33
+ getCategories: () => Promise<_the_smithy_hearth_schema.CategoryGroup[]>;
34
+ getCollections: () => Promise<_the_smithy_hearth_schema.Collection[]>;
35
+ getCollection: (slug: string, opts?: {
36
+ limit?: number;
37
+ offset?: number;
38
+ tag?: string;
39
+ category?: string;
40
+ when?: "upcoming" | "past";
41
+ }) => Promise<CollectionPage | EventCollectionPage | null>;
42
+ getEvent: (slug: string) => Promise<_the_smithy_hearth_schema.HearthEventDetail | null>;
43
+ /** Fundraisers with progress (sites with Giving). Refreshed after every donation. */
44
+ getFundraisers: (opts?: {
45
+ status?: "active" | "completed" | "all";
46
+ category?: string;
47
+ limit?: number;
48
+ }) => Promise<_the_smithy_hearth_schema.Fundraiser[]>;
49
+ getFundraiser: (slug: string) => Promise<_the_smithy_hearth_schema.FundraiserDetail | null>;
50
+ /**
51
+ * A donation form's server action, shaped for `useActionState(action, null)`. Export it from a "use server" file:
52
+ * export const donate = async (prev: FormResult | null, fd: FormData) => donateAction()(prev, fd);
53
+ * Fields: `amount` (dollars, or "custom" with `customAmount`), `fundraiser`, `name`, `email`, `anonymous`,
54
+ * `message`. On success it redirects the donor to the payment page; afterwards they return to `returnPath`.
55
+ */
56
+ donateAction: ({ returnPath }?: {
57
+ returnPath?: string;
58
+ }) => (_prev: FormResult | null, formData: FormData) => Promise<FormResult>;
59
+ /**
60
+ * An event registration form's server action (sites with Tickets), for `useActionState(action, null)`:
61
+ * export const register = async (slug: string, prev: FormResult | null, fd: FormData) => registerAction(slug)(prev, fd);
62
+ * Fields: `ticket`, `name`, `email`, `phone`, `teamName`, `notes`, and one `attendee` per name. A free ticket
63
+ * resolves to { ok: true, message }; a paid one redirects to the payment page, which returns to `returnPath`.
64
+ */
65
+ registerAction: (slug: string, { returnPath }?: {
66
+ returnPath?: string;
67
+ }) => (_prev: FormResult | null, formData: FormData) => Promise<FormResult>;
68
+ /** Whether this request is a draft preview (to show a "Preview" banner with an exit link). */
69
+ isPreview: () => Promise<boolean>;
70
+ /**
71
+ * GET handler for the site's preview route (app/api/preview/route.ts):
72
+ * export const GET = hearthNext.previewHandler();
73
+ * Hearth's "Preview on the website" button opens it with a token; it checks the token with Hearth, turns on
74
+ * draft mode and opens the page. Page and event reads then return saved drafts until the preview ends.
75
+ * Hearth's side-by-side preview adds `embed=1`, which makes the cookies work inside its editor's frame.
76
+ */
77
+ previewHandler(): (request: Request) => Promise<Response>;
78
+ /** GET handler that ends a preview (app/api/preview/exit/route.ts); `?path=` says where to go next. */
79
+ exitPreviewHandler(): (request: Request) => Promise<never>;
80
+ /** For `generateMetadata`: the page's title, description, canonical, robots and share tags ({} when not found). */
81
+ generatePageMetadata(slug?: string | string[] | null): Promise<Metadata>;
82
+ /** `export default sitemap` from app/sitemap.ts. */
83
+ sitemap(): Promise<MetadataRoute.Sitemap>;
84
+ /**
85
+ * POST handler for the site's revalidation webhook (app/api/revalidate/route.ts):
86
+ * export const POST = hearthNext.revalidateHandler();
87
+ * Verifies the signature with HEARTH_WEBHOOK_SECRET and expires the matching tags.
88
+ */
89
+ revalidateHandler({ secret }?: {
90
+ secret?: string;
91
+ }): (request: Request) => Promise<Response>;
92
+ /**
93
+ * Submit a form from your own server action, forwarding the visitor's IP:
94
+ * "use server";
95
+ * export async function contact(_: unknown, formData: FormData) { return hearthNext.submitForm("contact", formData); }
96
+ */
97
+ submitForm: (formKey: string, values: FormValues) => Promise<FormResult>;
98
+ /**
99
+ * The same, shaped for `useActionState(action, null)`. Export it from a "use server" file:
100
+ * export const contact = async (prev: FormResult | null, fd: FormData) => submitFormAction("contact")(prev, fd);
101
+ */
102
+ submitFormAction: (formKey: string) => (_prev: FormResult | null, formData: FormData) => Promise<FormResult>;
103
+ };
104
+
105
+ export { type HearthNextOptions, PREVIEW_COOKIE, createHearthNext, metadataFromMeta };
package/dist/next.js ADDED
@@ -0,0 +1,224 @@
1
+ import {
2
+ isHearthError,
3
+ normalizeSlug
4
+ } from "./chunk-7ZTQW5QR.js";
5
+ import {
6
+ parseWebhook,
7
+ tagsForWebhook
8
+ } from "./chunk-4VLAG4JX.js";
9
+
10
+ // src/next.ts
11
+ import { revalidateTag } from "next/cache";
12
+ import { cookies, draftMode, headers } from "next/headers";
13
+ import { redirect } from "next/navigation";
14
+ var PREVIEW_COOKIE = "hearth-preview";
15
+ var DRAFT_COOKIE = "__prerender_bypass";
16
+ async function previewToken() {
17
+ if (!(await draftMode()).isEnabled) return void 0;
18
+ return (await cookies()).get(PREVIEW_COOKIE)?.value;
19
+ }
20
+ var clientIp = (h) => h.get("x-nf-client-connection-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip");
21
+ var safePath = (path) => path && path.startsWith("/") && !path.startsWith("//") ? path : "/";
22
+ function metadataFromMeta(meta) {
23
+ const description = meta.description ?? void 0;
24
+ return {
25
+ title: { absolute: meta.fullTitle },
26
+ description,
27
+ alternates: { canonical: meta.canonical },
28
+ robots: { index: meta.robots.index, follow: meta.robots.follow },
29
+ openGraph: {
30
+ type: meta.openGraph.type,
31
+ siteName: meta.openGraph.siteName,
32
+ locale: meta.openGraph.locale,
33
+ url: meta.openGraph.url,
34
+ title: meta.title,
35
+ description,
36
+ images: [{ url: meta.image.url, width: meta.image.width, height: meta.image.height, alt: meta.image.alt }]
37
+ },
38
+ twitter: { card: meta.twitter.card, title: meta.title, description, images: [meta.image.url], ...meta.twitter.site ? { site: meta.twitter.site } : {} }
39
+ };
40
+ }
41
+ function createHearthNext(client, { tag = "hearth", revalidate = 3600 } = {}) {
42
+ const tags = (...extra) => ({ cache: "force-cache", next: { revalidate, tags: [tag, ...extra] } });
43
+ const read = async (...extra) => {
44
+ const preview = await previewToken();
45
+ return preview ? { preview } : tags(...extra);
46
+ };
47
+ const getPage = async (slug) => client.pages.get(slug, await read(`${tag}:page:${normalizeSlug(slug)}`));
48
+ return {
49
+ getSite: () => client.site.get(tags(`${tag}:site`)),
50
+ getPages: () => client.pages.list(tags(`${tag}:pages`)),
51
+ getPage,
52
+ getEvents: (opts) => client.events.list(opts, tags(`${tag}:events`)),
53
+ getCategories: () => client.categories.list(tags(`${tag}:site`)),
54
+ getCollections: () => client.collections.list(tags(`${tag}:site`, `${tag}:pages`)),
55
+ getCollection: (slug, opts) => client.collections.get(slug, opts, tags(`${tag}:pages`, `${tag}:events`)),
56
+ getEvent: async (slug) => client.events.get(slug, await read(`${tag}:events`)),
57
+ /** Fundraisers with progress (sites with Giving). Refreshed after every donation. */
58
+ getFundraisers: (opts) => client.fundraisers.list(opts, tags(`${tag}:giving`)),
59
+ getFundraiser: async (slug) => client.fundraisers.get(slug, await read(`${tag}:giving`)),
60
+ /**
61
+ * A donation form's server action, shaped for `useActionState(action, null)`. Export it from a "use server" file:
62
+ * export const donate = async (prev: FormResult | null, fd: FormData) => donateAction()(prev, fd);
63
+ * Fields: `amount` (dollars, or "custom" with `customAmount`), `fundraiser`, `name`, `email`, `anonymous`,
64
+ * `message`. On success it redirects the donor to the payment page; afterwards they return to `returnPath`.
65
+ */
66
+ donateAction: ({ returnPath = "/donate/thank-you" } = {}) => async (_prev, formData) => {
67
+ const h = await headers();
68
+ const host = h.get("x-forwarded-host") ?? h.get("host");
69
+ const proto = h.get("x-forwarded-proto") ?? (host?.startsWith("localhost") ? "http" : "https");
70
+ const raw = String(formData.get("amount") ?? "") === "custom" ? formData.get("customAmount") : formData.get("amount");
71
+ const dollars = Number(String(raw ?? "").replace(/[$,\s]/g, ""));
72
+ if (!Number.isFinite(dollars) || dollars <= 0) return { ok: false, message: "Choose or enter an amount.", fieldErrors: { amount: "Choose or enter an amount." } };
73
+ const text = (k) => String(formData.get(k) ?? "").trim() || void 0;
74
+ const result = await client.donations.checkout(
75
+ {
76
+ amountCents: Math.round(dollars * 100),
77
+ fundraiser: text("fundraiser"),
78
+ name: text("name"),
79
+ email: text("email"),
80
+ anonymous: formData.get("anonymous") === "on" || formData.get("anonymous") === "true",
81
+ message: text("message"),
82
+ returnUrl: `${proto}://${host}${safePath(returnPath)}`
83
+ },
84
+ { ip: clientIp(h) }
85
+ );
86
+ if (!result.ok) {
87
+ const fieldErrors = { ...result.fieldErrors };
88
+ if (fieldErrors.amountCents) fieldErrors.amount = fieldErrors.amountCents;
89
+ return { ok: false, message: result.message, fieldErrors };
90
+ }
91
+ redirect(result.checkoutUrl);
92
+ },
93
+ /**
94
+ * An event registration form's server action (sites with Tickets), for `useActionState(action, null)`:
95
+ * export const register = async (slug: string, prev: FormResult | null, fd: FormData) => registerAction(slug)(prev, fd);
96
+ * Fields: `ticket`, `name`, `email`, `phone`, `teamName`, `notes`, and one `attendee` per name. A free ticket
97
+ * resolves to { ok: true, message }; a paid one redirects to the payment page, which returns to `returnPath`.
98
+ */
99
+ registerAction: (slug, { returnPath = "/registered" } = {}) => async (_prev, formData) => {
100
+ const h = await headers();
101
+ const host = h.get("x-forwarded-host") ?? h.get("host");
102
+ const proto = h.get("x-forwarded-proto") ?? (host?.startsWith("localhost") ? "http" : "https");
103
+ const text = (k) => String(formData.get(k) ?? "").trim() || void 0;
104
+ const result = await client.events.register(
105
+ slug,
106
+ {
107
+ ticket: text("ticket") ?? "",
108
+ name: text("name") ?? "",
109
+ email: text("email") ?? "",
110
+ phone: text("phone"),
111
+ teamName: text("teamName"),
112
+ notes: text("notes"),
113
+ attendees: formData.getAll("attendee").map((a) => String(a).trim()),
114
+ returnUrl: `${proto}://${host}${safePath(returnPath)}`
115
+ },
116
+ { ip: clientIp(h) }
117
+ );
118
+ if (!result.ok) return { ok: false, message: result.message, fieldErrors: result.fieldErrors };
119
+ if (result.status === "pending") redirect(result.checkoutUrl);
120
+ return { ok: true, message: "You're registered. A confirmation is on its way to your email." };
121
+ },
122
+ /** Whether this request is a draft preview (to show a "Preview" banner with an exit link). */
123
+ isPreview: async () => Boolean(await previewToken()),
124
+ /**
125
+ * GET handler for the site's preview route (app/api/preview/route.ts):
126
+ * export const GET = hearthNext.previewHandler();
127
+ * Hearth's "Preview on the website" button opens it with a token; it checks the token with Hearth, turns on
128
+ * draft mode and opens the page. Page and event reads then return saved drafts until the preview ends.
129
+ * Hearth's side-by-side preview adds `embed=1`, which makes the cookies work inside its editor's frame.
130
+ */
131
+ previewHandler() {
132
+ return async (request) => {
133
+ const params = new URL(request.url).searchParams;
134
+ if (params.has("status")) {
135
+ const current = await previewToken();
136
+ if (!current) return new Response(null, { status: 204, headers: { "Cache-Control": "no-store" } });
137
+ try {
138
+ const { version, expiresAt } = await client.preview.verify(current);
139
+ return Response.json({ version, expiresAt }, { headers: { "Cache-Control": "no-store" } });
140
+ } catch (err) {
141
+ if (isHearthError(err) && err.code === "unauthorized") return new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } });
142
+ throw err;
143
+ }
144
+ }
145
+ const token = params.get("token") ?? "";
146
+ let session;
147
+ try {
148
+ session = await client.preview.verify(token);
149
+ } catch (err) {
150
+ if (isHearthError(err) && err.code === "unauthorized") return new Response(err.message, { status: 401 });
151
+ throw err;
152
+ }
153
+ (await draftMode()).enable();
154
+ const store = await cookies();
155
+ const expires = new Date(session.expiresAt);
156
+ const embedded = params.get("embed") === "1" && process.env.NODE_ENV === "production";
157
+ if (embedded) {
158
+ const bypass = store.get(DRAFT_COOKIE)?.value;
159
+ if (bypass) store.set(DRAFT_COOKIE, bypass, { httpOnly: true, secure: true, sameSite: "none", partitioned: true, path: "/" });
160
+ }
161
+ store.set(PREVIEW_COOKIE, token, {
162
+ httpOnly: true,
163
+ secure: process.env.NODE_ENV === "production",
164
+ sameSite: embedded ? "none" : "lax",
165
+ ...embedded ? { partitioned: true } : {},
166
+ path: "/",
167
+ expires
168
+ });
169
+ redirect(safePath(session.path));
170
+ };
171
+ },
172
+ /** GET handler that ends a preview (app/api/preview/exit/route.ts); `?path=` says where to go next. */
173
+ exitPreviewHandler() {
174
+ return async (request) => {
175
+ (await draftMode()).disable();
176
+ (await cookies()).delete(PREVIEW_COOKIE);
177
+ redirect(safePath(new URL(request.url).searchParams.get("path")));
178
+ };
179
+ },
180
+ /** For `generateMetadata`: the page's title, description, canonical, robots and share tags ({} when not found). */
181
+ async generatePageMetadata(slug) {
182
+ const page = await getPage(slug);
183
+ return page ? metadataFromMeta(page.meta) : {};
184
+ },
185
+ /** `export default sitemap` from app/sitemap.ts. */
186
+ async sitemap() {
187
+ const entries = await client.sitemap(tags(`${tag}:pages`, `${tag}:events`));
188
+ return entries.map((e) => ({ url: e.url, ...e.lastModified ? { lastModified: e.lastModified } : {}, priority: e.priority }));
189
+ },
190
+ /**
191
+ * POST handler for the site's revalidation webhook (app/api/revalidate/route.ts):
192
+ * export const POST = hearthNext.revalidateHandler();
193
+ * Verifies the signature with HEARTH_WEBHOOK_SECRET and expires the matching tags.
194
+ */
195
+ revalidateHandler({ secret = process.env.HEARTH_WEBHOOK_SECRET ?? "" } = {}) {
196
+ return async (request) => {
197
+ const payload = await parseWebhook(request, secret);
198
+ if (!payload) return new Response("Invalid signature", { status: 401 });
199
+ if (payload.site !== client.siteSlug) return new Response("Wrong site", { status: 400 });
200
+ for (const t of tagsForWebhook(payload, tag)) revalidateTag(t, { expire: 0 });
201
+ return Response.json({ revalidated: true });
202
+ };
203
+ },
204
+ /**
205
+ * Submit a form from your own server action, forwarding the visitor's IP:
206
+ * "use server";
207
+ * export async function contact(_: unknown, formData: FormData) { return hearthNext.submitForm("contact", formData); }
208
+ */
209
+ submitForm,
210
+ /**
211
+ * The same, shaped for `useActionState(action, null)`. Export it from a "use server" file:
212
+ * export const contact = async (prev: FormResult | null, fd: FormData) => submitFormAction("contact")(prev, fd);
213
+ */
214
+ submitFormAction: (formKey) => async (_prev, formData) => submitForm(formKey, formData)
215
+ };
216
+ async function submitForm(formKey, values) {
217
+ return client.forms.submit(formKey, values, { ip: clientIp(await headers()) });
218
+ }
219
+ }
220
+ export {
221
+ PREVIEW_COOKIE,
222
+ createHearthNext,
223
+ metadataFromMeta
224
+ };
@@ -0,0 +1,36 @@
1
+ import * as react from 'react';
2
+ import { CSSProperties } from 'react';
3
+
4
+ type HearthPreviewProps = {
5
+ /** The site's preview route; the SDK's `previewHandler()` answers `?status` there. Default `/api/preview`. */
6
+ previewRoute?: string;
7
+ /** The route that ends the preview (`exitPreviewHandler()`). Default `/api/preview/exit`. */
8
+ exitRoute?: string;
9
+ /** How often to check Hearth for newer drafts, in milliseconds. Default 2000. */
10
+ interval?: number;
11
+ /** Replace the default look. Without it the banner is a small dark pill at the bottom centre. */
12
+ className?: string;
13
+ style?: CSSProperties;
14
+ };
15
+ type Status = "live" | "updating" | "ended";
16
+ /**
17
+ * The message <HearthPreview /> sends to Hearth's editor when the preview runs inside its side-by-side frame, so the
18
+ * editor knows the draft loaded (and can offer a new tab when a browser or an older site blocks it). No secrets.
19
+ */
20
+ type HearthPreviewMessage = {
21
+ type: "hearth:preview";
22
+ status: Status;
23
+ path: string;
24
+ };
25
+ /**
26
+ * Live draft preview, for every site built on Hearth. Render it while previewing:
27
+ *
28
+ * {(await hearthNext.isPreview()) ? <HearthPreview /> : null}
29
+ *
30
+ * It shows that the page is a preview, checks Hearth every couple of seconds for newer drafts (Hearth saves them as
31
+ * editors type) and refreshes the page in place when there are any, keeping the scroll position. Inside Hearth's
32
+ * side-by-side preview it reports to the editor (a `HearthPreviewMessage`) and leaves out the exit link.
33
+ */
34
+ declare function HearthPreview({ previewRoute, exitRoute, interval, className, style }: HearthPreviewProps): react.JSX.Element;
35
+
36
+ export { HearthPreview, type HearthPreviewMessage, type HearthPreviewProps };
@@ -0,0 +1,88 @@
1
+ "use client";
2
+
3
+ // src/preview.tsx
4
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
5
+ import { usePathname, useRouter } from "next/navigation";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ var noSubscribe = () => () => {
8
+ };
9
+ var useInFrame = () => useSyncExternalStore(noSubscribe, () => window.parent !== window, () => false);
10
+ var defaultStyle = {
11
+ position: "fixed",
12
+ // Bottom centre: clear of chat widgets (usually bottom right) and Next.js's dev badge (bottom left).
13
+ left: "50%",
14
+ transform: "translateX(-50%)",
15
+ bottom: 16,
16
+ zIndex: 2147483e3,
17
+ display: "flex",
18
+ alignItems: "center",
19
+ gap: 12,
20
+ padding: "8px 14px",
21
+ borderRadius: 999,
22
+ background: "#1a1a14",
23
+ color: "#fff",
24
+ font: "600 13px/1.3 system-ui, -apple-system, Segoe UI, sans-serif",
25
+ boxShadow: "0 4px 16px rgb(0 0 0 / 0.25)"
26
+ };
27
+ function HearthPreview({ previewRoute = "/api/preview", exitRoute = "/api/preview/exit", interval = 2e3, className, style }) {
28
+ const router = useRouter();
29
+ const path = usePathname();
30
+ const [status, setStatus] = useState("live");
31
+ const embedded = useInFrame();
32
+ const version = useRef(null);
33
+ useEffect(() => {
34
+ if (!embedded) return;
35
+ const message = { type: "hearth:preview", status, path: path || "/" };
36
+ window.parent.postMessage(message, "*");
37
+ }, [embedded, status, path]);
38
+ useEffect(() => {
39
+ let stopped = false;
40
+ let timer;
41
+ const check = async () => {
42
+ if (stopped) return;
43
+ if (!document.hidden) {
44
+ try {
45
+ const res = await fetch(`${previewRoute}?status=1`, { cache: "no-store", headers: { accept: "application/json" } });
46
+ if (res.status === 401 || res.status === 204) {
47
+ setStatus("ended");
48
+ return;
49
+ }
50
+ if (res.ok) {
51
+ const { version: next } = await res.json();
52
+ if (next && version.current && next !== version.current) {
53
+ setStatus("updating");
54
+ router.refresh();
55
+ setTimeout(() => !stopped && setStatus("live"), 1200);
56
+ }
57
+ if (next) version.current = next;
58
+ }
59
+ } catch {
60
+ }
61
+ }
62
+ timer = setTimeout(check, interval);
63
+ };
64
+ const onVisible = () => {
65
+ if (document.hidden) return;
66
+ clearTimeout(timer);
67
+ check();
68
+ };
69
+ document.addEventListener("visibilitychange", onVisible);
70
+ check();
71
+ return () => {
72
+ stopped = true;
73
+ clearTimeout(timer);
74
+ document.removeEventListener("visibilitychange", onVisible);
75
+ };
76
+ }, [previewRoute, interval, router]);
77
+ const exit = `${exitRoute}?path=${encodeURIComponent(path || "/")}`;
78
+ return /* @__PURE__ */ jsxs("div", { role: "status", "aria-live": "polite", className, style: className ? style : { ...defaultStyle, ...style }, "data-hearth-preview": status, children: [
79
+ /* @__PURE__ */ jsx("span", { children: status === "ended" ? "Preview ended" : status === "updating" ? "Preview \xB7 updating\u2026" : "Live preview" }),
80
+ embedded ? null : (
81
+ // A plain link, not next/link: prefetching it would end the preview.
82
+ /* @__PURE__ */ jsx("a", { href: exit, style: className ? void 0 : { color: "inherit", textDecoration: "underline", textUnderlineOffset: 2 }, children: status === "ended" ? "Back to the site" : "Exit" })
83
+ )
84
+ ] });
85
+ }
86
+ export {
87
+ HearthPreview
88
+ };
@@ -0,0 +1,33 @@
1
+ import * as react from 'react';
2
+ import { ComponentType, ReactNode } from 'react';
3
+ import { KnownBlock, ResolvedBlock } from '@the-smithy/hearth-schema';
4
+
5
+ type KnownType = KnownBlock["type"];
6
+ /** One component per block type you render; each receives exactly that block. */
7
+ type BlockComponents = {
8
+ [T in KnownType]?: ComponentType<{
9
+ block: Extract<KnownBlock, {
10
+ type: T;
11
+ }>;
12
+ }>;
13
+ };
14
+ /**
15
+ * Render a page's blocks with your own components, in order. Block types without a
16
+ * component (including types added to Hearth later) go to `fallback`, which renders
17
+ * nothing by default.
18
+ *
19
+ * <HearthBlocks blocks={page.blocks} components={{ hero: Hero, richText: RichText }} />
20
+ */
21
+ declare function HearthBlocks({ blocks, components, fallback, }: {
22
+ blocks: ResolvedBlock[];
23
+ components: BlockComponents;
24
+ fallback?: (props: {
25
+ block: ResolvedBlock;
26
+ }) => ReactNode;
27
+ }): react.JSX.Element;
28
+ /** schema.org structured data from `meta.jsonLd`, safely serialized. */
29
+ declare function JsonLd({ data }: {
30
+ data: Record<string, unknown>[];
31
+ }): react.JSX.Element;
32
+
33
+ export { type BlockComponents, HearthBlocks, JsonLd };
package/dist/react.js ADDED
@@ -0,0 +1,24 @@
1
+ // src/react.tsx
2
+ import { Fragment } from "react";
3
+ import { Fragment as Fragment2, jsx } from "react/jsx-runtime";
4
+ function HearthBlocks({
5
+ blocks,
6
+ components,
7
+ fallback
8
+ }) {
9
+ return /* @__PURE__ */ jsx(Fragment2, { children: blocks.map((block) => {
10
+ const Component = components[block.type];
11
+ if (Component) return /* @__PURE__ */ jsx(Component, { block }, block.id);
12
+ if (process.env.NODE_ENV !== "production" && !fallback) {
13
+ console.warn(`[hearth] No component for block type "${block.type}"; it was skipped.`);
14
+ }
15
+ return /* @__PURE__ */ jsx(Fragment, { children: fallback?.({ block }) }, block.id);
16
+ }) });
17
+ }
18
+ function JsonLd({ data }) {
19
+ return /* @__PURE__ */ jsx(Fragment2, { children: data.map((item, i) => /* @__PURE__ */ jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(item).replace(/</g, "\\u003c") } }, i)) });
20
+ }
21
+ export {
22
+ HearthBlocks,
23
+ JsonLd
24
+ };
@@ -0,0 +1,65 @@
1
+ import { Site, Page, HearthEvent, HearthEventDetail, SitemapEntry, Collection, CollectionEntry, CategoryGroup, Fundraiser, FundraiserDetail, HearthForm } from '@the-smithy/hearth-schema';
2
+
3
+ type MockHearthData = {
4
+ site?: Partial<Site>;
5
+ pages?: Page[];
6
+ events?: (HearthEvent | HearthEventDetail)[];
7
+ sitemap?: SitemapEntry[];
8
+ /** Collections, with their entries (page collections) keyed by collection slug. */
9
+ collections?: Collection[];
10
+ entries?: Record<string, CollectionEntry[]>;
11
+ categories?: CategoryGroup[];
12
+ fundraisers?: (Fundraiser | FundraiserDetail)[];
13
+ /** The site's forms, for `forms.get`. */
14
+ forms?: HearthForm[];
15
+ /** Handle event registrations; default confirms free tickets (`ticket: "free"`) and sends others to a fake checkout. */
16
+ onRegistration?(slug: string, input: Record<string, unknown>): {
17
+ ok: true;
18
+ status: "confirmed";
19
+ } | {
20
+ ok: true;
21
+ status: "pending";
22
+ checkoutUrl: string;
23
+ } | {
24
+ ok: false;
25
+ message: string;
26
+ fieldErrors: Record<string, string>;
27
+ };
28
+ /** Handle donation checkouts; default returns a fake checkout URL. */
29
+ onDonation?(input: Record<string, unknown>): {
30
+ ok: true;
31
+ checkoutUrl: string;
32
+ } | {
33
+ ok: false;
34
+ message: string;
35
+ fieldErrors: Record<string, string>;
36
+ };
37
+ /** A token that `preview.verify` accepts, and the path it opens. */
38
+ previewToken?: string;
39
+ previewPath?: string;
40
+ /** The draft version `preview.verify` reports (change it to simulate an editor saving). */
41
+ previewVersion?: string;
42
+ /** Drafts returned instead of `pages` when a request carries a preview token. */
43
+ draftPages?: Page[];
44
+ /** Handle form posts; default accepts everything. */
45
+ onForm?(formKey: string, values: Record<string, unknown>): {
46
+ ok: true;
47
+ message: string;
48
+ } | {
49
+ ok: false;
50
+ message: string;
51
+ fieldErrors: Record<string, string>;
52
+ };
53
+ };
54
+ /**
55
+ * An in-memory Hearth for tests: pass `mock.fetch` to createHearthClient.
56
+ *
57
+ * const mock = createMockHearth({ pages: [page] });
58
+ * const hearth = createHearthClient({ site: "test", apiKey: "hk_test_key_for_tests_only", fetch: mock.fetch });
59
+ */
60
+ declare function createMockHearth(data?: MockHearthData): {
61
+ fetch: typeof globalThis.fetch;
62
+ requests: Request[];
63
+ };
64
+
65
+ export { type MockHearthData, createMockHearth };
@@ -0,0 +1,86 @@
1
+ // src/testing.ts
2
+ var json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
3
+ var notFound = () => json({ error: { code: "not_found", message: "Not found" } }, 404);
4
+ function createMockHearth(data = {}) {
5
+ const requests = [];
6
+ const fetch = async (input, init) => {
7
+ const req = input instanceof Request ? input : new Request(input, init);
8
+ requests.push(req.clone());
9
+ const url = new URL(req.url);
10
+ const m = url.pathname.match(/^\/api\/v1\/sites\/([^/]+)(\/.*)?$/);
11
+ if (!m) return notFound();
12
+ const [, slug, rest = ""] = m;
13
+ const registration = req.method === "POST" ? rest.match(/^\/events\/([^/]+)\/registrations$/)?.[1] : void 0;
14
+ if (registration) {
15
+ const input2 = await req.json().catch(() => ({}));
16
+ const result = data.onRegistration?.(decodeURIComponent(registration), input2) ?? (input2.ticket === "free" ? { ok: true, status: "confirmed" } : { ok: true, status: "pending", checkoutUrl: "https://checkout.example/pay" });
17
+ if (!result.ok) return json({ error: { code: "bad_request", message: result.message, details: result.fieldErrors } }, 400);
18
+ return json({ data: { registrationId: "00000000-0000-4000-8000-000000000002", ...result, ok: void 0 } }, 201);
19
+ }
20
+ if (req.method === "POST" && rest === "/donations/checkout") {
21
+ const input2 = await req.json().catch(() => ({}));
22
+ const result = data.onDonation?.(input2) ?? { ok: true, checkoutUrl: "https://checkout.example/pay" };
23
+ return result.ok ? json({ data: { donationId: "00000000-0000-4000-8000-000000000001", checkoutUrl: result.checkoutUrl } }, 201) : json({ error: { code: "bad_request", message: result.message, details: result.fieldErrors } }, 400);
24
+ }
25
+ if (req.method === "POST") {
26
+ const form = rest.match(/^\/forms\/([^/]+)$/)?.[1];
27
+ if (!form) return notFound();
28
+ const values = await req.json().catch(() => ({}));
29
+ const result = data.onForm?.(decodeURIComponent(form), values) ?? { ok: true, message: "Thanks!" };
30
+ return result.ok ? json({ data: { message: result.message } }, 201) : json({ error: { code: "bad_request", message: result.message, details: result.fieldErrors } }, 400);
31
+ }
32
+ if (rest === "") return json({ data: { slug, name: slug, domain: null, features: [], settings: {}, ...data.site } });
33
+ if (rest === "/pages") return json({ data: (data.pages ?? []).map(({ slug: slug2, path, title, seo, publishedAt }) => ({ slug: slug2, path, title, seo, noIndex: false, publishedAt })) });
34
+ if (rest.startsWith("/pages/")) {
35
+ const pool = req.headers.get("x-hearth-preview") ? [...data.draftPages ?? [], ...data.pages ?? []] : data.pages ?? [];
36
+ const page = pool.find((p) => p.slug === decodeURIComponent(rest.slice(7)));
37
+ return page ? json({ data: page }) : notFound();
38
+ }
39
+ if (rest.startsWith("/forms/")) {
40
+ const form = (data.forms ?? []).find((f) => f.key === decodeURIComponent(rest.slice(7)));
41
+ return form ? json({ data: form }) : notFound();
42
+ }
43
+ if (rest === "/events" || rest.startsWith("/events?")) return json({ data: data.events ?? [] });
44
+ if (rest.startsWith("/events/")) {
45
+ const event = (data.events ?? []).find((e) => e.slug === decodeURIComponent(rest.slice(8)));
46
+ return event ? json({ data: event }) : notFound();
47
+ }
48
+ if (rest === "/sitemap") return json({ data: data.sitemap ?? [] });
49
+ if (rest === "/fundraisers" || rest.startsWith("/fundraisers?")) {
50
+ const status = url.searchParams.get("status") ?? "active";
51
+ return json({ data: (data.fundraisers ?? []).filter((f) => status === "all" || f.status === status) });
52
+ }
53
+ if (rest.startsWith("/fundraisers/")) {
54
+ const fundraiser = (data.fundraisers ?? []).find((f) => f.slug === decodeURIComponent(rest.slice(13)));
55
+ return fundraiser ? json({ data: fundraiser }) : notFound();
56
+ }
57
+ if (rest === "/preview") {
58
+ const token = req.headers.get("x-hearth-preview");
59
+ return token && token === data.previewToken ? json({ data: { path: data.previewPath ?? "/", expiresAt: new Date(Date.now() + 36e5).toISOString(), version: data.previewVersion ?? "1" } }) : json({ error: { code: "unauthorized", message: "This preview link has expired." } }, 401);
60
+ }
61
+ if (rest === "/collections") return json({ data: data.collections ?? [] });
62
+ if (rest === "/categories") return json({ data: data.categories ?? [] });
63
+ if (rest.startsWith("/collections/")) {
64
+ const want = decodeURIComponent(rest.slice(13));
65
+ const collection = (data.collections ?? []).find((c) => c.slug === want);
66
+ if (!collection) return notFound();
67
+ if (collection.kind === "events") {
68
+ return json({ data: { collection, events: (data.events ?? []).filter((e) => e.collection?.id === collection.id) } });
69
+ }
70
+ const all = data.entries?.[collection.slug] ?? [];
71
+ const tag = url.searchParams.get("tag");
72
+ const category = url.searchParams.get("category");
73
+ const list = all.filter(
74
+ (e) => (!tag || e.tags.includes(tag)) && (!category || e.categories.some((c) => `${c.group.slug}/${c.slug}` === category))
75
+ );
76
+ const offset = Number(url.searchParams.get("offset") ?? 0);
77
+ const limit = Number(url.searchParams.get("limit") ?? 12);
78
+ return json({ data: { collection, total: list.length, entries: list.slice(offset, offset + limit) } });
79
+ }
80
+ return notFound();
81
+ };
82
+ return { fetch, requests };
83
+ }
84
+ export {
85
+ createMockHearth
86
+ };