fontdue-js 3.1.0 → 3.2.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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## 3.2.0
2
+
3
+ - **Fixed password-protected collections crashing statically-rendered Next.js storefronts.** Locked slugs are excluded from `generateStaticParams`, so a visitor's first request for one triggers an on-demand static fill of the font page — and the unlock-cookie read during that fill crashed it with a 500 (digest `DYNAMIC_SERVER_USAGE`). A locked page's static fill now renders (and caches) the password form, and unlocking takes that visitor past the static cache instead — the same mechanism as admin preview:
4
+ - New route handler `fontdue-js/next/unlock`. Mount it in your app (`export { POST } from "fontdue-js/next/unlock"` in `app/api/unlock/route.ts`): on a successful unlock it enables Next draft mode, whose bypass cookie makes that one visitor's renders dynamic, so the unlock cookie is read and the collection resolves. Everyone else keeps the static pages.
5
+ - New `unlockEndpoint` prop on `NodePasswordForm` — the form POSTs the access token there before reloading. Pass `unlockEndpoint="/api/unlock"` on statically-rendered Next storefronts; omit it on frameworks that render per request (Astro, React Router 7, TanStack Start), where the cookie alone unlocks.
6
+ - `nodeAccessHeaders()` (`fontdue-js/next`) now reads the unlock cookie only during draft-mode renders, so a locked page's static fill is never flagged dynamic. See [Password-protected collections](https://www.fontdue.com/docs/develop/password-protected-collections).
7
+
1
8
  ## 3.1.0
2
9
 
3
10
  - **New `FeatureTesters` component** (`fontdue-js/FeatureTesters`) — renders a collection's *feature tester* cards, each showcasing an OpenType feature by toggling it on and off and tinting exactly the characters the feature changes. Pass `collectionId` or `collectionSlug`; in a React Server Component the server entry preloads its own query, and in a client tree (`preloadedQuery` or inside a `<FontdueProvider>`) it hydrates or lazy-loads like the other collection components. Collection-level props override the per-card dashboard settings: `highlightColor`, `toggle` (`'button'` or `'hover'`), and `autofit`. Requires a Fontdue backend that exposes `FontCollection.featureTesters` and the `FeatureTester` node. See [Feature Testers](https://www.fontdue.com/docs/reference/feature-testers).
