Skip to content
Merged
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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"astro": "^6.1.8",
"fontdue-js": "3.0.6",
"fontdue-js": "3.1.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
Expand Down
9 changes: 5 additions & 4 deletions src/lib/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { createFontdueFetch, FontdueNotFoundError } from 'fontdue-js/server';
// error handling, so there's no transport boilerplate at the call sites.
//
// There's no per-request binding: because src/middleware.ts wraps every request
// in runWithPreview, 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
// in runWithFontdue, this fetcher automatically forwards the admin preview token
// when an admin is previewing (revealing 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 preview from the ambient context.
// variables and they pick up the ambient context.
//
// Use it at the top of a page's frontmatter:
//
Expand Down
33 changes: 20 additions & 13 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { defineMiddleware } from 'astro:middleware';
import { readPreviewToken } from 'fontdue-js/preview';
import { runWithPreview } from 'fontdue-js/preview/server';
import { runWithFontdue } from 'fontdue-js/server/middleware';

// Two responsibilities, both per request:
//
// 1. Preview. runWithPreview puts the logged-in admin's token (from the
// preview cookie) into an ambient context for the whole render, so every
// GraphQL fetch and fontdue-js preload reveals unpublished ("hidden") fonts
// with no per-page plumbing — and it forces preview responses out of the
// shared cache so an admin render is never served to the public. (This relies
// on middleware running in the same runtime as the render, which is the
// default. If you set `edgeMiddleware: true`, the context can't cross to the
// render — fall back to reading the token here and threading
// previewAuthHeaders(token) into fetches/preloads explicitly.)
// 1. Fontdue request context. runWithFontdue puts two request-scoped tokens
// into an ambient context for the whole render: the logged-in admin's preview
// token (reveals unpublished "hidden" fonts) and the visitor's per-collection
// node-access token (a collection they unlocked with a password). Every
// GraphQL fetch and fontdue-js preload forwards them with no per-page
// plumbing, and it forces a per-visitor response out of the shared cache so
// an admin's — or an unlocked visitor's — render is never served to the
// public. (runWithFontdue is runWithPreview composed with runWithNodeAccess;
// mount either alone if you only need one.) This relies on middleware running
// in the same runtime as the render, which is the default. If you set
// `edgeMiddleware: true`, the context can't cross to the render — fall back to
// reading the cookies here and threading previewAuthHeaders(token) /
// nodeAccessHeadersFromCookie(cookie) into fetches/preloads explicitly.
//
// 2. CDN caching for public pages. Netlify's edge serves the cached HTML
// instantly while regenerating in the background, so the page feels static
Expand All @@ -27,12 +31,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
const previewing =
readPreviewToken(context.request.headers.get('cookie')) != null;

const response = await runWithPreview(context.request, next);
const response = await runWithFontdue(context.request, next);

