fontdue-js 3.0.0-alpha7 → 3.0.0-alpha9

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,12 @@
1
+ ## 2.28.0
2
+
3
+ - Checkout now captures the buyer's **analytics consent and ad attribution** on the order. When the cart or store-modal checkout opens, fontdue-js sends the consent-banner state, anonymous ID, Meta browser IDs (the `_fbp`/`_fbc` cookies), and the page URL to the server, where they're stored on the order. This lets Fontdue emit a server-side *purchase* conversion event (Facebook Conversions API, Google Ads) when the order completes — completion happens in a Stripe webhook, outside the browser — while respecting the buyer's cookie-consent choice: if consent was declined, no identifiers are sent and nothing is forwarded to ad platforms. The call is fire-and-forget and never affects checkout.
4
+
5
+ ## 2.27.0
6
+
7
+ - The `TypeTester` `truncate` option now accepts a **number** to cap the sample paragraph at N lines (in addition to `false` = off and `true` = 1 line). Truncation is now ascender/descender-safe at any line-height — it reads the font's vertical metrics so accents and descenders are never clipped — and flows the clipped text into columns, so multi-column truncated specimens render correctly and side-by-side testers keep their toolbars aligned.
8
+ - The `TypeTester` **family selector now lazy-loads its data**. The family list is fetched on first dropdown open (shared once per page) and per-family styles load on demand, instead of fetching the whole font library up front. This substantially speeds up the initial render of pages with type testers, especially with several standalone testers. The dropdown shows a “Loading…” state while data loads.
9
+
1
10
  ## 2.26.1
2
11
 
3
12
  - Fixed a jitter in the `TypeTester` variable-font axis sliders: dragging a slider no longer shifts the handle position as the instance name and value beside it change width. The instance name now settles shortly after dragging stops, and the value reserves a fixed width for its range.
package/README.md CHANGED
@@ -53,7 +53,18 @@ The shape of step 3 and 4 is the only thing that changes between frameworks.
53
53
  <details>
54
54
  <summary><b>Next.js (App Router)</b></summary>
55
55
 
