fontdue-js 3.0.6 → 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.
Files changed (65) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/__generated__/FeatureTesterCard_fontStyle.graphql.d.ts +23 -0
  3. package/dist/__generated__/FeatureTesterCard_fontStyle.graphql.js +62 -0
  4. package/dist/__generated__/FeatureTesterStandaloneQuery.graphql.d.ts +25 -0
  5. package/dist/__generated__/FeatureTesterStandaloneQuery.graphql.js +240 -0
  6. package/dist/__generated__/FeatureTestersIdQuery.graphql.d.ts +21 -0
  7. package/dist/__generated__/FeatureTestersIdQuery.graphql.js +344 -0
  8. package/dist/__generated__/FeatureTestersSlugQuery.graphql.d.ts +25 -0
  9. package/dist/__generated__/FeatureTestersSlugQuery.graphql.js +364 -0
  10. package/dist/__generated__/FeatureTesters_collection.graphql.d.ts +39 -0
  11. package/dist/__generated__/FeatureTesters_collection.graphql.js +145 -0
  12. package/dist/__generated__/NodePasswordFormAccessNodeMutation.graphql.d.ts +2 -1
  13. package/dist/__generated__/NodePasswordFormAccessNodeMutation.graphql.js +10 -4
  14. package/dist/__generated__/NodePasswordFormElementIDQuery.graphql.d.ts +21 -0
  15. package/dist/__generated__/NodePasswordFormElementIDQuery.graphql.js +86 -0
  16. package/dist/__generated__/NodePasswordFormElementSlugQuery.graphql.d.ts +25 -0
  17. package/dist/__generated__/NodePasswordFormElementSlugQuery.graphql.js +108 -0
  18. package/dist/__tests__/createFontdueFetch.test.js +128 -4
  19. package/dist/__tests__/highlight.test.js +141 -0
  20. package/dist/__tests__/middleware.test.js +132 -0
  21. package/dist/__tests__/nextAdapter.test.js +115 -1
  22. package/dist/components/FeatureTester/FeatureTesterCard.d.ts +30 -0
  23. package/dist/components/FeatureTester/FeatureTesterCard.js +167 -0
  24. package/dist/components/FeatureTester/FeatureTesterElement.d.ts +19 -0
  25. package/dist/components/FeatureTester/FeatureTesterElement.js +42 -0
  26. package/dist/components/FeatureTester/FeatureTesters.d.ts +43 -0
  27. package/dist/components/FeatureTester/FeatureTesters.js +156 -0
  28. package/dist/components/FeatureTester/FeatureTesters.server.d.ts +9 -0
  29. package/dist/components/FeatureTester/FeatureTesters.server.js +40 -0
  30. package/dist/components/FeatureTester/FeatureTestersElement.d.ts +10 -0
  31. package/dist/components/FeatureTester/FeatureTestersElement.js +27 -0
  32. package/dist/components/FeatureTester/highlight.d.ts +19 -0
  33. package/dist/components/FeatureTester/highlight.js +71 -0
  34. package/dist/components/FeatureTester/index.d.ts +26 -0
  35. package/dist/components/FeatureTester/index.js +66 -0
  36. package/dist/components/FeatureTester/index.server.d.ts +7 -0
  37. package/dist/components/FeatureTester/index.server.js +30 -0
  38. package/dist/components/NodePasswordForm/NodePasswordFormElement.d.ts +3 -0
  39. package/dist/components/NodePasswordForm/NodePasswordFormElement.js +65 -0
  40. package/dist/components/NodePasswordForm/index.d.ts +27 -1
  41. package/dist/components/NodePasswordForm/index.js +54 -60
  42. package/dist/components/Root/index.js +2 -1
  43. package/dist/fontdue.css +145 -0
  44. package/dist/hooks/useFeatureTesterAutofit.d.ts +17 -0
  45. package/dist/hooks/useFeatureTesterAutofit.js +106 -0
  46. package/dist/next/index.d.ts +2 -0
  47. package/dist/next/index.js +19 -3
  48. package/dist/next/registerSingleTenantResolver.js +13 -1
  49. package/dist/next/tenant.d.ts +1 -0
  50. package/dist/next/tenant.js +58 -2
  51. package/dist/next/unlock.d.ts +3 -0
  52. package/dist/next/unlock.js +49 -0
  53. package/dist/nodeAccess.d.ts +18 -0
  54. package/dist/nodeAccess.js +84 -0
  55. package/dist/preview/server.d.ts +1 -20
  56. package/dist/preview/server.js +16 -85
  57. package/dist/relay/environment.js +8 -2
  58. package/dist/relay/serverConfig.d.ts +3 -0
  59. package/dist/relay/serverConfig.js +22 -1
  60. package/dist/server/index.d.ts +8 -0
  61. package/dist/server/index.js +90 -7
  62. package/dist/server/middleware.d.ts +37 -0
  63. package/dist/server/middleware.js +144 -0
  64. package/package.json +9 -2
  65. package/types/next-headers.d.ts +5 -1
