Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions app/lib/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
//
Expand Down
19 changes: 11 additions & 8 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/routes/api.preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Route } from "./+types/api.preview";
// by <FontdueProvider> — 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.
//
Expand Down
49 changes: 38 additions & 11 deletions app/routes/fonts.$slug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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";
Expand All @@ -25,26 +30,36 @@ 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<FontQuery, FontQueryVariables>("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<FontQuery, FontQueryVariables>("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) {
throw new Response("Not found", { status: 404 });
}

return {
locked: false as const,
collection,
typeTestersPreload,
characterViewerPreload,
Expand All @@ -53,6 +68,18 @@ export async function loader({ params }: Route.LoaderArgs) {
}

export default function FontDetail({ loaderData }: Route.ComponentProps) {
if (loaderData.locked) {
return (
<>
<h1 className="my-2 mb-4 text-5xl leading-[1.05]">Password required</h1>
<p className="mb-6 text-lg text-gray-700">
This collection is password-protected. Enter the password to view it.
</p>
<NodePasswordForm collectionSlug={loaderData.slug} />
</>
);
}

const {
collection,
typeTestersPreload,
Expand Down
6 changes: 3 additions & 3 deletions react-router.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;