@@ -48,17 +48,23 @@ vi.mock('next/navigation', () => ({
48
48
  const draft = vi.hoisted(() => ({
49
49
  enabled: false,
50
50
  token: undefined,
51
+ nodeAccess: undefined,
51
52
  cookiesError: undefined
52
53
  }));
53
54
  vi.mock('next/headers', () => ({
54
55
  draftMode: async () => ({
55
- isEnabled: draft.enabled
56
+ isEnabled: draft.enabled,
57
+ enable: () => {
58
+ draft.enabled = true;
59
+ }
56
60
  }),
57
61
  cookies: async () => {
58
62
  if (draft.cookiesError) throw draft.cookiesError;
59
63
  return {
60
64
  get: name => name === 'fontdue_preview_token' && draft.token ? {
61
65
  value: draft.token
66
+ } : name === 'fontdue_node_access' && draft.nodeAccess ? {
67
+ value: draft.nodeAccess
62
68
  } : undefined
63
69
  };
64
70
  }
@@ -74,6 +80,7 @@ beforeEach(() => {
74
80
  vi.restoreAllMocks();
75
81
  draft.enabled = false;
76
82
  draft.token = undefined;
83
+ draft.nodeAccess = undefined;
77
84
  draft.cookiesError = undefined;
78
85
  });
79
86
  function stubSingleTenant() {
@@ -360,6 +367,113 @@ describe('single-tenant ambient resolver (no per-render call)', () => {
360
367
  expect(config === null || config === void 0 ? void 0 : (_config$headers5 = config.headers) === null || _config$headers5 === void 0 ? void 0 : _config$headers5.authorization).toBeUndefined();
361
368
  });
362
369
  });
370
+ describe('nodeAccessHeaders', () => {
371
+ it('draft-mode bypass render: returns the forwarding header', async () => {
372
+ stubSingleTenant();
373
+ draft.enabled = true;
374
+ draft.nodeAccess = 'na-tok';
375
+ const {
376
+ nodeAccessHeaders
377
+ } = await importTenant();
378
+ expect(await nodeAccessHeaders()).toEqual({
379
+ 'fontdue-node-access': 'na-tok'
380
+ });
381
+ });
382
+ it('draft-mode bypass render without the cookie: returns {}', async () => {
383
+ stubSingleTenant();
384
+ draft.enabled = true;
385
+ const {
386
+ nodeAccessHeaders
387
+ } = await importTenant();
388
+ expect(await nodeAccessHeaders()).toEqual({});
389
+ });
390
+
391
+ // Regression for FD-773: locked slugs are excluded from generateStaticParams,
392
+ // so a visitor's first request is an on-demand STATIC fill of the SSG route.
393
+ // Merely calling cookies() there flags the render as dynamic and Next 15
394
+ // 500s ("Page changed from static to dynamic at runtime") — catching the
395
+ // throw doesn't unpoison it. Off draft mode the hook must return "no token"
396
+ // WITHOUT touching cookies(), so the fill can prerender the password form.
397
+ // cookiesError guards the "without touching" half: any cookies() call would
398
+ // reject.
399
+ it('runtime static fill (no draft mode): no token, and cookies() is never called', async () => {
400
+ stubSingleTenant();
401
+ draft.cookiesError = Object.assign(new Error('Dynamic server usage'), {
402
+ digest: 'DYNAMIC_SERVER_USAGE'
403
+ });
404
+ const {
405
+ nodeAccessHeaders
406
+ } = await importTenant();
407
+ expect(await nodeAccessHeaders()).toEqual({});
408
+ });
409
+
410
+ // At build time the gate is skipped and the bailout keeps propagating: a
411
+ // static route whose own fetch touches a locked collection bails out to
412
+ // dynamic at build today, and the fix must not change build classification.
413
+ it('build-time prerender: still propagates the dynamic bailout', async () => {
414
+ stubSingleTenant();
415
+ vi.stubEnv('NEXT_PHASE', 'phase-production-build');
416
+ draft.cookiesError = Object.assign(new Error('Dynamic server usage'), {
417
+ digest: 'DYNAMIC_SERVER_USAGE'
418
+ });
419
+ const {
420
+ nodeAccessHeaders
421
+ } = await importTenant();
422
+ await expect(nodeAccessHeaders()).rejects.toBe(draft.cookiesError);
423
+ });
424
+ it('swallows a non-control-flow throw (no request scope)', async () => {
425
+ stubSingleTenant();
426
+ draft.enabled = true;
427
+ draft.cookiesError = new Error('no request scope');
428
+ const {
429
+ nodeAccessHeaders
430
+ } = await importTenant();
431
+ expect(await nodeAccessHeaders()).toEqual({});
432
+ });
433
+ });
434
+ describe('unlock route handler', () => {
435
+ async function importUnlock() {
436
+ return await import("../next/unlock.js");
437
+ }
438
+ function unlockRequest(body) {
439
+ return new Request('https://storefront.example/api/unlock', {
440
+ method: 'POST',
441
+ headers: {
442
+ 'content-type': 'application/json'
443
+ },
444
+ body
445
+ });
446
+ }
447
+ it('POST with a token enables the draft-mode bypass', async () => {
448
+ const {
449
+ POST
450
+ } = await importUnlock();
451
+ const response = await POST(unlockRequest(JSON.stringify({
452
+ token: 'na-tok'
453
+ })));
454
+ expect(response.status).toBe(200);
455
+ expect(await response.json()).toEqual({
456
+ ok: true
457
+ });
458
+ expect(draft.enabled).toBe(true);
459
+ });
460
+ it('rejects a missing token without enabling the bypass', async () => {
461
+ const {
462
+ POST
463
+ } = await importUnlock();
464
+ const response = await POST(unlockRequest(JSON.stringify({})));
465
+ expect(response.status).toBe(400);
466
+ expect(draft.enabled).toBe(false);
467
+ });
468
+ it('rejects a malformed body without enabling the bypass', async () => {
469
+ const {
470
+ POST
471
+ } = await importUnlock();
472
+ const response = await POST(unlockRequest('not json'));
473
+ expect(response.status).toBe(400);
474
+ expect(draft.enabled).toBe(false);
475
+ });
476
+ });
363
477
 
364
478
  // withFontdue detects the route-tree shape from the working directory; give
365
479
  // it one with or without src/app/[domain].
@@ -2,6 +2,17 @@ import React from 'react';
2
2
  export interface NodePasswordForm_props {
3
3
  collectionId?: string | null;
4
4
  collectionSlug?: string | null;
5
+ /**
6
+ * Path of the storefront's unlock route (see fontdue-js/next/unlock), e.g.
7
+ * "/api/unlock". On a successful unlock the form POSTs `{ token }` there
8
+ * before reloading. Required on statically-rendered Next storefronts: the
9
+ * route enables the draft-mode bypass that takes this visitor off the
10
+ * full-route cache, so the reload can actually render the unlocked
11
+ * collection instead of re-serving the cached password form. Omit on
12
+ * frameworks that render server responses per request (Astro, React Router,
13
+ * TanStack Start) — there the cookie alone is enough.
14
+ */
15
+ unlockEndpoint?: string | null;
5
16
  }
6
17
  /**
7
18
  * Password form for a collection you already know is locked. Render it when a
@@ -25,6 +25,29 @@ const accessNodeMutation = (_NodePasswordFormAccessNodeMutation.hash && _NodePas
25
25
  export default function NodePasswordForm(props) {
26
26
  return /*#__PURE__*/React.createElement(EnsureFontdueContext, null, /*#__PURE__*/React.createElement(NodePasswordFormFields, props));
27
27
  }
28
+
29
+ // Tell the storefront's unlock route (fontdue-js/next/unlock) about a
30
+ // successful unlock so it can enable Next's draft-mode bypass — without it, a
31
+ // statically-cached font page would keep serving this visitor the password
32
+ // form after they unlocked (the full-route cache is visitor-blind). Always
33
+ // resolves: the reload must happen even if the endpoint is absent or errors,
34
+ // because on per-request-rendered frameworks the cookie alone unlocks.
35
+ async function notifyUnlockEndpoint(endpoint, token) {
36
+ if (!endpoint || !token) return;
37
+ try {
38
+ await fetch(endpoint, {
39
+ method: 'POST',
40
+ headers: {
41
+ 'content-type': 'application/json'
42
+ },
43
+ body: JSON.stringify({
44
+ token
45
+ })
46
+ });
47
+ } catch {
48
+ // Ignore: reload regardless, the cookie may still be enough.
49
+ }
50
+ }
28
51
  function NodePasswordFormFields(props) {
29
52
  const [password, setPassword] = useState('');
30
53
  const [error, setError] = useState(null);
@@ -49,7 +72,7 @@ function NodePasswordFormFields(props) {
49
72
  // ride a proxied or cross-origin server fetch, so we forward this
50
73
  // token as a header instead (see fontdue-js nodeAccess).
51
74
  rememberNodeAccessToken(res.accessNode.token);
52
- location.reload();
75
+ notifyUnlockEndpoint(props.unlockEndpoint, res.accessNode.token).then(() => location.reload());
53
76
  } else {
54
77
  setError('Incorrect password');
55
78
  }
@@ -1,6 +1,7 @@
1
1
  // Server-side entrypoint for Next.js apps (the App Router / RSC adapter).
2
- // The config-time wrapper lives in 'fontdue-js/next/config' and the deploy
3
- // hook route handler in 'fontdue-js/next/revalidate'.
2
+ // The config-time wrapper lives in 'fontdue-js/next/config', the deploy
3
+ // hook route handler in 'fontdue-js/next/revalidate', and the
4
+ // password-unlock route handler in 'fontdue-js/next/unlock'.
4
5
 
5
6
  // Single-tenant apps need NO per-render setup and nothing from this entrypoint:
6
7
  // mounting <FontdueProvider> registers the ambient resolver that configures
@@ -163,13 +163,41 @@ export async function __prepareFontdueRender(props) {
163
163
  //
164
164
  // Reading cookies() opts the route into dynamic rendering, so call this only
165
165
  // when it's actually needed — after a public fetch comes back
166
- // password-protected — to keep non-protected font pages static/cacheable. The
167
- // catch mirrors readPreviewHeaders: re-throw Next's control-flow bailout so a
168
- // protected page renders dynamically instead of being prerendered cookie-blind
169
- // (which would serve the locked form to a visitor who has unlocked it).
166
+ // password-protected — to keep non-protected font pages static/cacheable.
167
+ //
168
+ // The draft-mode gate is where the caching architecture for locked
169
+ // collections lives, so spelling it out: locked slugs are excluded from
170
+ // generateStaticParams, so a visitor's first request for one is an on-demand
171
+ // STATIC fill of an SSG route. Merely CALLING cookies() there flags the
172
+ // render as dynamic, and Next 15 cannot take a runtime fill dynamic the way a
173
+ // build-time pass can — it 500s ("Page changed from static to dynamic at
174
+ // runtime", or DYNAMIC_SERVER_USAGE; FD-773). Catching the throw doesn't
175
+ // help: the render is already poisoned by the call. So the cookie must not be
176
+ // read at all unless this render is a draft-mode one — draftMode().isEnabled
177
+ // is static-safe (readPreviewHeaders below leans on the same property), false
178
+ // during any static pass.
179
+ //
180
+ // Gating on draft mode is not just crash avoidance, it IS the design: with
181
+ // the gate, a locked slug's static fill renders (and caches) the password
182
+ // form — the correct public view. The full-route cache is visitor-blind, so
183
+ // no cookie could ever change what it serves anyway; the only way a visitor
184
+ // sees unlocked content is off the static cache entirely. That's what the
185
+ // unlock route handler (see ./unlock) provides: on a successful unlock the
186
+ // form POSTs there, draft mode's bypass cookie takes this visitor's requests
187
+ // to dynamic renders, the gate opens, and the token folds in here. (Exactly
188
+ // the admin-preview model, with the password standing in for the admin
189
+ // session.)
190
+ //
191
+ // At build time (NEXT_PHASE=phase-production-build) the gate is skipped and
192
+ // the cookies() read trips Next's dynamic bailout, which unstable_rethrow
193
+ // propagates: a build-time render whose own fetch touches a locked collection
194
+ // bails that route out to dynamic today, and baking a cookie-blind form into
195
+ // such a page is a behavior change we don't need for this fix. Non-bailout
196
+ // throws (no request scope at all) still fall through to "no token".
170
197
  export async function nodeAccessHeaders() {
171
198
  try {
172
199
  var _await$cookies$get;
200
+ if (!isBuildPhase() && !(await draftMode()).isEnabled) return {};
173
201
  const value = (_await$cookies$get = (await cookies()).get(NODE_ACCESS_COOKIE)) === null || _await$cookies$get === void 0 ? void 0 : _await$cookies$get.value;
174
202
  return value ? {
175
203
  [NODE_ACCESS_HEADER]: value
@@ -179,6 +207,9 @@ export async function nodeAccessHeaders() {
179
207
  return {};
180
208
  }
181
209
  }
210
+ function isBuildPhase() {
211
+ return typeof process !== 'undefined' && process.env.NEXT_PHASE === 'phase-production-build';
212
+ }
182
213
 
183
214
  // The admin preview token for this render, as Authorization headers, or
184
215
  // undefined when not previewing. Draft mode gates it: reading draftMode().
@@ -0,0 +1,3 @@
1
+ /** Default path the templates mount the handler at. */
2
+ export declare const UNLOCK_ENDPOINT = "/api/unlock";
3
+ export declare function POST(request: Request): Promise<Response>;
@@ -0,0 +1,49 @@
1
+ // Route handler that lets a visitor who unlocked a password-protected
2
+ // collection actually see it on a statically-served Next storefront.
3
+ // Re-export it from app/api/unlock/route.ts:
4
+ //
5
+ // export { POST } from 'fontdue-js/next/unlock';
6
+ //
7
+ // and pass the route's path to the form: <NodePasswordForm
8
+ // unlockEndpoint="/api/unlock" …/>. On a successful unlock the form POSTs the
9
+ // access token here before reloading.
10
+ //
11
+ // Why this exists (FD-773): font pages are SSG, and Next's full-route cache is
12
+ // visitor-blind — once a locked collection's page is (correctly) cached as the
13
+ // password form, no cookie the form sets can change what the cache serves.
14
+ // Enabling Next draft mode sets the __prerender_bypass cookie, which is the
15
+ // one sanctioned way to take a single visitor off the full-route cache: their
16
+ // subsequent requests render dynamically, nodeAccessHeaders() (fontdue-js/next)
17
+ // reads the node-access cookie, and the collection resolves. Everyone else
18
+ // keeps getting the static pages. This mirrors the admin-preview route
19
+ // (/api/preview in the templates), which layers the same draft-mode bypass on
20
+ // the preview cookie contract — including its trust model: the token is opaque
21
+ // here and validated by the Fontdue GraphQL server on every request, so a
22
+ // forged POST reveals nothing and buys the caller only their own dynamic
23
+ // renders, exactly like a forged preview POST.
24
+ //
25
+ // The bypass cookie is a session cookie, while the node-access cookie lasts ~30
26
+ // days. A returning visitor whose bypass lapsed sees the static form again and
27
+ // re-enters the password; re-unlocking re-enables the bypass. There is no
28
+ // DELETE: draft mode is shared with admin preview, so an unlock exit must not
29
+ // tear down an active preview — the bypass simply expires with the session.
30
+
31
+ import { draftMode } from 'next/headers';
32
+
33
+ /** Default path the templates mount the handler at. */
34
+ export const UNLOCK_ENDPOINT = '/api/unlock';
35
+ export async function POST(request) {
36
+ const body = await request.json().catch(() => ({}));
37
+ if (typeof body.token !== 'string' || body.token === '') {
38
+ return Response.json({
39
+ ok: false,
40
+ error: 'Missing access token'
41
+ }, {
42
+ status: 400
43
+ });
44
+ }
45
+ (await draftMode()).enable();
46
+ return Response.json({
47
+ ok: true
48
+ });
49
+ }
@@ -8,7 +8,7 @@ import { NODE_ACCESS_HEADER } from '../nodeAccess.js';
8
8
  // (defineVersionPlugin in .babelrc.cjs) with the literal package.json#version.
9
9
  // Exported so UI (the admin toolbar) can surface it without re-reading the
10
10
  // build-time global in a 'use client' module.
11
- export const version = "3.1.0";
11
+ export const version = "3.2.0";
12
12
  const IS_SERVER = typeof window === typeof undefined;
13
13
 
14
14
  // Opt server fetches into Next's data cache only in production; dev stays
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fontdue-js",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "npm run relay && run-p build-js build-css build-ts",
@@ -90,6 +90,7 @@
90
90
  "./next": "./dist/next/index.js",
91
91
  "./next/config": "./dist/next/config.js",
92
92
  "./next/revalidate": "./dist/next/revalidate.js",
93
+ "./next/unlock": "./dist/next/unlock.js",
93
94
  "./next/image-loader": "./dist/next/image-loader.js",
94
95
  "./fontdue.css": "./dist/fontdue.css",
95
96
  "./BuyButton": {
@@ -2,7 +2,11 @@
2
2
  // installed (it's the host app's dependency; this package only ever runs the
3
3
  // import inside a Next.js server).
4
4
  declare module 'next/headers' {
5
- export function draftMode(): Promise<{ isEnabled: boolean }>;
5
+ export function draftMode(): Promise<{
6
+ isEnabled: boolean;
7
+ enable(): void;
8
+ disable(): void;
9
+ }>;
6
10
  export function cookies(): Promise<{
7
11
  get(name: string): { value: string } | undefined;
8
12
  }>;