@@ -0,0 +1,17 @@
1
+ export interface UseFeatureTesterAutofitProps {
2
+ enabled: boolean;
3
+ text: string;
4
+ fontFamily: string | null;
5
+ feature: string;
6
+ fontSize: number;
7
+ min?: number;
8
+ max?: number;
9
+ letterSpacing?: number;
10
+ fontVariationSettings?: string;
11
+ padding?: number;
12
+ }
13
+ export interface UseFeatureTesterAutofitResult {
14
+ ref: React.RefObject<HTMLDivElement | null>;
15
+ fontSize: number;
16
+ }
17
+ export default function useFeatureTesterAutofit({ enabled, text, fontFamily, feature, fontSize, min, max, letterSpacing, fontVariationSettings, padding, }: UseFeatureTesterAutofitProps): UseFeatureTesterAutofitResult;
@@ -0,0 +1,106 @@
1
+ import { useRef, useState, useLayoutEffect } from 'react';
2
+ import useResizeObserver from './useResizeObserver.js';
3
+
4
+ // Autofit for the Feature Tester. Unlike the plain type-tester autofit, the
5
+ // rendered width depends on the toggle state — an alternate glyph is wider or
6
+ // narrower, a ligature collapses, a fraction shrinks. So we measure the sample
7
+ // in BOTH states (feature off and on) and size to fit the WIDER one. The size
8
+ // is then fixed across toggles, so the tester never reflows or overflows when
9
+ // the feature is switched.
10
+ //
11
+ // Measurement must use the DOM (not canvas): canvas `measureText` can't apply
12
+ // `font-feature-settings`, which is exactly the thing that changes the width.
13
+ // Measuring the whole string with the feature applied is exact here, not an
14
+ // over-estimate: a character the feature doesn't touch renders identically
15
+ // whether the feature is on or off, so the two measurements are precisely the
16
+ // tester's two real states.
17
+
18
+ const REF_SIZE = 100;
19
+ let measureEl = null;
20
+ function getMeasureElement() {
21
+ if (!measureEl) {
22
+ measureEl = document.createElement('span');
23
+ measureEl.style.position = 'absolute';
24
+ measureEl.style.top = '-9999px';
25
+ measureEl.style.left = '-9999px';
26
+ measureEl.style.whiteSpace = 'pre';
27
+ measureEl.style.visibility = 'hidden';
28
+ document.body.appendChild(measureEl);
29
+ }
30
+ return measureEl;
31
+ }
32
+ function measure(text, fontFamily, letterSpacing, fontFeatureSettings, fontVariationSettings) {
33
+ const el = getMeasureElement();
34
+ el.style.fontFamily = fontFamily;
35
+ el.style.fontSize = `${REF_SIZE}px`;
36
+ el.style.letterSpacing = `${letterSpacing}em`;
37
+ el.style.fontFeatureSettings = fontFeatureSettings;
38
+ el.style.fontVariationSettings = fontVariationSettings;
39
+ el.textContent = text;
40
+ return el.getBoundingClientRect().width;
41
+ }
42
+ export default function useFeatureTesterAutofit(_ref) {
43
+ let {
44
+ enabled,
45
+ text,
46
+ fontFamily,
47
+ feature,
48
+ fontSize,
49
+ min = 8,
50
+ max = 999,
51
+ letterSpacing = 0,
52
+ fontVariationSettings = '',
53
+ padding = 0
54
+ } = _ref;
55
+ const ref = useRef(null);
56
+ const [size, setSize] = useState(fontSize);
57
+ const [containerWidth, setContainerWidth] = useState(0);
58
+ const [fontVersion, setFontVersion] = useState(0);
59
+ useResizeObserver(ref, entry => setContainerWidth(entry.contentRect.width));
60
+
61
+ // Re-measure once the webfont is available (measuring the fallback would size
62
+ // to the wrong metrics). The font is often already cached — other cards on the
63
+ // page load the same family — so `document.fonts.load` can resolve in a
64
+ // microtask *before* a freshly-measured element reflects the loaded font in
65
+ // layout. Re-measuring on the next animation frame, when layout is font-ready,
66
+ // avoids sizing to a stale fallback measurement that then never gets corrected.
67
+ // Re-measure until the sizing lands on the real webfont metrics. A fresh DOM
68
+ // measurement can lag the font becoming available — the family is often
69
+ // already cached (other cards on the page load it), so `fonts.load`/
70
+ // `fonts.ready` can settle a frame or two before a measured element actually
71
+ // reflects it. Sizing to that stale fallback measurement would then stick.
72
+ // Re-measuring across a short schedule lets the size settle onto the real
73
+ // metrics whenever they land; the extra `fonts.ready` bump covers slow loads
74
+ // beyond the schedule.
75
+ useLayoutEffect(() => {
76
+ if (!enabled || !fontFamily) return;
77
+ let cancelled = false;
78
+ const bump = () => {
79
+ if (!cancelled) setFontVersion(v => v + 1);
80
+ };
81
+ const timers = [0, 80, 200, 500, 1000].map(ms => window.setTimeout(bump, ms));
82
+ document.fonts.ready.then(bump);
83
+ return () => {
84
+ cancelled = true;
85
+ timers.forEach(clearTimeout);
86
+ };
87
+ }, [enabled, fontFamily]);
88
+ useLayoutEffect(() => {
89
+ if (!enabled || !fontFamily) return;
90
+ const available = containerWidth - padding;
91
+ if (available <= 0 || !text) return;
92
+ const off = `"${feature}" 0`;
93
+ const on = `"${feature}" 1`;
94
+ // Widest line across both states — samples can carry authored newlines.
95
+ const refWidth = Math.max(...text.split('\n').flatMap(line => [measure(line, fontFamily, letterSpacing, off, fontVariationSettings), measure(line, fontFamily, letterSpacing, on, fontVariationSettings)]));
96
+ if (refWidth <= 0) return;
97
+
98
+ // Width scales linearly with font-size; floor to avoid a sub-pixel wrap.
99
+ const next = Math.min(Math.max(min, Math.floor(available / (refWidth / REF_SIZE))), max);
100
+ setSize(next);
101
+ }, [enabled, text, fontFamily, feature, letterSpacing, fontVariationSettings, containerWidth, min, max, padding, fontVersion]);
102
+ return {
103
+ ref,
104
+ fontSize: enabled ? size : fontSize
105
+ };
106
+ }
@@ -1 +1,3 @@
1
1
  export { __prepareFontdueRender, type FontdueEndpoint } from './tenant.js';