56
- No Vite plugin needed. The simplest setup omits the layout preload — with React Server Components, each fontdue-js component preloads its own query internally on the server and streams to the client.
56
+ No Vite plugin needed wrap your Next config with [`withFontdue`](#nextjs-adapter) instead:
57
+
58
+ ```js
59
+ // next.config.mjs
60
+ import { withFontdue } from "fontdue-js/next/config";
61
+
62
+ export default withFontdue({
63
+ // your Next config
64
+ });
65
+ ```
66
+
67
+ The simplest setup omits the layout preload — with React Server Components, each fontdue-js component preloads its own query internally on the server and streams to the client.
57
68
 
58
69
  ```tsx
59
70
  // app/layout.tsx
@@ -84,6 +95,8 @@ export default function FontPage() {
84
95
  }
85
96
  ```
86
97
 
98
+ Beyond the components, Next.js projects get a few extra entry points — config wrapping, cache revalidation, and helpers for your own GraphQL fetches. See [Next.js adapter](#nextjs-adapter).
99
+
87
100
  Example repo: [`fontdue/fontdue-example-next`](https://github.com/fontdue/fontdue-example-next)
88
101
 
89
102
  </details>
@@ -356,6 +369,76 @@ export default function App() {
356
369
 
357
370
  </details>
358
371
 
372
+ ## Next.js adapter
373
+
374
+ Next.js App Router projects get a few extra entry points beyond the components. The [example repo](https://github.com/fontdue/fontdue-example-next) wires up all of them.
375
+
376
+ ### `withFontdue` — next.config wrapper
377
+
378
+ ```js
379
+ // next.config.mjs
380
+ import { withFontdue } from "fontdue-js/next/config";
381
+
382
+ export default withFontdue({
383
+ // your Next config
384
+ });
385
+ ```
386
+
387
+ What it installs:
388
+
389
+ - **Image settings** — `images.remotePatterns` entries for Fontdue's image hosts (plus `dangerouslyAllowSVG`, since font specimens are often SVGs), merged with your own `images` config.
390
+ - **Correct 404 statuses** — Next's streamed metadata locks in a `200` response before a `notFound()` thrown during `generateMetadata` can take effect ([vercel/next.js#82041](https://github.com/vercel/next.js/issues/82041)). `withFontdue` sets `htmlLimitedBots` to match every user agent so metadata rendering blocks the response and missing pages come out as real 404s.
391
+
392
+ The rest of your config — `rewrites` included — passes through unchanged.
393
+
394
+ #### Optional: image optimization on Cloudflare
395
+
396
+ If you have a Cloudflare zone with [image transformations](https://developers.cloudflare.com/images/transform-images/) enabled, set:
397
+
398
+ ```shell
399
+ NEXT_PUBLIC_FONTDUE_IMAGE_HOST=img.your-domain.com
400
+ NEXT_PUBLIC_FONTDUE_IMAGE_ORIGINS=cdn.fontdue.com
401
+ ```
402
+
403
+ `next/image` optimization then moves to the Cloudflare edge, and your deployment needs neither the `/_next/image` endpoint nor sharp. `NEXT_PUBLIC_FONTDUE_IMAGE_ORIGINS` (comma-separated hostnames) should mirror the transformation host's allowed source origins — sources on other hosts are served as originals rather than as transform URLs Cloudflare would refuse. Both variables must be present when `next build` runs (the loader is inlined into the client bundle), not just at serve time.
404
+
405
+ ### Updating content: `/api/revalidate`
406
+
407
+ fontdue-js's server-side fetches are cached by Next and tagged `graphql`. Re-export the deploy-hook route handler:
408
+
409
+ ```ts
410
+ // app/api/revalidate/route.ts
411
+ export { POST } from "fontdue-js/next/revalidate";
412
+ ```
413
+
414
+ and set the Deploy hook URL in your Fontdue admin (Settings → Website settings) to `https://your-site.example/api/revalidate`. Fontdue calls it whenever your site's content changes, purging everything tagged `graphql` so the next request renders fresh.
415
+
416
+ fontdue-js's own server-side fetches opt into Next's data cache (and the `graphql` tag) automatically — static pages revalidated by the deploy hook is the intended way to run a Fontdue site, not dynamic rendering. Give your own fetches the same treatment; `currentFontdueEndpoint()` below shows how.
417
+
418
+ ### Your own GraphQL fetches
419
+
420
+ `currentFontdueEndpoint()` (from `fontdue-js/next`) describes the endpoint your own server-side [GraphQL](https://docs.fontdue.com/graphql-api) fetches should use — the base origin, required headers, and the cache tags that tie them into `/api/revalidate`:
421
+
422
+ ```ts
423
+ import { currentFontdueEndpoint } from "fontdue-js/next";
424
+
425
+ export async function fetchGraphql(query: string, variables?: unknown) {
426
+ const endpoint = currentFontdueEndpoint();
427
+ const response = await fetch(`${endpoint.origin}/graphql`, {
428
+ method: "POST",
429
+ body: JSON.stringify({ query, variables }),
430
+ headers: { "content-type": "application/json", ...endpoint.headers },
431
+ // Cache explicitly (Next 15 doesn't cache fetch by default), tagged so
432
+ // the deploy hook purges this too.
433
+ cache: "force-cache",
434
+ next: { tags: endpoint.tags },
435
+ });
436
+ return (await response.json()).data;
437
+ }
438
+ ```
439
+
440
+ The shape is exported as `type FontdueEndpoint`.
441
+
359
442
  ## UI config
360
443
 
361
444
  Most components accept a `config` object that controls UI behavior — type-tester options (`selectable`, `priceBar`, size ranges, OpenType-feature UI…), store-modal layout, form styling, analytics tracking, and more. See the [full config reference](https://docs.fontdue.com/fontduejs#b3dec49aa08240bba2b4c71a67c08333).
@@ -1,9 +1,3 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- exports.default = void 0;
7
1
  /**
8
2
  * @generated SignedSource<<017a8a724b3a0fd0918153ce04d07f68>>
9
3
  * @lightSyntaxTransform
@@ -68,5 +62,4 @@ const node = function () {
68
62
  };
69
63
  }();
70
64
  node.hash = "d59a127a7f140424f507ae549731bac7";
71
- var _default = node;
72
- exports.default = _default;
65
+ export default node;
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ // environment.ts reads env at module load, so stub env and re-import fresh.
3
+ beforeEach(() => {
4
+ vi.resetModules();
5
+ vi.unstubAllEnvs();
6
+ vi.unstubAllGlobals();
7
+ });
8
+ const request = {
9
+ name: 'TestQuery',
10
+ text: 'query TestQuery { viewer { id } }'
11
+ };
12
+ describe('createNetworkFetch (server)', () => {
13
+ it('opts fetches into the Next data cache, tagged for revalidation', async () => {
14
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
15
+ const fetchMock = vi.fn(async () => ({
16
+ json: async () => ({
17
+ data: {}
18
+ })
19
+ }));
20
+ vi.stubGlobal('fetch', fetchMock);
21
+ const {
22
+ createNetworkFetch
23
+ } = await import("../relay/environment.js");
24
+ await createNetworkFetch()(request, {});
25
+ expect(fetchMock).toHaveBeenCalledTimes(1);
26
+ const [url, options] = fetchMock.mock.calls[0];
27
+ expect(url).toBe('https://acme.fontdue.com/graphql?queryName=TestQuery');
28
+ // Without an explicit cache option Next 15 treats the fetch as no-store
29
+ // and silently makes every page fully dynamic — the static + revalidate
30
+ // pattern depends on this.
31
+ expect(options.cache).toBe('force-cache');
32
+ expect(options.next.tags).toContain('graphql');
33
+ expect(options.next.tags).toContain('operation:TestQuery');
34
+ });
35
+ it('applies the per-render server config tags and headers', async () => {
36
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
37
+ const fetchMock = vi.fn(async () => ({
38
+ json: async () => ({
39
+ data: {}
40
+ })
41
+ }));
42
+ vi.stubGlobal('fetch', fetchMock);
43
+
44
+ // React.cache doesn't memoize outside a React render, so the store is
45
+ // mocked rather than set through setFontdueServerConfig.
46
+ vi.doMock('../relay/serverConfig', async importActual => ({
47
+ ...(await importActual()),
48
+ getFontdueServerConfig: () => ({
49
+ url: 'http://app:4000',
50
+ headers: {
51
+ 'x-forwarded-host': 'acme.fontdue.com'
52
+ },
53
+ cacheTags: ['graphql:acme.fontdue.com']
54
+ })
55
+ }));
56
+ const {
57
+ createNetworkFetch
58
+ } = await import("../relay/environment.js");
59
+ await createNetworkFetch()(request, {});
60
+ vi.doUnmock('../relay/serverConfig');
61
+ const [url, options] = fetchMock.mock.calls[0];
62
+ expect(url).toBe('http://app:4000/graphql?queryName=TestQuery');
63
+ expect(options.headers['x-forwarded-host']).toBe('acme.fontdue.com');
64
+ expect(options.next.tags).toContain('graphql:acme.fontdue.com');
65
+ });
66
+ });
@@ -0,0 +1,447 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ // The next adapter modules read process.env at module load, so each test
7
+ // group stubs the env and re-imports them fresh.
8
+ async function importTenant() {
9
+ return await import("../next/tenant.js");
10
+ }
11
+ async function importConfig() {
12
+ return await import("../next/config.js");
13
+ }
14
+ async function importRevalidate() {
15
+ return await import("../next/revalidate.js");
16
+ }
17
+ const revalidateTag = vi.fn();
18
+ vi.mock('next/cache', () => ({
19
+ revalidateTag: tag => revalidateTag(tag)
20
+ }));
21
+
22
+ // Next's notFound() throws a sentinel the framework catches; the mock does
23
+ // the same so tests can assert on it.
24
+ vi.mock('next/navigation', () => ({
25
+ notFound: () => {
26
+ throw new Error('NEXT_NOT_FOUND');
27
+ }
28
+ }));
29
+ beforeEach(() => {
30
+ vi.resetModules();
31
+ revalidateTag.mockClear();
32
+ vi.unstubAllEnvs();
33
+ vi.restoreAllMocks();
34
+ });
35
+ function stubSingleTenant() {
36
+ let url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'https://acme.fontdue.com';
37
+ vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', url);
38
+ vi.stubEnv('FONTDUE_MULTI_TENANT', '');
39
+ vi.stubEnv('FONTDUE_ORIGIN', '');
40
+ vi.stubEnv('FONTDUE_PROXY_SECRET', '');
41
+ }
42
+ function stubMultiTenant() {
43
+ let {
44
+ origin = '',
45
+ secret = ''
46
+ } = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
47
+ vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
48
+ vi.stubEnv('FONTDUE_MULTI_TENANT', '1');
49
+ vi.stubEnv('FONTDUE_ORIGIN', origin);
50
+ vi.stubEnv('FONTDUE_PROXY_SECRET', secret);
51
+ }
52
+ describe('isValidDomain', () => {
53
+ it('accepts plain dotted hostnames and rejects everything else', async () => {
54
+ stubMultiTenant();
55
+ const {
56
+ isValidDomain
57
+ } = await importTenant();
58
+ expect(isValidDomain('acme.fontdue.com')).toBe(true);
59
+ expect(isValidDomain('a-b.example')).toBe(true);
60
+ expect(isValidDomain('localhost')).toBe(false); // no dot
61
+ expect(isValidDomain('acme.fontdue.com:3000')).toBe(false); // port
62
+ expect(isValidDomain('acme.fontdue.com/x')).toBe(false); // path
63
+ expect(isValidDomain('-bad.example')).toBe(false);
64
+ expect(isValidDomain('a'.repeat(254) + '.example')).toBe(false);
65
+ });
66
+ });
67
+ describe('fontdueEndpoint', () => {
68
+ it('single-tenant: targets NEXT_PUBLIC_FONTDUE_URL with no headers', async () => {
69
+ stubSingleTenant('https://acme.fontdue.com');
70
+ const {
71
+ fontdueEndpoint
72
+ } = await importTenant();
73
+ expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
74
+ domain: 'acme.fontdue.com',
75
+ origin: 'https://acme.fontdue.com',
76
+ headers: {},
77
+ tags: ['graphql', 'graphql:acme.fontdue.com']
78
+ });
79
+ });
80
+ it('single-tenant: throws when NEXT_PUBLIC_FONTDUE_URL is missing', async () => {
81
+ vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
82
+ vi.stubEnv('FONTDUE_MULTI_TENANT', '');
83
+ const {
84
+ fontdueEndpoint
85
+ } = await importTenant();
86
+ expect(() => fontdueEndpoint('acme.fontdue.com')).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
87
+ });
88
+ it('multi-tenant: forwards the host to FONTDUE_ORIGIN with the proxy secret', async () => {
89
+ stubMultiTenant({
90
+ origin: 'http://app:4000',
91
+ secret: 's3cret'
92
+ });
93
+ const {
94
+ fontdueEndpoint
95
+ } = await importTenant();
96
+ expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
97
+ domain: 'acme.fontdue.com',
98
+ origin: 'http://app:4000',
99
+ headers: {
100
+ 'x-forwarded-host': 'acme.fontdue.com',
101
+ 'x-fontdue-proxy-secret': 's3cret'
102
+ },
103
+ tags: ['graphql', 'graphql:acme.fontdue.com']
104
+ });
105
+ });
106
+ it('multi-tenant: omits the secret header when unset', async () => {
107
+ stubMultiTenant({
108
+ origin: 'http://app:4000'
109
+ });
110
+ const {
111
+ fontdueEndpoint
112
+ } = await importTenant();
113
+ expect(fontdueEndpoint('acme.fontdue.com').headers).toEqual({
114
+ 'x-forwarded-host': 'acme.fontdue.com'
115
+ });
116
+ });
117
+ it('multi-tenant: falls back to the tenant public URL without FONTDUE_ORIGIN', async () => {
118
+ stubMultiTenant();
119
+ const {
120
+ fontdueEndpoint
121
+ } = await importTenant();
122
+ expect(fontdueEndpoint('acme.fontdue.com').origin).toBe('https://acme.fontdue.com');
123
+ });
124
+ });
125
+ describe('configureFontdueRender', () => {
126
+ it('rejects invalid domains with null and sets no config', async () => {
127
+ stubMultiTenant({
128
+ origin: 'http://app:4000'
129
+ });
130
+ const {
131
+ configureFontdueRender
132
+ } = await importTenant();
133
+ const {
134
+ getFontdueServerConfig
135
+ } = await import("../relay/serverConfig.js");
136
+ expect(configureFontdueRender('not a domain')).toBeNull();
137
+ expect(getFontdueServerConfig()).toBeUndefined();
138
+ });
139
+ it('sets the per-render server config and returns the endpoint', async () => {
140
+ stubMultiTenant({
141
+ origin: 'http://app:4000',
142
+ secret: 's3cret'
143
+ });
144
+ const {
145
+ configureFontdueRender
146
+ } = await importTenant();
147
+ const {
148
+ getFontdueServerConfig
149
+ } = await import("../relay/serverConfig.js");
150
+ const endpoint = configureFontdueRender('acme.fontdue.com');
151
+ expect(endpoint === null || endpoint === void 0 ? void 0 : endpoint.origin).toBe('http://app:4000');
152
+ // Outside an RSC render React.cache doesn't memoize, so the write is a
153
+ // no-op here — but the config passed must still be shaped correctly, so
154
+ // assert via fontdueServerConfig instead.
155
+ const {
156
+ fontdueServerConfig
157
+ } = await importTenant();
158
+ expect(fontdueServerConfig('acme.fontdue.com')).toEqual({
159
+ url: 'http://app:4000',
160
+ headers: {
161
+ 'x-forwarded-host': 'acme.fontdue.com',
162
+ 'x-fontdue-proxy-secret': 's3cret'
163
+ },
164
+ cacheTags: ['graphql:acme.fontdue.com'],
165
+ domain: 'acme.fontdue.com'
166
+ });
167
+ });
168
+ });
169
+ describe('prepareFontdueRender', () => {
170
+ const props = params => ({
171
+ params: Promise.resolve(params)
172
+ });
173
+ it('configures the render and returns the endpoint', async () => {
174
+ stubMultiTenant({
175
+ origin: 'http://app:4000'
176
+ });
177
+ const {
178
+ prepareFontdueRender
179
+ } = await importTenant();
180
+ const endpoint = await prepareFontdueRender(props({
181
+ domain: 'acme.fontdue.com',
182
+ slug: 'sans'
183
+ }));
184
+ expect(endpoint.origin).toBe('http://app:4000');
185
+ expect(endpoint.tags).toContain('graphql:acme.fontdue.com');
186
+ });
187
+ it('404s invalid or missing domains', async () => {
188
+ stubMultiTenant();
189
+ const {
190
+ prepareFontdueRender
191
+ } = await importTenant();
192
+ await expect(prepareFontdueRender(props({
193
+ domain: 'not a domain'
194
+ }))).rejects.toThrow('NEXT_NOT_FOUND');
195
+ await expect(prepareFontdueRender(props({
196
+ slug: 'sans'
197
+ }))).rejects.toThrow('NEXT_NOT_FOUND');
198
+ });
199
+ });
200
+ describe('currentFontdueEndpoint', () => {
201
+ it('multi-tenant: throws when no render config was set', async () => {
202
+ stubMultiTenant({
203
+ origin: 'http://app:4000'
204
+ });
205
+ const {
206
+ currentFontdueEndpoint
207
+ } = await importTenant();
208
+ expect(() => currentFontdueEndpoint()).toThrow(/prepareFontdueRender/);
209
+ });
210
+ it('single-tenant: derives the endpoint from NEXT_PUBLIC_FONTDUE_URL', async () => {
211
+ stubSingleTenant('https://acme.fontdue.com');
212
+ const {
213
+ currentFontdueEndpoint
214
+ } = await importTenant();
215
+ expect(currentFontdueEndpoint()).toEqual({
216
+ domain: 'acme.fontdue.com',
217
+ origin: 'https://acme.fontdue.com',
218
+ headers: {},
219
+ tags: ['graphql', 'graphql:acme.fontdue.com']
220
+ });
221
+ });
222
+ it('uses the render-configured domain when set', async () => {
223
+ stubMultiTenant({
224
+ origin: 'http://app:4000'
225
+ });
226
+ vi.doMock('../relay/serverConfig', async importActual => ({
227
+ ...(await importActual()),
228
+ getFontdueServerConfig: () => ({
229
+ domain: 'acme.fontdue.com'
230
+ })
231
+ }));
232
+ const {
233
+ currentFontdueEndpoint
234
+ } = await importTenant();
235
+ expect(currentFontdueEndpoint().headers['x-forwarded-host']).toBe('acme.fontdue.com');
236
+ vi.doUnmock('../relay/serverConfig');
237
+ });
238
+ });
239
+ describe('generateStaticParams', () => {
240
+ it('returns no build-time params (everything renders on demand)', async () => {
241
+ stubMultiTenant();
242
+ const {
243
+ generateStaticParams
244
+ } = await importTenant();
245
+ await expect(generateStaticParams()).resolves.toEqual([]);
246
+ });
247
+ });
248
+
249
+ // withFontdue detects the route-tree shape from the working directory; give
250
+ // it one with or without src/app/[domain].
251
+ function mockAppDir(_ref) {
252
+ let {
253
+ domainTree
254
+ } = _ref;
255
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fontdue-app-'));
256
+ fs.mkdirSync(path.join(dir, domainTree ? 'src/app/[domain]' : 'src/app'), {
257
+ recursive: true
258
+ });
259
+ vi.spyOn(process, 'cwd').mockReturnValue(dir);
260
+ }
261
+ describe('withFontdue', () => {
262
+ beforeEach(() => mockAppDir({
263
+ domainTree: true
264
+ }));
265
+ it('throws when neither mode is configured', async () => {
266
+ vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
267
+ vi.stubEnv('FONTDUE_MULTI_TENANT', '');
268
+ const {
269
+ withFontdue
270
+ } = await importConfig();
271
+ expect(() => withFontdue({})).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
272
+ });
273
+ it('single-tenant: rewrites every path under the constant domain', async () => {
274
+ stubSingleTenant('https://acme.fontdue.com');
275
+ const {
276
+ withFontdue
277
+ } = await importConfig();
278
+ const rewrites = await withFontdue({}).rewrites();
279
+ expect(rewrites.afterFiles).toEqual([]);
280
+ expect(rewrites.fallback).toEqual([]);
281
+ const destinations = rewrites.beforeFiles.map(r => r.destination);
282
+ expect(destinations).toEqual(['/acme.fontdue.com', '/acme.fontdue.com/robots.txt', '/acme.fontdue.com/sitemap.xml', '/acme.fontdue.com/:path']);
283
+ });
284
+ it('multi-tenant: emits forwarded-host rules before host rules, mutually exclusive', async () => {
285
+ stubMultiTenant();
286
+ const {
287
+ withFontdue
288
+ } = await importConfig();
289
+ const rewrites = await withFontdue({}).rewrites();
290
+ expect(rewrites.beforeFiles).toHaveLength(8);
291
+ const [forwarded, hostBased] = [rewrites.beforeFiles.slice(0, 4), rewrites.beforeFiles.slice(4)];
292
+ for (const rule of forwarded) {
293
+ var _rule$has;
294
+ expect((_rule$has = rule.has) === null || _rule$has === void 0 ? void 0 : _rule$has[0]).toMatchObject({
295
+ type: 'header',
296
+ key: 'x-forwarded-host'
297
+ });
298
+ expect(rule.missing).toBeUndefined();
299
+ }
300
+ for (const rule of hostBased) {
301
+ var _rule$has2, _rule$missing;
302
+ expect((_rule$has2 = rule.has) === null || _rule$has2 === void 0 ? void 0 : _rule$has2[0]).toMatchObject({
303
+ type: 'host'
304
+ });
305
+ expect((_rule$missing = rule.missing) === null || _rule$missing === void 0 ? void 0 : _rule$missing[0]).toMatchObject({
306
+ type: 'header',
307
+ key: 'x-forwarded-host'
308
+ });
309
+ }
310
+ });
311
+ it("chains the app's beforeFiles rules ahead of the tenant rules", async () => {
312
+ stubSingleTenant();
313
+ const {
314
+ withFontdue
315
+ } = await importConfig();
316
+ const userRule = {
317
+ source: '/old',
318
+ destination: '/new'
319
+ };
320
+ const config = withFontdue({
321
+ rewrites: async () => ({
322
+ beforeFiles: [userRule]
323
+ })
324
+ });
325
+ const rewrites = await config.rewrites();
326
+ expect(rewrites.beforeFiles[0]).toEqual(userRule);
327
+ expect(rewrites.beforeFiles).toHaveLength(5);
328
+ });
329
+ it("treats a plain-array rewrites() result as afterFiles, per Next's contract", async () => {
330
+ stubSingleTenant();
331
+ const {
332
+ withFontdue
333
+ } = await importConfig();
334
+ const userRule = {
335
+ source: '/old',
336
+ destination: '/new'
337
+ };
338
+ const rewrites = await withFontdue({
339
+ rewrites: async () => [userRule]
340
+ }).rewrites();
341
+ expect(rewrites.afterFiles).toEqual([userRule]);
342
+ expect(rewrites.beforeFiles).toHaveLength(4);
343
+ });
344
+ it('merges image settings, keeping app remotePatterns and overrides', async () => {
345
+ stubMultiTenant();
346
+ const {
347
+ withFontdue
348
+ } = await importConfig();
349
+ const config = withFontdue({
350
+ images: {
351
+ dangerouslyAllowSVG: false,
352
+ remotePatterns: [{
353
+ protocol: 'https',
354
+ hostname: 'cdn.example'
355
+ }]
356
+ }
357
+ });
358
+ expect(config.images.dangerouslyAllowSVG).toBe(false);
359
+ // NODE_ENV is 'test' here, i.e. not production → dev workaround active.
360
+ expect(config.images.unoptimized).toBe(true);
361
+ const hostnames = config.images.remotePatterns.map(p => p.hostname);
362
+ expect(hostnames).toContain('cdn.example');
363
+ expect(hostnames).toContain('*.fontdue.com');
364
+ expect(hostnames).toContain('**');
365
+ });
366
+ it('passes unrelated config through and defaults htmlLimitedBots', async () => {
367
+ stubSingleTenant();
368
+ const {
369
+ withFontdue
370
+ } = await importConfig();
371
+ const config = withFontdue({
372
+ reactStrictMode: true
373
+ });
374
+ expect(config.reactStrictMode).toBe(true);
375
+ expect(config.htmlLimitedBots).toEqual(/.*/);
376
+ expect(withFontdue({
377
+ htmlLimitedBots: /Googlebot/
378
+ }).htmlLimitedBots).toEqual(/Googlebot/);
379
+ });
380
+ it('single-tenant flat tree: installs no tenant rewrites', async () => {
381
+ mockAppDir({
382
+ domainTree: false
383
+ });
384
+ stubSingleTenant('https://acme.fontdue.com');
385
+ const {
386
+ withFontdue
387
+ } = await importConfig();
388
+ const userRule = {
389
+ source: '/old',
390
+ destination: '/new'
391
+ };
392
+ const rewrites = await withFontdue({
393
+ rewrites: async () => ({
394
+ beforeFiles: [userRule]
395
+ })
396
+ }).rewrites();
397
+ expect(rewrites.beforeFiles).toEqual([userRule]);
398
+ expect(rewrites.afterFiles).toEqual([]);
399
+ });
400
+ it('multi-tenant flat tree: refuses to start', async () => {
401
+ mockAppDir({
402
+ domainTree: false
403
+ });
404
+ stubMultiTenant();
405
+ const {
406
+ withFontdue
407
+ } = await importConfig();
408
+ expect(() => withFontdue({})).toThrow(/\[domain\]/);
409
+ });
410
+ });
411
+ describe('revalidate POST', () => {
412
+ it('multi-tenant: purges only the tenant tag', async () => {
413
+ stubMultiTenant();
414
+ const {
415
+ POST
416
+ } = await importRevalidate();
417
+ const response = await POST(new Request('http://internal/api/revalidate?domain=Acme.Fontdue.com', {
418
+ method: 'POST'
419
+ }));
420
+ expect(response.status).toBe(200);
421
+ expect(revalidateTag).toHaveBeenCalledExactlyOnceWith('graphql:acme.fontdue.com');
422
+ });
423
+ it('multi-tenant: 400s on a missing or invalid domain', async () => {
424
+ stubMultiTenant();
425
+ const {
426
+ POST
427
+ } = await importRevalidate();
428
+ for (const url of ['http://internal/api/revalidate', 'http://internal/api/revalidate?domain=not%20a%20domain']) {
429
+ const response = await POST(new Request(url, {
430
+ method: 'POST'
431
+ }));
432
+ expect(response.status).toBe(400);
433
+ }
434
+ expect(revalidateTag).not.toHaveBeenCalled();
435
+ });
436
+ it('single-tenant: purges the global graphql tag', async () => {
437
+ stubSingleTenant();
438
+ const {
439
+ POST
440
+ } = await importRevalidate();
441
+ const response = await POST(new Request('http://internal/api/revalidate', {
442
+ method: 'POST'
443
+ }));
444
+ expect(response.status).toBe(200);
445
+ expect(revalidateTag).toHaveBeenCalledExactlyOnceWith('graphql');
446
+ });
447
+ });
@@ -4,7 +4,7 @@ import _CartOrderRemoveDiscountMutation from "../../__generated__/CartOrderRemov
4
4
  import _CartOrderUpdateMutation from "../../__generated__/CartOrderUpdateMutation.graphql.js";
5
5
  import _CartOrder_UpdateErrors from "../../__generated__/CartOrder_UpdateErrors.graphql.js";
6
6
  import _CartOrderCompleteOrderMutation from "../../__generated__/CartOrderCompleteOrderMutation.graphql.js";
7
- import React, { Fragment, useRef, useState } from 'react';
7
+ import React, { Fragment, useEffect, useRef, useState } from 'react';
8
8
  import { commitMutation, graphql, useFragment, useRelayEnvironment } from 'react-relay';
9
9
  import Checkout from './Checkout.js';
10
10
  import CheckoutSteps from './CheckoutSteps.js';
@@ -21,6 +21,7 @@ import { textVariablesAllHaveText } from './utils.js';
21
21
  import { useStripe } from '@stripe/react-stripe-js';
22
22
  import { useDispatch } from 'react-redux';
23
23
  import CartState from './CartState.js';
24
+ import { sendOrderTracking } from './orderTracking.js';
24
25
 
25
26
  // note here we're only updating the order (by id).
26
27
  // careful not to update the current customer's reference
@@ -44,6 +45,13 @@ const CartOrder = _ref => {
44
45
  const environment = useRelayEnvironment();
45
46
  const stripe = useStripe();
46
47
  const dispatch = useDispatch();
48
+
49
+ // Capture analytics consent + attribution on the order for the purchase
50
+ // event the server emits on completion (from a Stripe webhook, where no
51
+ // browser context exists).
52
+ useEffect(() => {
53
+ if (open) sendOrderTracking(environment);
54
+ }, [open, environment]);
47
55
  const order = useFragment((_CartOrder_order.hash && _CartOrder_order.hash !== "25ef000c40000c4f76f973ac6ac7f593" && console.error("The definition of 'CartOrder_order' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _CartOrder_order), orderKey);
48
56
  const viewer = useFragment((_CartOrder_viewer.hash && _CartOrder_viewer.hash !== "8cf5ab5a33a474483a6c231cc44b0df1" && console.error("The definition of 'CartOrder_viewer' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _CartOrder_viewer), viewerKey);
49
57