diff --git a/app/lib/graphql.ts b/app/lib/graphql.ts index a1c2017..81db3e5 100644 --- a/app/lib/graphql.ts +++ b/app/lib/graphql.ts @@ -5,12 +5,12 @@ import { createFontdueFetch, FontdueNotFoundError } from "fontdue-js/server"; // there's no transport boilerplate in the loaders. // // There's no per-request binding: because the root route's middleware (see -// app/root.tsx) wraps every loader in runWithPreview, this fetcher automatically +// app/root.tsx) wraps every loader in runWithFontdue, this fetcher automatically // forwards the admin preview token when an admin is previewing (revealing -// unpublished fonts), and sends a plain request otherwise. The same is true of -// every fontdue-js preload helper (loadTypeTesterQuery, loadFontdueProviderQuery, -// …) — call them with just their variables and they pick up preview from the -// ambient context. +// unpublished fonts) and the visitor's node-access token for a collection they've +// unlocked, and sends a plain request otherwise. The same is true of every +// fontdue-js preload helper (loadTypeTesterQuery, loadFontdueProviderQuery, …) — +// call them with just their variables and they pick up the ambient context. // // Use it at the top of a loader: // diff --git a/app/root.tsx b/app/root.tsx index 9328cc8..9110774 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -11,7 +11,7 @@ import { import FontdueProvider, { loadFontdueProviderQuery } from "fontdue-js/FontdueProvider"; import StoreModal from "fontdue-js/StoreModal"; import CartButton from "fontdue-js/CartButton"; -import { runWithPreview } from "fontdue-js/preview/server"; +import { runWithFontdue } from "fontdue-js/server/middleware"; import type { Route } from "./+types/root"; import "./app.css"; @@ -20,14 +20,17 @@ import { fetchGraphql } from "./lib/graphql"; import RootLayoutDoc from "./queries/RootLayout.graphql?raw"; import type { RootLayoutQuery } from "./queries/operations-types"; -// Root middleware wraps every loader on every route. runWithPreview puts the -// admin preview token (from the preview cookie) into an ambient context for the -// whole request, so the fetcher and all fontdue-js preloads reveal unpublished -// fonts with no per-loader plumbing — and it forces preview responses out of -// the shared CDN cache so an admin render is never served to the public. Public -// requests pass through untouched and stay cacheable (see `headers` below). +// Root middleware wraps every loader on every route. runWithFontdue puts two +// request-scoped tokens into an ambient context for the whole request: the admin +// preview token (reveals unpublished fonts) and the visitor's per-collection +// node-access token (a collection they unlocked with a password). The fetcher +// and all fontdue-js preloads forward them with no per-loader plumbing — and it +// forces a per-visitor response out of the shared CDN cache so an admin's (or an +// unlocked visitor's) render is never served to the public. Public requests pass +// through untouched and stay cacheable (see `headers` below). (runWithFontdue is +// runWithPreview composed with runWithNodeAccess; mount either alone for one.) export const middleware: Route.MiddlewareFunction[] = [ - ({ request }, next) => runWithPreview(request, next), + ({ request }, next) => runWithFontdue(request, next), ]; // The route loader is the SSR data layer — equivalent to Astro's diff --git a/app/routes/api.preview.ts b/app/routes/api.preview.ts index 1435af2..fc1efd9 100644 --- a/app/routes/api.preview.ts +++ b/app/routes/api.preview.ts @@ -5,7 +5,7 @@ import type { Route } from "./+types/api.preview"; // by — POSTs a short-lived token here to turn preview on, and // DELETEs to turn it off. handlePreviewRequest sets the preview cookies (an // httpOnly token + a readable marker that the toolbar checks); the root route's -// middleware (app/root.tsx) wraps each request in runWithPreview, which forwards +// middleware (app/root.tsx) wraps each request in runWithFontdue, which forwards // the token to GraphQL and keeps preview pages out of the shared CDN cache so // the public never sees unpublished fonts. // diff --git a/app/routes/fonts.$slug.tsx b/app/routes/fonts.$slug.tsx index 03acd3c..8942e13 100644 --- a/app/routes/fonts.$slug.tsx +++ b/app/routes/fonts.$slug.tsx @@ -4,6 +4,8 @@ import CharacterViewer, { loadCharacterViewerQuery, } from "fontdue-js/CharacterViewer"; import BuyButton, { loadBuyButtonQuery } from "fontdue-js/BuyButton"; +import { FontduePasswordProtectedError } from "fontdue-js/server"; +import NodePasswordForm from "fontdue-js/NodePasswordForm"; import { fetchGraphql } from "../lib/graphql"; import FontDoc from "../queries/Font.graphql?raw"; import type { @@ -12,6 +14,9 @@ import type { } from "../queries/operations-types"; export function meta({ data }: Route.MetaArgs) { + if (data?.locked) { + return [{ title: "Password required — fontdue-js on RR7" }]; + } const collection = data?.collection; const title = collection?.pageMetadata?.title ?? collection?.name ?? "Font detail"; @@ -25,19 +30,28 @@ export function meta({ data }: Route.MetaArgs) { // reveal unpublished styles — preview rides the ambient context, so nothing // is threaded here (see app/lib/graphql.ts). export async function loader({ params }: Route.LoaderArgs) { - const [ - fontData, + let fontData, typeTestersPreload, characterViewerPreload, - buyButtonPreload, - ] = await Promise.all([ - fetchGraphql("Font", FontDoc, { - slug: params.slug, - }), - loadTypeTestersQuery({ collectionSlug: params.slug }), - loadCharacterViewerQuery({ collectionSlug: params.slug }), - loadBuyButtonQuery({ collectionSlug: params.slug }), - ]); + buyButtonPreload; + try { + [fontData, typeTestersPreload, characterViewerPreload, buyButtonPreload] = + await Promise.all([ + fetchGraphql("Font", FontDoc, { + slug: params.slug, + }), + loadTypeTestersQuery({ collectionSlug: params.slug }), + loadCharacterViewerQuery({ collectionSlug: params.slug }), + loadBuyButtonQuery({ collectionSlug: params.slug }), + ]); + } catch (error) { + // The collection is password-protected and the visitor hasn't unlocked it. + // Render the password form instead of a 404 — it exists, it's just gated. + if (error instanceof FontduePasswordProtectedError) { + return { locked: true as const, slug: params.slug }; + } + throw error; + } const collection = fontData.viewer.slug?.fontCollection; if (!collection) { @@ -45,6 +59,7 @@ export async function loader({ params }: Route.LoaderArgs) { } return { + locked: false as const, collection, typeTestersPreload, characterViewerPreload, @@ -53,6 +68,18 @@ export async function loader({ params }: Route.LoaderArgs) { } export default function FontDetail({ loaderData }: Route.ComponentProps) { + if (loaderData.locked) { + return ( + <> +