2
+ import { nodeAccessHeaders } from './tenant.js';
3
+ export { nodeAccessHeaders };
@@ -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
@@ -15,4 +16,19 @@
15
16
  // configureFontdueRender, setFontdueServerConfig) are deliberately not
16
17
  // re-exported here; the modules that need them import them directly from
17
18
  // './tenant.js' / '../relay/serverConfig.js'.
18
- export { __prepareFontdueRender } from './tenant.js';
19
+ export { __prepareFontdueRender } from './tenant.js';
20
+ import { registerNodeAccessRecovery } from '../relay/serverConfig.js';
21
+ import { nodeAccessHeaders } from './tenant.js';
22
+
23
+ // Wire up automatic node-access recovery: when a server fetch comes back
24
+ // `password_protected`, createFontdueFetch calls this to read the visitor's
25
+ // node-access cookie and (if they've unlocked the collection) fold the token
26
+ // into the per-render config + retry — so the page fetch and the embedded server
27
+ // components' preloads resolve, with no per-fetch plumbing in the app. Importing
28
+ // this entry (the next-template does, via __prepareFontdueRender) registers it.
29
+ registerNodeAccessRecovery(() => nodeAccessHeaders());
30
+
31
+ // Escape hatch (rarely needed now recovery is automatic): the visitor's
32
+ // per-collection node-access header for this request, or {} when none. See the
33
+ // password-protected-collections guide.
34
+ export { nodeAccessHeaders };
@@ -15,7 +15,19 @@
15
15
  // stays safe for any non-Next RSC consumer. (Today only Next resolves the