// Only public, non-preview HTML gets the long-lived CDN cache. Preview
// responses were already marked uncacheable by runWithPreview.
// Only public HTML gets the long-lived CDN cache. runWithFontdue already
// marked per-visitor responses (admin preview, or a collection this visitor
// unlocked via the node-access cookie) `no-store`; don't override that, or an
// unlocked render could be cached and served to someone who hasn't unlocked.
if (
previewing ||
response.headers.get('cache-control')?.includes('no-store') ||
response.status !== 200 ||
context.url.pathname.startsWith('/api/') ||
!response.headers.get('content-type')?.includes('text/html')
Expand Down
2 changes: 1 addition & 1 deletion src/pages/api/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { handlePreviewRequest } from 'fontdue-js/preview';
// admins 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);
// src/middleware.ts wraps each request in runWithPreview, which forwards the
// src/middleware.ts 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. The default path is /api/preview — to
// use another path, set config.preview.endpoint on <FontdueProvider> and
Expand Down
181 changes: 109 additions & 72 deletions src/pages/fonts/[slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import CharacterViewer, {
loadCharacterViewerQuery,
} from 'fontdue-js/CharacterViewer';
import BuyButton, { loadBuyButtonQuery } from 'fontdue-js/BuyButton';
import NodePasswordForm from 'fontdue-js/NodePasswordForm';
import { FontduePasswordProtectedError } from 'fontdue-js/server';
import { fetchGraphql } from '../../lib/graphql';
import FontDoc from '../../queries/Font.graphql?raw';
import type { FontQuery, FontQueryVariables } from '../../queries/operations-types';
Expand All @@ -18,24 +20,43 @@ if (!slug) return Astro.redirect('/');
// this page resolves even for an unpublished collection and its islands
// reveal unpublished styles — preview rides the ambient context, so nothing
// is threaded here (see src/lib/graphql.ts).
const [
fontData,
typeTestersPreload,
characterViewerPreload,
buyButtonPreload,
] = await Promise.all([
fetchGraphql<FontQuery, FontQueryVariables>('Font', FontDoc, { slug }),
loadTypeTestersQuery({ collectionSlug: slug }),
loadCharacterViewerQuery({ collectionSlug: slug }),
loadBuyButtonQuery({ collectionSlug: slug }),
]);

const collection = fontData.viewer.slug?.fontCollection;
if (!collection) {
//
// A password-protected collection the visitor hasn't unlocked throws
// FontduePasswordProtectedError — render the password form island instead of a
// 404. The node-access token the form stores then rides the ambient context
// (src/middleware.ts) on reload, just like preview, and the collection resolves.
let locked = false;
let collection: NonNullable<
FontQuery['viewer']['slug']
>['fontCollection'] = null;
let typeTestersPreload;
let characterViewerPreload;
let buyButtonPreload;

try {
const [fontData, tt, cv, bb] = await Promise.all([
fetchGraphql<FontQuery, FontQueryVariables>('Font', FontDoc, { slug }),
loadTypeTestersQuery({ collectionSlug: slug }),
loadCharacterViewerQuery({ collectionSlug: slug }),
loadBuyButtonQuery({ collectionSlug: slug }),
]);
collection = fontData.viewer.slug?.fontCollection ?? null;
typeTestersPreload = tt;
characterViewerPreload = cv;
buyButtonPreload = bb;
} catch (error) {
if (error instanceof FontduePasswordProtectedError) {
locked = true;
} else {
throw error;
}
}

if (!locked && !collection) {
return new Response('Not found', { status: 404 });
}

const feature = collection.featureStyle;
const feature = collection?.featureStyle;
const featureSrc = feature?.webfontSources?.find((s) => s?.format === 'woff2');
const featureFontFace = feature && featureSrc?.url
? `@font-face {
Expand All @@ -45,72 +66,88 @@ const featureFontFace = feature && featureSrc?.url
}`
: '';

const heroImage = collection.images?.find((img) => img && img.url);
const pageTitle = collection.pageMetadata?.title ?? collection.name;
const heroImage = collection?.images?.find((img) => img && img.url);
const pageTitle = locked
? 'Password required'
: (collection?.pageMetadata?.title ?? collection?.name);
---

<Layout title={pageTitle}>
{featureFontFace && <style is:inline set:html={featureFontFace} />}

<h1
class="font-detail__title"
style={feature ? `font-family: "${feature.cssFamily} ${feature.name}", system-ui, sans-serif;` : ''}
>
{collection.name}
{collection.collectionType === 'superfamily' && ' Collection'}
</h1>

{collection.shortDescription && (
<p class="font-detail__short">{collection.shortDescription}</p>
{locked && (
<>
<h1 class="font-detail__title">Password required</h1>
<p class="font-detail__short">
This collection is password-protected. Enter the password to view it.
</p>
<NodePasswordForm client:load collectionSlug={slug} />
</>
)}

{heroImage && (
<figure class="font-detail__hero">
{heroImage.meta?.mimeType === 'video/mp4' ? (
<video src={heroImage.url!} playsinline muted autoplay loop />
) : (
<img
src={heroImage.url!}
width={heroImage.meta?.width ?? undefined}
height={heroImage.meta?.height ?? undefined}
alt={heroImage.description ?? ''}
{collection && (
<>
{featureFontFace && <style is:inline set:html={featureFontFace} />}

<h1
class="font-detail__title"
style={feature ? `font-family: "${feature.cssFamily} ${feature.name}", system-ui, sans-serif;` : ''}
>
{collection.name}
{collection.collectionType === 'superfamily' && ' Collection'}
</h1>

{collection.shortDescription && (
<p class="font-detail__short">{collection.shortDescription}</p>
)}

{heroImage && (
<figure class="font-detail__hero">
{heroImage.meta?.mimeType === 'video/mp4' ? (
<video src={heroImage.url!} playsinline muted autoplay loop />
) : (
<img
src={heroImage.url!}
width={heroImage.meta?.width ?? undefined}
height={heroImage.meta?.height ?? undefined}
alt={heroImage.description ?? ''}
/>
)}
</figure>
)}

<div class="font-detail__buy">
<BuyButton
client:load
preloadedQuery={buyButtonPreload!}
collectionName={collection.name}
/>
{collection.minisiteLink && (
<a href={collection.minisiteLink} target="_blank" rel="noopener">
{collection.name} Minisite
</a>
)}
</div>

<h2>TypeTesters</h2>
<div class="tester">
<TypeTesters
client:load
preloadedQuery={typeTestersPreload!}
defaultMode="local"
/>
</div>

{collection.description && (
<section class="font-detail__description">
<Fragment set:html={collection.description} />
</section>
)}
</figure>
)}

<div class="font-detail__buy">
<BuyButton
client:load
preloadedQuery={buyButtonPreload}
collectionName={collection.name}
/>
{collection.minisiteLink && (
<a href={collection.minisiteLink} target="_blank" rel="noopener">
{collection.name} Minisite
</a>
)}
</div>

<h2>TypeTesters</h2>
<div class="tester">
<TypeTesters
client:load
preloadedQuery={typeTestersPreload}
defaultMode="local"
/>
</div>

{collection.description && (
<section class="font-detail__description">
<Fragment set:html={collection.description} />
</section>
<h2>Character viewer</h2>
<div class="tester">
<CharacterViewer client:load preloadedQuery={characterViewerPreload!} />
</div>
</>
)}

<h2>Character viewer</h2>
<div class="tester">
<CharacterViewer client:load preloadedQuery={characterViewerPreload} />
</div>
</Layout>

<style>
Expand Down
Loading