Password required

+

+ This collection is password-protected. Enter the password to view it. +

+ + + ); + } + const { collection, typeTestersPreload, diff --git a/package-lock.json b/package-lock.json index 0522ba6..f52f460 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "@netlify/functions": "^5.2.0", "@react-router/node": "7.14.0", "@react-router/serve": "7.14.0", - "fontdue-js": "3.0.6", + "fontdue-js": "3.1.0", "isbot": "^5.1.36", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -2523,9 +2523,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2542,9 +2539,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2561,9 +2555,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2580,9 +2571,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2599,9 +2587,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2618,9 +2603,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2653,9 +2635,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3333,9 +3312,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3352,9 +3328,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3371,9 +3344,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3390,9 +3360,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3425,9 +3392,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3628,9 +3592,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3645,9 +3606,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3662,9 +3620,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3679,9 +3634,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3696,9 +3648,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3713,9 +3662,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3730,9 +3676,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3747,9 +3690,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3764,9 +3704,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3781,9 +3718,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3798,9 +3732,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3828,9 +3759,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4144,9 +4072,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4164,9 +4089,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4200,9 +4122,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6056,9 +5975,9 @@ "license": "MIT" }, "node_modules/fontdue-js": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fontdue-js/-/fontdue-js-3.0.6.tgz", - "integrity": "sha512-ThSTQ4tue+hxyE9d6TqwQNRGsSKSonixCRQxc2UidQtjOCQMNfAHqFdcXv8yQvUHzw/rmWtHA0b/E+LNWtXpeg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fontdue-js/-/fontdue-js-3.1.0.tgz", + "integrity": "sha512-zFgzLEzJSpq8Eh2ZR3iVEg3jf510gAX2Fefwe2Nur5kTw9kKHub8iQRb12iY3qPWwudCTp+xbWiHXRpD/9GAuw==", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", @@ -7424,9 +7343,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7447,9 +7363,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7490,9 +7403,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 7cd5972..330aac9 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@netlify/functions": "^5.2.0", "@react-router/node": "7.14.0", "@react-router/serve": "7.14.0", - "fontdue-js": "3.0.6", + "fontdue-js": "3.1.0", "isbot": "^5.1.36", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/react-router.config.ts b/react-router.config.ts index 7ce0621..790411a 100644 --- a/react-router.config.ts +++ b/react-router.config.ts @@ -5,9 +5,9 @@ export default { ssr: true, future: { // Route middleware (stable since React Router 7.9). The root route's - // middleware wraps every loader in runWithPreview so the staff preview - // token reaches fontdue-js fetches/preloads automatically. Opt-in now; the - // default in the next major. + // middleware wraps every loader in runWithFontdue so the staff preview token + // and a visitor's collection-unlock token reach fontdue-js fetches/preloads + // automatically. Opt-in now; the default in the next major. v8_middleware: true, }, } satisfies Config;