16
16
  // react-server export condition, but the decoupling makes that a structural
17
17
  // invariant rather than an incidental one.)
18
- import { registerAmbientConfigResolver } from '../relay/serverConfig.js';
18
+ import { registerAmbientConfigResolver, registerNodeAccessRecovery } from '../relay/serverConfig.js';
19
+
20
+ // Automatic node-access recovery for single-tenant Next apps (the same hook the
21
+ // multi-tenant entry registers). createFontdueFetch calls this when a fetch
22
+ // comes back `password_protected`; it reads the visitor's node-access cookie via
23
+ // Next's request APIs. Lazy-imported, for the same reason as the resolver below:
24
+ // keep next/headers off this module's static graph.
25
+ registerNodeAccessRecovery(async () => {
26
+ const {
27
+ nodeAccessHeaders
28
+ } = await import('./tenant.js');
29
+ return nodeAccessHeaders();
30
+ });
19
31
  registerAmbientConfigResolver(async () => {
20
32
  // Loaded lazily so next/headers + next/navigation never enter the
21
33
  // provider's static graph; config is read per fetch from Next's request
@@ -23,4 +23,5 @@ interface RenderProps {
23
23
  }
24
24
  export declare function buildRenderConfig(domain: string): Promise<FontdueServerConfig>;
25
25
  export declare function __prepareFontdueRender(props: RenderProps): Promise<FontdueEndpoint>;
26
+ export declare function nodeAccessHeaders(): Promise<Record<string, string>>;
26
27
  export {};
@@ -29,6 +29,7 @@ import { notFound, unstable_rethrow } from 'next/navigation';
29
29
  import { cookies, draftMode } from 'next/headers';
30
30
  import { setFontdueServerConfig } from '../relay/serverConfig.js';
31
31
  import { PREVIEW_TOKEN_COOKIE, previewAuthHeaders } from '../preview/index.js';
32
+ import { NODE_ACCESS_COOKIE, NODE_ACCESS_HEADER } from '../nodeAccess.js';
32
33
  export const isMultiTenant = process.env.FONTDUE_MULTI_TENANT === '1';
33
34
  export const singleTenantUrl = process.env.NEXT_PUBLIC_FONTDUE_URL;
34
35
  const internalOrigin = process.env.FONTDUE_ORIGIN;
@@ -155,6 +156,61 @@ export async function __prepareFontdueRender(props) {
155
156
  return endpointForDomain(domain);
156
157
  }
157
158
 
159
+ // The visitor's per-collection node-access header for the current request, or {}
160
+ // when they haven't unlocked anything. Reads the first-party cookie the password
161
+ // form set and returns it as `{ 'fontdue-node-access': <tokens> }`, ready to
162
+ // spread into a GraphQL fetch's headers so the unlocked collection resolves.
163
+ //
164
+ // Reading cookies() opts the route into dynamic rendering, so call this only
165
+ // when it's actually needed — after a public fetch comes back
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".
197
+ export async function nodeAccessHeaders() {
198
+ try {
199
+ var _await$cookies$get;
200
+ if (!isBuildPhase() && !(await draftMode()).isEnabled) return {};
201
+ const value = (_await$cookies$get = (await cookies()).get(NODE_ACCESS_COOKIE)) === null || _await$cookies$get === void 0 ? void 0 : _await$cookies$get.value;
202
+ return value ? {
203
+ [NODE_ACCESS_HEADER]: value
204
+ } : {};
205
+ } catch (error) {
206
+ unstable_rethrow(error);
207
+ return {};
208
+ }
209
+ }
210
+ function isBuildPhase() {
211
+ return typeof process !== 'undefined' && process.env.NEXT_PHASE === 'phase-production-build';
212
+ }
213
+
158
214
  // The admin preview token for this render, as Authorization headers, or
159
215
  // undefined when not previewing. Draft mode gates it: reading draftMode().
160
216
  // isEnabled is static-safe (it returns false during static generation and only
@@ -182,9 +238,9 @@ export async function __prepareFontdueRender(props) {
182
238
  // so unstable_rethrow is a no-op and we fall through to "not previewing".
183
239
  async function readPreviewHeaders() {
184
240
  try {
185
- var _await$cookies$get;
241
+ var _await$cookies$get2;
186
242
  if (!(await draftMode()).isEnabled) return undefined;
187
- const token = (_await$cookies$get = (await cookies()).get(PREVIEW_TOKEN_COOKIE)) === null || _await$cookies$get === void 0 ? void 0 : _await$cookies$get.value;
243
+ const token = (_await$cookies$get2 = (await cookies()).get(PREVIEW_TOKEN_COOKIE)) === null || _await$cookies$get2 === void 0 ? void 0 : _await$cookies$get2.value;
188
244
  return token ? previewAuthHeaders(token) : undefined;
189
245
  } catch (error) {
190
246
  unstable_rethrow(error);
@@ -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
+ }
@@ -0,0 +1,18 @@
1
+ export declare const NODE_ACCESS_COOKIE = "fontdue_node_access";
2
+ export declare const NODE_ACCESS_HEADER = "fontdue-node-access";
3
+ /** The node-access cookie value from a Cookie header string, or undefined. */
4
+ export declare function readNodeAccessCookie(cookieHeader: string | null | undefined): string | undefined;
5
+ /**
6
+ * The node-access forwarding header for a Cookie header string, or {} when the
7
+ * cookie is absent. Spread into a server-side GraphQL fetch's headers so a
8
+ * collection the visitor unlocked resolves for them. Returns {} for public
9
+ * (no-cookie) requests, leaving them untouched and cacheable.
10
+ */
11
+ export declare function nodeAccessHeadersFromCookie(cookieHeader: string | null | undefined): Record<string, string>;
12
+ /**
13
+ * Remember a freshly minted access token in the browser's node-access cookie,
14
+ * preserving any collections already unlocked. First-party and JS-readable on
15
+ * purpose (see the module note); SameSite=Lax so it rides top-level navigations,
16
+ * Secure only on https so it still persists in local dev. No-op off the browser.
17
+ */
18
+ export declare function rememberNodeAccessToken(token: string | null | undefined): void;
@@ -0,0 +1,84 @@
1
+ // Per-collection node-access contract.
2
+ //
3
+ // When a visitor unlocks a password-protected collection (the `accessNode`
4
+ // mutation), the server returns a signed token. The browser remembers it in a
5
+ // first-party cookie, and a server-rendered storefront forwards that cookie to
6
+ // GraphQL as a request header so the collection keeps resolving for this visitor.
7
+ //
8
+ // This mirrors the admin-preview token contract (see ./preview/constants), with
9
+ // one deliberate difference: the node-access token is low sensitivity — it only
10
+ // re-grants access the visitor already earned by entering the password — so it is
11
+ // a plain, JS-readable first-party cookie the form sets directly in the browser,
12
+ // not an httpOnly cookie brokered through a server route. That keeps the
13
+ // template surface tiny: no `/api/...` route to mount, just the form plus the
14
+ // transport every framework already runs for preview.
15
+ //
16
+ // Kept free of Web/Node APIs (only `document`, behind a guard) so it can be
17
+ // imported from both server transport and the client component.
18
+
19
+ // First-party cookie the browser carries after unlocking. Value is a
20
+ // comma-separated list of signed tokens, one per unlocked collection.
21
+ export const NODE_ACCESS_COOKIE = 'fontdue_node_access';
22
+
23
+ // Request header a server-rendered storefront forwards the cookie value as, read
24
+ // back by the Fontdue GraphQL server (see FontageWeb.Schema.Context).
25
+ export const NODE_ACCESS_HEADER = 'fontdue-node-access';
26
+
27
+ // ~30 days, matching the token's `max_age` on the server (Fontage.Token
28
+ // `:node_access`). The cookie and the token expire together.
29
+ const NODE_ACCESS_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
30
+
31
+ // Cap on how many unlock tokens the cookie carries. The value is a
32
+ // comma-separated list of ~150-byte signed tokens, so an unbounded list would
33
+ // eventually pass the browser's ~4 KB per-cookie limit and be dropped whole —
34
+ // silently losing every unlock. We keep the most recent few (oldest are also
35
+ // the closest to their 30-day expiry, so the server would drop them anyway) and
36
+ // evict from the front. A visitor unlocking more than this many collections is
37
+ // well outside the intended "share a handful of drafts" use.
38
+ const MAX_NODE_ACCESS_TOKENS = 16;
39
+
40
+ /** The node-access cookie value from a Cookie header string, or undefined. */
41
+ export function readNodeAccessCookie(cookieHeader) {
42
+ if (!cookieHeader) return undefined;
43
+ for (const part of cookieHeader.split(';')) {
44
+ const idx = part.indexOf('=');
45
+ if (idx === -1) continue;
46
+ if (part.slice(0, idx).trim() !== NODE_ACCESS_COOKIE) continue;
47
+ const value = part.slice(idx + 1).trim();
48
+ return value === '' ? undefined : value;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ /**
54
+ * The node-access forwarding header for a Cookie header string, or {} when the
55
+ * cookie is absent. Spread into a server-side GraphQL fetch's headers so a
56
+ * collection the visitor unlocked resolves for them. Returns {} for public
57
+ * (no-cookie) requests, leaving them untouched and cacheable.
58
+ */
59
+ export function nodeAccessHeadersFromCookie(cookieHeader) {
60
+ const value = readNodeAccessCookie(cookieHeader);
61
+ return value ? {
62
+ [NODE_ACCESS_HEADER]: value
63
+ } : {};
64
+ }
65
+
66
+ /**
67
+ * Remember a freshly minted access token in the browser's node-access cookie,
68
+ * preserving any collections already unlocked. First-party and JS-readable on
69
+ * purpose (see the module note); SameSite=Lax so it rides top-level navigations,
70
+ * Secure only on https so it still persists in local dev. No-op off the browser.
71
+ */
72
+ export function rememberNodeAccessToken(token) {
73
+ if (typeof document === 'undefined' || !token) return;
74
+ const existing = readNodeAccessCookie(document.cookie);
75
+ const tokens = existing ? existing.split(',').filter(Boolean) : [];
76
+ if (!tokens.includes(token)) tokens.push(token);
77
+ // Keep the list bounded so it can't grow past the browser's cookie-size limit
78
+ // and get dropped wholesale; the newest unlock is always retained.
79
+ if (tokens.length > MAX_NODE_ACCESS_TOKENS) {
80
+ tokens.splice(0, tokens.length - MAX_NODE_ACCESS_TOKENS);
81
+ }
82
+ const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';
83
+ document.cookie = `${NODE_ACCESS_COOKIE}=${tokens.join(',')}; Path=/; SameSite=Lax${secure}; Max-Age=${NODE_ACCESS_MAX_AGE_SECONDS}`;
84
+ }
@@ -1,20 +1 @@
1
- /** Whether the current async context is an admin preview. */
2
- export declare function isPreviewing(): boolean;
3
- /**
4
- * Preview auth headers for the current async context, or {} when not previewing.
5
- * Spread into a hand-rolled fetch you make inside `runWithPreview`; the
6
- * fontdue-js fetch/preload helpers pick these up automatically and don't need
7
- * it.
8
- */
9
- export declare function ambientPreviewHeaders(): Record<string, string>;
10
- /**
11
- * Run `next` (your framework's render/loader chain) with the request's preview
12
- * token in ambient context, then return its Response. When the request is
13
- * previewing, the response's cache headers are rewritten so it is never stored
14
- * in a shared/CDN cache. Public (no-token) requests pass through untouched and
15
- * stay fully cacheable.
16
- *
17
- * `next` is your middleware's continuation — `next` in Astro, `next` in a React
18
- * Router 7 middleware. It must return (a promise of) the Response.
19
- */
20
- export declare function runWithPreview(request: Request, next: () => Response | Promise<Response>): Promise<Response>;
1
+ export { runWithPreview, isPreviewing, ambientHeaders as ambientPreviewHeaders, } from '../server/middleware.js';
@@ -1,89 +1,20 @@
1
- // Ambient (request-scoped) preview for non-RSC server frameworks.
1
+ // Ambient (request-scoped) admin preview for non-RSC server frameworks.
2
2
  //
3
- // fontdue-js/preview gives you the cookie contract and an explicit option:
4
- // read the token per request and thread `previewAuthHeaders(token)` into every
5
- // GraphQL fetch and preload helper. That's portable but easy to get wrong — miss
6
- // one preload and that island silently renders the public view.
3
+ // The ambient-context machinery moved to ../server/middleware, because it is
4
+ // shared by admin preview and the per-collection password unlock (and a
5
+ // storefront usually wants both). This module re-exports the preview-facing
6
+ // pieces so existing `fontdue-js/preview/server` imports keep working:
7
7
  //
8
- // This module removes the threading. `runWithPreview` establishes an
9
- // AsyncLocalStorage context for the duration of a request; while it's active,
10
- // every fontdue-js server fetch (createFontdueFetch and all the preload helpers)
11
- // automatically forwards the admin preview token, with no per-call plumbing —
12
- // the same ergonomics RSC gets from React.cache, and the same mechanism Next's
13
- // own headers()/draftMode() use under the hood. Install it once in your
14
- // framework's middleware:
8
+ // - runWithPreview forward only the admin preview token (reveals
9
+ // unpublished fonts). Mount it in your framework's request middleware.
10
+ // - isPreviewing / ambientPreviewHeaders read the current ambient state.
15
11
  //
16
- // // Astro: src/middleware.ts
17
- // export const onRequest = (ctx, next) => runWithPreview(ctx.request, next);
18
- //
19
- // // React Router 7 (root route, with future.v8_middleware):
20
- // export const middleware = [({ request }, next) => runWithPreview(request, next)];
21
- //
22
- // runWithPreview also binds the cache guarantee to the same call: when a request
23
- // is previewing, the response is forced out of any shared/CDN cache. Because the
24
- // context and the no-cache rule are set in one place, you can't be ambiently
25
- // previewing without the response being uncacheable — so an admin render is never
26
- // served to the public.
27
- //
28
- // Requirements & fallback: AsyncLocalStorage must propagate from the install
29
- // point into the render. That holds for in-process middleware — Node (Netlify
30
- // Functions, the default SSR target), Deno (Netlify Edge), Bun. It does NOT hold
31
- // when middleware runs in a separate runtime from the render (e.g. Astro with
32
- // `edgeMiddleware: true`, where locals cross the boundary as a serialized
33
- // header). In that case fall back to the explicit path: read the token in
34
- // middleware and thread `previewAuthHeaders(token)` via the `headers` option on
35
- // fetches/preloads. The explicit `headers` option always overrides the ambient
36
- // context, so the two compose.
37
-
38
- import { AsyncLocalStorage } from 'node:async_hooks';
39
- import { readPreviewToken, previewAuthHeaders } from './index.js';
40
- import { registerAmbientConfigResolver } from '../relay/serverConfig.js';
41
- const previewStore = new AsyncLocalStorage();
42
-
43
- // Feed the ambient token into fontdue-js's server config resolution. Returns
44
- // undefined outside a preview context, so non-preview requests are untouched.
45
- registerAmbientConfigResolver(() => {
46
- const store = previewStore.getStore();
47
- return store ? {
48
- headers: store.headers
49
- } : undefined;
50
- });
51
-
52
- /** Whether the current async context is an admin preview. */
53
- export function isPreviewing() {
54
- return previewStore.getStore() != null;
55
- }
56
-
57
- /**
58
- * Preview auth headers for the current async context, or {} when not previewing.
59
- * Spread into a hand-rolled fetch you make inside `runWithPreview`; the
60
- * fontdue-js fetch/preload helpers pick these up automatically and don't need
61
- * it.
62
- */
63
- export function ambientPreviewHeaders() {
64
- var _previewStore$getStor;
65
- return ((_previewStore$getStor = previewStore.getStore()) === null || _previewStore$getStor === void 0 ? void 0 : _previewStore$getStor.headers) ?? {};
66
- }
67
- const NO_STORE = 'private, no-store';
12
+ // To also forward a visitor's password-unlock token, mount the precomposed
13
+ // `runWithFontdue` from `fontdue-js/server/middleware` in place of
14
+ // runWithPreview (or compose runWithPreview with runWithNodeAccess yourself).
15
+ // See the password-protected collections guide.
68
16
 
69
- /**
70
- * Run `next` (your framework's render/loader chain) with the request's preview
71
- * token in ambient context, then return its Response. When the request is
72
- * previewing, the response's cache headers are rewritten so it is never stored
73
- * in a shared/CDN cache. Public (no-token) requests pass through untouched and
74
- * stay fully cacheable.
75
- *
76
- * `next` is your middleware's continuation — `next` in Astro, `next` in a React
77
- * Router 7 middleware. It must return (a promise of) the Response.
78
- */
79
- export async function runWithPreview(request, next) {
80
- const token = readPreviewToken(request.headers.get('cookie'));
81
- if (!token) return next();
82
- const response = await previewStore.run({
83
- headers: previewAuthHeaders(token)
84
- }, next);
85
- response.headers.set('Cache-Control', NO_STORE);
86
- response.headers.delete('Netlify-CDN-Cache-Control');
87
- response.headers.delete('CDN-Cache-Control');
88
- return response;
89
- }
17
+ export { runWithPreview, isPreviewing,
18
+ // Historical name kept for back-compat; returns the full ambient header set
19
+ // (preview and/or node-access), so it works under runWithFontdue too.
20
+ ambientHeaders as ambientPreviewHeaders } from '../server/middleware.js';
@@ -2,12 +2,13 @@ import { Environment, Network, RecordSource, Store, QueryResponseCache } from 'r
2
2
  import { handlePossibleCorsError } from '../corsError.js';
3
3
  import { resolveFontdueServerConfig } from './serverConfig.js';
4
4
  import { PREVIEW_HEADER, hasPreviewMarkerCookie } from '../preview/constants.js';
5
+ import { NODE_ACCESS_HEADER } from '../nodeAccess.js';
5
6
 
6
7
  // `__FONTDUE_JS_VERSION__` is replaced by an inline babel plugin
7
8
  // (defineVersionPlugin in .babelrc.cjs) with the literal package.json#version.
8
9
  // Exported so UI (the admin toolbar) can surface it without re-reading the
9
10
  // build-time global in a 'use client' module.
10
- export const version = "3.0.6";
11
+ export const version = "3.2.0";
11
12
  const IS_SERVER = typeof window === typeof undefined;
12
13
 
13
14
  // Opt server fetches into Next's data cache only in production; dev stays
@@ -91,6 +92,11 @@ export function createNetworkFetch(options) {
91
92
  const previewing = IS_SERVER ? headers.authorization != null || headers.Authorization != null : hasPreviewMarkerCookie();
92
93
  headers[PREVIEW_HEADER] = previewing ? 'true' : 'false';
93
94
 
95
+ // A forwarded node-access token (a collection this visitor unlocked) makes
96
+ // the server response per-visitor just like preview, so it must never be
97
+ // written to or served from the shared data cache.
98
+ const perVisitor = previewing || IS_SERVER && headers[NODE_ACCESS_HEADER] != null;
99
+
94
100
  // Public server fetches are all site content (per-session data is fetched
95
101
  // client-side only), so opt them into Next's data cache — without this,
96
102
  // Next 15 treats them as no-store and silently makes every page fully
@@ -113,7 +119,7 @@ export function createNetworkFetch(options) {
113
119
  variables
114
120
  })
115
121
  };
116
- if (IS_SERVER && (previewing || !cacheInProduction())) {
122
+ if (IS_SERVER && (perVisitor || !cacheInProduction())) {
117
123
  init.cache = 'no-store';
118
124
  } else if (IS_SERVER) {
119
125
  init.cache = 'force-cache';
@@ -8,7 +8,10 @@ export interface FontdueServerConfig {
8
8
  }
9
9
  type MaybePromise<T> = T | Promise<T>;
10
10
  type AmbientConfigResolver = () => MaybePromise<FontdueServerConfig | undefined>;
11
+ type NodeAccessRecovery = () => MaybePromise<Record<string, string>>;
11
12
  export declare function setFontdueServerConfig(config: FontdueServerConfig): void;
12
13
  export declare function registerAmbientConfigResolver(resolver: AmbientConfigResolver): void;
14
+ export declare function registerNodeAccessRecovery(recovery: NodeAccessRecovery): void;
15
+ export declare function resolveNodeAccessRecovery(): NodeAccessRecovery | undefined;
13
16
  export declare function resolveFontdueServerConfig(): Promise<FontdueServerConfig | undefined>;
14
17
  export {};