fontdue-js 3.1.0 → 3.2.1
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 +14 -0
- package/dist/__tests__/nextAdapter.test.js +178 -4
- package/dist/__tests__/recaptchaTimeout.test.js +191 -0
- package/dist/components/ConnectionErrorToolbar.js +2 -2
- package/dist/components/FontdueAdminToolbar/index.js +23 -11
- package/dist/components/NewsletterSignup/index.js +33 -5
- package/dist/components/NodePasswordForm/index.d.ts +11 -0
- package/dist/components/NodePasswordForm/index.js +29 -1
- package/dist/components/Recaptcha.d.ts +3 -0
- package/dist/components/Recaptcha.js +25 -0
- package/dist/components/TestFontsForm/index.js +33 -5
- package/dist/components/TypeTester/TypeTesterStandaloneElement.js +9 -4
- package/dist/components/TypeTester/TypeTesterVariableAxes.js +16 -0
- package/dist/components/elements/StoreModalUnifiedCheckout.js +48 -6
- package/dist/fontdue.css +149 -25
- package/dist/hooks/useRecaptchaTimeout.d.ts +11 -0
- package/dist/hooks/useRecaptchaTimeout.js +60 -0
- package/dist/next/index.js +3 -2
- package/dist/next/tenant.js +35 -4
- package/dist/next/unlock.d.ts +3 -0
- package/dist/next/unlock.js +112 -0
- package/dist/relay/environment.js +1 -1
- package/package.json +2 -1
- package/types/next-headers.d.ts +26 -2
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
// How long we wait for the invisible reCAPTCHA widget to hand back a token
|
|
4
|
+
// after calling `execute()` before we give up. reCAPTCHA normally resolves in
|
|
5
|
+
// a second or two; a genuine challenge is handled by the readiness probe below
|
|
6
|
+
// (we don't cut a buyer off mid-challenge), so this only needs to be long
|
|
7
|
+
// enough to rule out a transient slow load.
|
|
8
|
+
export const RECAPTCHA_TIMEOUT_MS = 15000;
|
|
9
|
+
|
|
10
|
+
// User-facing message shown when the reCAPTCHA widget never produced a token
|
|
11
|
+
// (typically because Google's api.js failed to load). Curly apostrophes per
|
|
12
|
+
// the Fontdue house style.
|
|
13
|
+
export const RECAPTCHA_UNAVAILABLE_MESSAGE = 'We couldn’t verify that you’re human. Please refresh the page and try again.';
|
|
14
|
+
|
|
15
|
+
// True once Google's reCAPTCHA script (`window.grecaptcha`) has loaded. We use
|
|
16
|
+
// this to tell the two failure modes apart when the timeout fires:
|
|
17
|
+
// - script never loaded → the widget is dead and would hang forever; reset.
|
|
18
|
+
// - script loaded, no token yet → the buyer may be mid-challenge; keep
|
|
19
|
+
// waiting rather than cancelling on them.
|
|
20
|
+
export function isRecaptchaScriptLoaded() {
|
|
21
|
+
// Google's api.js defines `grecaptcha` on the global object; in a browser
|
|
22
|
+
// `globalThis === window`, so this reads the same property while staying
|
|
23
|
+
// safe under SSR and unit tests where `window` may be undefined.
|
|
24
|
+
return typeof globalThis !== 'undefined' && globalThis.grecaptcha != null;
|
|
25
|
+
}
|
|
26
|
+
// Framework-free timer wrapper. Kept as a plain factory (not a hook) so the
|
|
27
|
+
// resilience contract can be unit-tested with fake timers without a DOM.
|
|
28
|
+
export function createRecaptchaTimeout() {
|
|
29
|
+
let timeoutMs = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : RECAPTCHA_TIMEOUT_MS;
|
|
30
|
+
let handle = null;
|
|
31
|
+
const clear = () => {
|
|
32
|
+
if (handle !== null) {
|
|
33
|
+
clearTimeout(handle);
|
|
34
|
+
handle = null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const start = onTimeout => {
|
|
38
|
+
clear();
|
|
39
|
+
handle = setTimeout(() => {
|
|
40
|
+
handle = null;
|
|
41
|
+
onTimeout();
|
|
42
|
+
}, timeoutMs);
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
start,
|
|
46
|
+
clear
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// React hook wrapper: one stable timer per component that auto-clears on
|
|
51
|
+
// unmount (so a pending timeout never fires setState after teardown).
|
|
52
|
+
export function useRecaptchaTimeout(timeoutMs) {
|
|
53
|
+
const ref = useRef(null);
|
|
54
|
+
if (ref.current === null) {
|
|
55
|
+
ref.current = createRecaptchaTimeout(timeoutMs);
|
|
56
|
+
}
|
|
57
|
+
const timeout = ref.current;
|
|
58
|
+
useEffect(() => () => timeout.clear(), [timeout]);
|
|
59
|
+
return timeout;
|
|
60
|
+
}
|
package/dist/next/index.js
CHANGED
|
@@ -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'
|
|
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
|
package/dist/next/tenant.js
CHANGED
|
@@ -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.
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
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,112 @@
|
|
|
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 is scoped to the unlocked page's path, not the whole site.
|
|
26
|
+
// draftMode().enable() sets the cookie with Path=/, which would take EVERY
|
|
27
|
+
// page this visitor touches off the full-route cache — and because a render
|
|
28
|
+
// carrying a node-access token also opts all of its GraphQL fetches out of the
|
|
29
|
+
// shared data cache, one unlock made the visitor's whole browsing session
|
|
30
|
+
// dynamically rendered and measurably slow (seconds per page on heavy pages).
|
|
31
|
+
// Draft mode is decided per-request by whether the browser SENDS the bypass
|
|
32
|
+
// cookie, and the browser decides that by cookie Path — so the form posts the
|
|
33
|
+
// pathname it unlocked on and we narrow the cookie to it. Public pages stay on
|
|
34
|
+
// the static cache; only the unlocked collection's pages render dynamically
|
|
35
|
+
// for this visitor. Unlocking several collections yields several same-name
|
|
36
|
+
// cookies with different paths, which browsers store and send independently.
|
|
37
|
+
// (Admin preview keeps its site-wide Path=/ bypass — previewing the whole site
|
|
38
|
+
// dynamically is the point there.)
|
|
39
|
+
//
|
|
40
|
+
// The bypass cookie is a session cookie, while the node-access cookie lasts ~30
|
|
41
|
+
// days. A returning visitor whose bypass lapsed sees the static form again and
|
|
42
|
+
// re-enters the password; re-unlocking re-enables the bypass. There is no
|
|
43
|
+
// DELETE: draft mode is shared with admin preview, so an unlock exit must not
|
|
44
|
+
// tear down an active preview — the bypass simply expires with the session.
|
|
45
|
+
|
|
46
|
+
import { cookies, draftMode } from 'next/headers';
|
|
47
|
+
|
|
48
|
+
/** Default path the templates mount the handler at. */
|
|
49
|
+
export const UNLOCK_ENDPOINT = '/api/unlock';
|
|
50
|
+
|
|
51
|
+
// Next's draft-mode bypass cookie. The name is Next's stable, documented
|
|
52
|
+
// public contract (the same one draftMode().enable() sets), but it isn't
|
|
53
|
+
// exported from a public entry point, so it's spelled out here.
|
|
54
|
+
const PRERENDER_BYPASS_COOKIE = '__prerender_bypass';
|
|
55
|
+
|
|
56
|
+
// A client-sent pathname we are willing to put in a Set-Cookie Path attribute:
|
|
57
|
+
// absolute, bounded, visible ASCII with no ';' — i.e. what
|
|
58
|
+
// window.location.pathname (always percent-encoded) actually produces, and
|
|
59
|
+
// nothing that could terminate or escape the attribute (Next serializes the
|
|
60
|
+
// path verbatim). Anything else falls back to the site-wide default.
|
|
61
|
+
const SCOPE_PATH_MAX_LENGTH = 512;
|
|
62
|
+
const SCOPE_PATH_RE = /^\/[\x21-\x3a\x3c-\x7e]*$/;
|
|
63
|
+
export async function POST(request) {
|
|
64
|
+
const body = await request.json().catch(() => ({}));
|
|
65
|
+
if (typeof body.token !== 'string' || body.token === '') {
|
|
66
|
+
return Response.json({
|
|
67
|
+
ok: false,
|
|
68
|
+
error: 'Missing access token'
|
|
69
|
+
}, {
|
|
70
|
+
status: 400
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
(await draftMode()).enable();
|
|
74
|
+
await scopeBypassCookie(scopePath(body.path));
|
|
75
|
+
return Response.json({
|
|
76
|
+
ok: true
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The cookie Path to narrow the bypass to, or undefined to leave it site-wide
|
|
81
|
+
// (path missing — an older form — or unusable). Trailing slashes are stripped
|
|
82
|
+
// because cookie path-matching treats "/fonts/x" as covering both the page
|
|
83
|
+
// itself and everything under it, while "/fonts/x/" would miss the page.
|
|
84
|
+
function scopePath(path) {
|
|
85
|
+
if (typeof path !== 'string' || path.length > SCOPE_PATH_MAX_LENGTH || !SCOPE_PATH_RE.test(path)) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const trimmed = path.replace(/\/+$/, '');
|
|
89
|
+
return trimmed === '' ? undefined : trimmed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Replace the pending site-wide bypass cookie enable() just queued with one
|
|
93
|
+
// scoped to `path`. cookies() in a route handler exposes the same mutable
|
|
94
|
+
// store enable() wrote to, and setting the same cookie name again replaces the
|
|
95
|
+
// queued Set-Cookie rather than adding a second one — so exactly one bypass
|
|
96
|
+
// cookie leaves this response, and no Path=/ variant that could clobber an
|
|
97
|
+
// admin's site-wide preview bypass. The explicit attributes mirror enable()'s
|
|
98
|
+
// as a fallback; the spread keeps whatever enable() actually set (bar the
|
|
99
|
+
// path) if Next ever changes them.
|
|
100
|
+
async function scopeBypassCookie(path) {
|
|
101
|
+
if (!path) return;
|
|
102
|
+
const jar = await cookies();
|
|
103
|
+
const bypass = jar.get(PRERENDER_BYPASS_COOKIE);
|
|
104
|
+
if (!(bypass !== null && bypass !== void 0 && bypass.value)) return;
|
|
105
|
+
jar.set({
|
|
106
|
+
httpOnly: true,
|
|
107
|
+
sameSite: process.env.NODE_ENV !== 'development' ? 'none' : 'lax',
|
|
108
|
+
secure: process.env.NODE_ENV !== 'development',
|
|
109
|
+
...bypass,
|
|
110
|
+
path
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -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
|
|
11
|
+
export const version = "3.2.1";
|
|
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
|
|
3
|
+
"version": "3.2.1",
|
|
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": {
|
package/types/next-headers.d.ts
CHANGED
|
@@ -1,9 +1,33 @@
|
|
|
1
1
|
// Minimal declaration so src/next/tenant.ts type-checks without `next`
|
|
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
|
+
//
|
|
5
|
+
// The cookie shape mirrors Next's route-handler cookie store: get() also
|
|
6
|
+
// carries the queued cookie's attributes (the store is ResponseCookies-backed,
|
|
7
|
+
// so a cookie draftMode().enable() just set comes back with its path/flags),
|
|
8
|
+
// and set() with an existing name replaces the queued Set-Cookie. The unlock
|
|
9
|
+
// route (src/next/unlock.ts) relies on both to narrow the bypass cookie's
|
|
10
|
+
// path.
|
|
4
11
|
declare module 'next/headers' {
|
|
5
|
-
|
|
12
|
+
interface CookieAttributes {
|
|
13
|
+
path?: string;
|
|
14
|
+
httpOnly?: boolean;
|
|
15
|
+
secure?: boolean;
|
|
16
|
+
sameSite?: 'strict' | 'lax' | 'none';
|
|
17
|
+
expires?: Date | number;
|
|
18
|
+
maxAge?: number;
|
|
19
|
+
domain?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function draftMode(): Promise<{
|
|
23
|
+
isEnabled: boolean;
|
|
24
|
+
enable(): void;
|
|
25
|
+
disable(): void;
|
|
26
|
+
}>;
|
|
6
27
|
export function cookies(): Promise<{
|
|
7
|
-
get(
|
|
28
|
+
get(
|
|
29
|
+
name: string,
|
|
30
|
+
): ({ name: string; value: string } & CookieAttributes) | undefined;
|
|
31
|
+
set(cookie: { name: string; value: string } & CookieAttributes): void;
|
|
8
32
|
}>;
|
|
9
33
|
}
|