fontdue-js 3.0.0-alpha10 → 3.0.0-alpha12

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 (46) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +165 -13
  3. package/dist/__generated__/FontdueAdminToolbarQuery.graphql.d.ts +20 -0
  4. package/dist/__generated__/FontdueAdminToolbarQuery.graphql.js +80 -0
  5. package/dist/__tests__/createFontdueFetch.test.js +222 -0
  6. package/dist/__tests__/imageLoader.test.js +62 -0
  7. package/dist/__tests__/networkFetch.test.js +22 -0
  8. package/dist/__tests__/nextAdapter.test.js +64 -0
  9. package/dist/__tests__/preview.test.js +96 -0
  10. package/dist/__tests__/previewServer.test.js +118 -0
  11. package/dist/components/BuyButton/index.d.ts +2 -2
  12. package/dist/components/BuyButton/index.js +3 -3
  13. package/dist/components/CharacterViewer/index.d.ts +2 -2
  14. package/dist/components/CharacterViewer/index.js +3 -3
  15. package/dist/components/ConfigContext.d.ts +10 -0
  16. package/dist/components/ConfigContext.js +5 -1
  17. package/dist/components/FontdueAdminToolbar/index.js +26 -25
  18. package/dist/components/FontdueProvider/index.js +2 -2
  19. package/dist/components/NewsletterSignup/index.d.ts +2 -2
  20. package/dist/components/NewsletterSignup/index.js +2 -2
  21. package/dist/components/TestFontsForm/index.d.ts +2 -2
  22. package/dist/components/TestFontsForm/index.js +2 -2
  23. package/dist/components/TypeTester/TypeTesterStandalone.d.ts +2 -2
  24. package/dist/components/TypeTester/TypeTesterStandalone.js +2 -2
  25. package/dist/components/TypeTesters/index.d.ts +2 -2
  26. package/dist/components/TypeTesters/index.js +3 -3
  27. package/dist/loadFontdueProviderQuery.d.ts +2 -1
  28. package/dist/loadFontdueProviderQuery.js +5 -2
  29. package/dist/next/image-loader.js +22 -3
  30. package/dist/next/tenant.js +39 -3
  31. package/dist/preview/constants.d.ts +3 -0
  32. package/dist/preview/constants.js +18 -0
  33. package/dist/preview/index.d.ts +49 -0
  34. package/dist/preview/index.js +164 -0
  35. package/dist/preview/server.d.ts +20 -0
  36. package/dist/preview/server.js +89 -0
  37. package/dist/relay/environment.d.ts +6 -0
  38. package/dist/relay/environment.js +4 -1
  39. package/dist/relay/loadSerializableQuery.d.ts +13 -3
  40. package/dist/relay/loadSerializableQuery.js +2 -0
  41. package/dist/relay/serverConfig.d.ts +3 -0
  42. package/dist/relay/serverConfig.js +26 -1
  43. package/dist/server/index.d.ts +37 -0
  44. package/dist/server/index.js +135 -0
  45. package/package.json +4 -1
  46. package/types/next-headers.d.ts +9 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## 3.0.0
2
+
3
+ In alpha on the `alpha` dist-tag — install with `npm install fontdue-js@alpha`.
4
+
5
+ - **Framework-agnostic.** fontdue-js now works in any React SSR or client-only environment — Astro, React Router 7, TanStack Start, Vike, Remix, and the existing Next.js App Router — not just Next.js. Each supported framework has a worked example repo (see the README "Examples"). The package is now ESM-only and ships an `exports` map, so TypeScript consumers need `moduleResolution` set to `bundler`, `node16`, or `nodenext`.
6
+ - **Admin preview for every framework.** A preview toolbar — rendered by `<FontdueProvider>` and shown only to logged-in admins — reveals hidden (unpublished) fonts site-wide. New entry points implement a portable contract:
7
+ - `fontdue-js/preview` — `handlePreviewRequest` (a Web-standard enter/exit route handler), `readPreviewToken`, `previewAuthHeaders`, and the cookie/endpoint constants.
8
+ - `fontdue-js/preview/server` — `runWithPreview`, which holds the preview token in `AsyncLocalStorage` for the duration of a request so every server fetch and preload forwards it automatically, and forces preview responses out of shared/CDN caches so an admin's render is never served to the public.
9
+
10
+ On Next.js (which has no app-installable ambient context) the adapter's `prepareFontdueRender` folds the draft-mode token into the per-render config instead — the same effect, so the embedded components reveal hidden fonts server-side. See the README "Admin preview".
11
+ - **One server fetch for every framework.** `fontdue-js/server` exports `createFontdueFetch({ url?, headers?, cacheTags? })` — a ready-made server-side GraphQL fetcher with URL resolution, error handling, `FontdueNotFoundError`, and automatic preview-token forwarding. Each input resolves per call from the explicit option, the per-render config (`runWithPreview` or the Next adapter), then the environment, and passing `cacheTags` opts the fetch into Next's data cache + `/api/revalidate` (inert in other runtimes) — so Next and the other frameworks now share the same fetcher rather than Next hand-rolling its own. See the README "Server-side GraphQL fetches".
12
+ - **Server-rendered embeds.** Components now render their full HTML on the server where the framework supports it (v2 hydrated some empty and fetched on the client), via the `load{Component}Query()` preload helpers.
13
+ - **Migrating from v2 (Next.js)** is a small, mechanical upgrade — see the README "Migrating a Next.js site from v2". `useFontStyle` is renamed to `useFont` (the old name still works as an alias).
14
+
1
15
  ## 2.28.0
2
16
 
3
17
  - 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.
package/README.md CHANGED
@@ -417,27 +417,26 @@ fontdue-js's own server-side fetches opt into Next's data cache (and the `graphq
417
417
 
418
418
  ### Your own GraphQL fetches
419
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`:
420
+ Use the same [`createFontdueFetch`](#server-side-graphql-fetches) as every other framework, and give it the current render's endpoint so it ties into Next's data cache and `/api/revalidate`. `currentFontdueEndpoint()` (from `fontdue-js/next`) describes that endpoint — the base origin, required headers, and the per-site cache tags:
421
421
 
422
422
  ```ts
423
+ import { createFontdueFetch } from "fontdue-js/server";
423
424
  import { currentFontdueEndpoint } from "fontdue-js/next";
424
425
 
425
- export async function fetchGraphql(query: string, variables?: unknown) {
426
+ export async function fetchGraphql<Q>(name: string, query: string, variables?: unknown) {
426
427
  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 },
428
+ const fetchFontdue = createFontdueFetch({
429
+ url: endpoint.origin,
430
+ headers: endpoint.headers,
431
+ // Opts the fetch into Next's data cache (force-cache) and tags it so the
432
+ // deploy hook purges it. Drop cacheTags (pass `[]`) on a preview render.
433
+ cacheTags: endpoint.tags,
435
434
  });
436
- return (await response.json()).data;
435
+ return fetchFontdue<Q>(name, query, variables);
437
436
  }
438
437
  ```
439
438
 
440
- The shape is exported as `type FontdueEndpoint`.
439
+ `currentFontdueEndpoint()` resolves the per-render tenant in multi-tenant mode (after `prepareFontdueRender`) or the `NEXT_PUBLIC_FONTDUE_URL` site otherwise; the shape is exported as `type FontdueEndpoint`. Route handlers render outside React, so pass the endpoint they got from `prepareFontdueRender` explicitly. For preview, see [Admin preview → Next.js](#nextjs).
441
440
 
442
441
  ## Migrating a Next.js site from v2
443
442
 
@@ -469,7 +468,7 @@ For a site built on the [example repo](https://github.com/fontdue/example-next)
469
468
 
470
469
  Keep the Deploy hook URL in your Fontdue admin pointed at it.
471
470
 
472
- 4. **Delete caching workarounds you no longer need.** `export const fetchCache = "default-cache"` in the layout (if you added it) is obsolete — fontdue-js opts its own fetches into the data cache now. Your app's own GraphQL fetches should pass `cache: "force-cache"` and `next: { tags: endpoint.tags }` explicitly — see [Your own GraphQL fetches](#your-own-graphql-fetches).
471
+ 4. **Delete caching workarounds you no longer need.** `export const fetchCache = "default-cache"` in the layout (if you added it) is obsolete — fontdue-js opts its own fetches into the data cache now. For your app's own GraphQL fetches, move the transport to `createFontdueFetch` and pass it `cacheTags: endpoint.tags` so caching and `/api/revalidate` are handled for you — see [Your own GraphQL fetches](#your-own-graphql-fetches).
473
472
 
474
473
  5. **Remove the `url` prop from `<FontdueProvider>` if you passed one.** It never configured server-side fetches (server components have no context); v3 resolves everything from `NEXT_PUBLIC_FONTDUE_URL`. The prop still works as a client-side runtime override, but with the env var set you don't need it.
475
474
 
@@ -479,6 +478,159 @@ What you get for it: server components now render the embeds' full HTML on the s
479
478
 
480
479
  If you're starting fresh instead of migrating, fork the example repo — it ships in this shape already.
481
480
 
481
+ ## Server-side GraphQL fetches
482
+
483
+ Beyond the preload helpers, you'll often run your own [GraphQL](https://docs.fontdue.com/graphql-api) queries server-side — page chrome, metadata, custom sections. `fontdue-js/server` exports a ready-made fetcher so you don't hand-roll the transport:
484
+
485
+ ```ts
486
+ import { createFontdueFetch } from "fontdue-js/server";
487
+
488
+ // One fetcher for the whole app. Resolves the Fontdue URL from the environment
489
+ // (FONTDUE_URL / PUBLIC_FONTDUE_URL / VITE_FONTDUE_URL).
490
+ export const fetchGraphql = createFontdueFetch();
491
+
492
+ // In a loader / frontmatter / server component:
493
+ const data = await fetchGraphql<IndexQuery>("Index", indexQuery, { slug });
494
+ ```
495
+
496
+ `createFontdueFetch({ url?, headers?, cacheTags? })` returns `fetchGraphql(operationName, query, variables?)`. It POSTs to `/graphql`, unwraps `data`, throws on GraphQL errors, and throws `FontdueNotFoundError` when the host doesn't resolve to a site — catch it to render your framework's 404. It's the same fetcher in every framework; each input resolves per call from the explicit option, then the per-render config (set by `runWithPreview` or the Next adapter), then the environment:
497
+
498
+ - **url** — option → per-render tenant URL → `FONTDUE_URL` / `PUBLIC_FONTDUE_URL` / `VITE_FONTDUE_URL`.
499
+ - **headers** — merged ambient + explicit (explicit wins); this is how the [admin preview](#admin-preview) token is forwarded automatically.
500
+ - **cacheTags** — when present, the fetch opts into Next's data cache (`force-cache` + tags) so `/api/revalidate` can purge it; absent/empty leaves it uncached. The Next hints are inert in other runtimes, where HTML is cached at the response/CDN layer instead.
501
+
502
+ **Upgrading a hand-rolled fetch.** If you already have something like this:
503
+
504
+ ```ts
505
+ // Before — hand-rolled.
506
+ async function fetchGraphql(name, query, variables) {
507
+ const res = await fetch(`${import.meta.env.PUBLIC_FONTDUE_URL}/graphql`, {
508
+ method: "POST",
509
+ headers: { "content-type": "application/json" },
510
+ body: JSON.stringify({ query, variables }),
511
+ });
512
+ const json = await res.json();
513
+ if (json.errors) throw new Error(json.errors[0].message);
514
+ return json.data;
515
+ }
516
+ ```
517
+
518
+ replace it with:
519
+
520
+ ```ts
521
+ // After.
522
+ import { createFontdueFetch } from "fontdue-js/server";
523
+ export const fetchGraphql = createFontdueFetch();
524
+ ```
525
+
526
+ You get URL resolution, error handling, `FontdueNotFoundError`, and automatic preview-token forwarding for free.
527
+
528
+ > **Next.js:** the same `createFontdueFetch` — pass it the endpoint's `cacheTags` so it ties into Next's data cache and the `/api/revalidate` deploy hook. See [Your own GraphQL fetches](#your-own-graphql-fetches) under the Next adapter.
529
+
530
+ ## Admin preview
531
+
532
+ Logged-in Fontdue admins get a preview toolbar — rendered automatically by `<FontdueProvider>`, hidden for everyone else — that reveals **hidden (unpublished) fonts** across the whole site. The toolbar brokers a short-lived admin token and POSTs it to a small preview route on your own origin; from then on, server renders forward the token so GraphQL returns the unpublished content. The public never has the cookie, so their renders stay sessionless and cacheable.
533
+
534
+ Two entry points cover this: `fontdue-js/preview` (the portable cookie contract) and `fontdue-js/preview/server` (the ambient wiring).
535
+
536
+ **1. Add the preview route** at `/api/preview` — the toolbar POSTs to enter preview and DELETEs to exit. `handlePreviewRequest` is a Web-standard `Request → Response` handler, so it drops into any Fetch-API framework:
537
+
538
+ ```ts
539
+ // Astro — src/pages/api/preview.ts
540
+ import { handlePreviewRequest } from "fontdue-js/preview";
541
+ export const ALL = ({ request }) => handlePreviewRequest(request);
542
+ export const prerender = false;
543
+ ```
544
+
545
+ ```ts
546
+ // React Router 7 — app/routes/api.preview.ts
547
+ import { handlePreviewRequest } from "fontdue-js/preview";
548
+ export const action = ({ request }) => handlePreviewRequest(request);
549
+ ```
550
+
551
+ The path is configurable via `config.preview.endpoint` on `<FontdueProvider>` (default `/api/preview`) — mount the route to match.
552
+
553
+ **2. Forward the token on server renders.** The recommended way is ambient: wrap each request in `runWithPreview` (from `fontdue-js/preview/server`) in your framework's middleware. While it's active, every `createFontdueFetch` call and every `load*Query()` preload forwards the token automatically — no per-call plumbing — and preview responses are forced out of any shared/CDN cache so an admin's render is never served to the public.
554
+
555
+ ```ts
556
+ // Astro — src/middleware.ts
557
+ import { runWithPreview } from "fontdue-js/preview/server";
558
+ export const onRequest = (ctx, next) => runWithPreview(ctx.request, next);
559
+ ```
560
+
561
+ ```ts
562
+ // React Router 7 — root route (with future.v8_middleware enabled)
563
+ import { runWithPreview } from "fontdue-js/preview/server";
564
+ export const middleware = [({ request }, next) => runWithPreview(request, next)];
565
+ ```
566
+
567
+ `runWithPreview` uses `AsyncLocalStorage`, so it works wherever middleware shares a runtime with the render — Node (the default SSR target on Netlify/Vercel), Deno, Bun.
568
+
569
+ **Explicit alternative.** Where the ambient context can't propagate (e.g. middleware running in a separate runtime from the render, such as Astro's `edgeMiddleware: true`), read the token yourself and pass it as the `{ headers }` option, which always overrides the ambient context:
570
+
571
+ ```ts
572
+ import { readPreviewToken, previewAuthHeaders } from "fontdue-js/preview";
573
+
574
+ const headers = previewAuthHeaders(readPreviewToken(request.headers.get("cookie")));
575
+ const fetchGraphql = createFontdueFetch({ headers });
576
+ const preload = await loadTypeTesterQuery(vars, { headers });
577
+ ```
578
+
579
+ `previewAuthHeaders` returns `{}` when there's no token, so it's always safe to pass.
580
+
581
+ ### Next.js
582
+
583
+ Next uses [draft mode](https://nextjs.org/docs/app/building-your-application/configuring/draft-mode) rather than ambient context. The route layers `draftMode()` on top of `handlePreviewRequest`:
584
+
585
+ ```ts
586
+ // app/api/preview/route.ts
587
+ import { draftMode } from "next/headers";
588
+ import { handlePreviewRequest } from "fontdue-js/preview";
589
+
590
+ export async function POST(request: Request) {
591
+ const response = await handlePreviewRequest(request);
592
+ if (response.ok) (await draftMode()).enable();
593
+ return response;
594
+ }
595
+
596
+ export async function DELETE(request: Request) {
597
+ const response = await handlePreviewRequest(request);
598
+ (await draftMode()).disable();
599
+ return response;
600
+ }
601
+ ```
602
+
603
+ Two things then forward the token on a Next render:
604
+
605
+ - **`prepareFontdueRender`** — the call you already make at the top of every page folds the draft-mode token into the per-render config, so the embedded components (type testers, store) reveal hidden fonts server-side automatically. This is the Next counterpart to `runWithPreview`.
606
+ - **Your own fetch** reads draft mode and hands the token to `createFontdueFetch`, dropping the cache tags so the render stays live:
607
+
608
+ ```ts
609
+ import { draftMode, cookies } from "next/headers";
610
+ import { createFontdueFetch } from "fontdue-js/server";
611
+ import { currentFontdueEndpoint } from "fontdue-js/next";
612
+ import { PREVIEW_TOKEN_COOKIE, previewAuthHeaders } from "fontdue-js/preview";
613
+
614
+ export async function fetchGraphql<Q>(name: string, query: string, variables?: unknown) {
615
+ const endpoint = currentFontdueEndpoint();
616
+ // draftMode()/cookies() are request-scoped — guard build-time static
617
+ // generation, where they throw, as "not preview".
618
+ const isPreview = (await draftMode()).isEnabled;
619
+ const token = isPreview ? (await cookies()).get(PREVIEW_TOKEN_COOKIE)?.value : undefined;
620
+
621
+ const fetchFontdue = createFontdueFetch({
622
+ url: endpoint.origin,
623
+ headers: { ...endpoint.headers, ...previewAuthHeaders(token) },
624
+ // Public renders are tagged + cached; preview renders pass no tags so they
625
+ // stay live and reveal hidden fonts.
626
+ cacheTags: isPreview ? [] : endpoint.tags,
627
+ });
628
+ return fetchFontdue<Q>(name, query, variables);
629
+ }
630
+ ```
631
+
632
+ The example repos wire this up end to end for each framework.
633
+
482
634
  ## UI config
483
635
 
484
636
  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).
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @generated SignedSource<<c46bd30612c05d6cd1c334c403aafb35>>
3
+ * @lightSyntaxTransform
4
+ * @nogrep
5
+ */
6
+ import { ConcreteRequest } from 'relay-runtime';
7
+ export type FontdueAdminToolbarQuery$variables = Record<PropertyKey, never>;
8
+ export type FontdueAdminToolbarQuery$data = {
9
+ readonly viewer: {
10
+ readonly adminUser: {
11
+ readonly name: string | null;
12
+ } | null;
13
+ };
14
+ };
15
+ export type FontdueAdminToolbarQuery = {
16
+ response: FontdueAdminToolbarQuery$data;
17
+ variables: FontdueAdminToolbarQuery$variables;
18
+ };
19
+ declare const node: ConcreteRequest;
20
+ export default node;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @generated SignedSource<<c46bd30612c05d6cd1c334c403aafb35>>
3
+ * @lightSyntaxTransform
4
+ * @nogrep
5
+ */
6
+
7
+ /* tslint:disable */
8
+ /* eslint-disable */
9
+ // @ts-nocheck
10
+
11
+ const node = function () {
12
+ var v0 = {
13
+ "alias": null,
14
+ "args": null,
15
+ "concreteType": "User",
16
+ "kind": "LinkedField",
17
+ "name": "adminUser",
18
+ "plural": false,
19
+ "selections": [{
20
+ "alias": null,
21
+ "args": null,
22
+ "kind": "ScalarField",
23
+ "name": "name",
24
+ "storageKey": null
25
+ }],
26
+ "storageKey": null
27
+ };
28
+ return {
29
+ "fragment": {
30
+ "argumentDefinitions": [],
31
+ "kind": "Fragment",
32
+ "metadata": null,
33
+ "name": "FontdueAdminToolbarQuery",
34
+ "selections": [{
35
+ "alias": null,
36
+ "args": null,
37
+ "concreteType": "Viewer",
38
+ "kind": "LinkedField",
39
+ "name": "viewer",
40
+ "plural": false,
41
+ "selections": [v0 /*: any*/],
42
+ "storageKey": null
43
+ }],
44
+ "type": "RootQueryType",
45
+ "abstractKey": null
46
+ },
47
+ "kind": "Request",
48
+ "operation": {
49
+ "argumentDefinitions": [],
50
+ "kind": "Operation",
51
+ "name": "FontdueAdminToolbarQuery",
52
+ "selections": [{
53
+ "alias": null,
54
+ "args": null,
55
+ "concreteType": "Viewer",
56
+ "kind": "LinkedField",
57
+ "name": "viewer",
58
+ "plural": false,
59
+ "selections": [v0 /*: any*/, {
60
+ "alias": null,
61
+ "args": null,
62
+ "kind": "ScalarField",
63
+ "name": "id",
64
+ "storageKey": null
65
+ }],
66
+ "storageKey": null
67
+ }]
68
+ },
69
+ "params": {
70
+ "cacheID": "a48946b192fea90930ffac67eff7b1b5",
71
+ "id": null,
72
+ "metadata": {},
73
+ "name": "FontdueAdminToolbarQuery",
74
+ "operationKind": "query",
75
+ "text": "query FontdueAdminToolbarQuery {\n viewer {\n adminUser {\n name\n }\n id\n }\n}\n"
76
+ }
77
+ };
78
+ }();
79
+ node.hash = "8660b45f086137d249eee82459ad648d";
80
+ export default node;
@@ -0,0 +1,222 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { createFontdueFetch, FontdueNotFoundError } from '../server/index.js';
3
+ import { registerAmbientConfigResolver } from '../relay/serverConfig.js';
4
+ beforeEach(() => {
5
+ vi.unstubAllEnvs();
6
+ vi.unstubAllGlobals();
7
+ });
8
+
9
+ // Several tests inject a per-render config through the ambient resolver seam
10
+ // (the same seam runWithPreview and the Next slot use). Always clear it.
11
+ afterEach(() => {
12
+ registerAmbientConfigResolver(() => undefined);
13
+ });
14
+ function withConfig(config) {
15
+ registerAmbientConfigResolver(() => config);
16
+ }
17
+ function mockFetch(impl) {
18
+ const fetchMock = vi.fn(async (url, init) => impl(url, init));
19
+ vi.stubGlobal('fetch', fetchMock);
20
+ return fetchMock;
21
+ }
22
+ describe('createFontdueFetch', () => {
23
+ it('posts the query to the configured URL and unwraps data', async () => {
24
+ const fetchMock = mockFetch(() => ({
25
+ status: 200,
26
+ json: async () => ({
27
+ data: {
28
+ viewer: {
29
+ id: '1'
30
+ }
31
+ }
32
+ })
33
+ }));
34
+ const fetchGraphql = createFontdueFetch({
35
+ url: 'https://acme.fontdue.com'
36
+ });
37
+ const data = await fetchGraphql('Index', 'query Index { viewer { id } }', {
38
+ a: 1
39
+ });
40
+ expect(data).toEqual({
41
+ viewer: {
42
+ id: '1'
43
+ }
44
+ });
45
+ const [url, init] = fetchMock.mock.calls[0];
46
+ expect(url).toBe('https://acme.fontdue.com/graphql?query=Index');
47
+ expect(init.method).toBe('POST');
48
+ expect(JSON.parse(init.body)).toEqual({
49
+ query: 'query Index { viewer { id } }',
50
+ variables: {
51
+ a: 1
52
+ }
53
+ });
54
+ });
55
+ it('forwards bound headers (the preview Bearer token) on every call', async () => {
56
+ const fetchMock = mockFetch(() => ({
57
+ status: 200,
58
+ json: async () => ({
59
+ data: {}
60
+ })
61
+ }));
62
+ const fetchGraphql = createFontdueFetch({
63
+ url: 'https://acme.fontdue.com',
64
+ headers: {
65
+ authorization: 'Bearer admin-tok'
66
+ }
67
+ });
68
+ await fetchGraphql('A', 'query A { __typename }');
69
+ await fetchGraphql('B', 'query B { __typename }');
70
+ for (const call of fetchMock.mock.calls) {
71
+ const init = call[1];
72
+ expect(init.headers.authorization).toBe('Bearer admin-tok');
73
+ }
74
+ });
75
+ it('throws FontdueNotFoundError on a 404 (host did not resolve)', async () => {
76
+ mockFetch(() => ({
77
+ status: 404,
78
+ json: async () => ({})
79
+ }));
80
+ const fetchGraphql = createFontdueFetch({
81
+ url: 'https://acme.fontdue.com'
82
+ });
83
+ await expect(fetchGraphql('X', 'query X { __typename }')).rejects.toBeInstanceOf(FontdueNotFoundError);
84
+ });
85
+ it('throws on GraphQL errors in a 200 response', async () => {
86
+ mockFetch(() => ({
87
+ status: 200,
88
+ json: async () => ({
89
+ errors: [{
90
+ message: 'boom'
91
+ }]
92
+ })
93
+ }));
94
+ const fetchGraphql = createFontdueFetch({
95
+ url: 'https://acme.fontdue.com'
96
+ });
97
+ await expect(fetchGraphql('X', 'query X { __typename }')).rejects.toThrow('boom');
98
+ });
99
+ it('resolves the URL from the environment when none is passed', async () => {
100
+ vi.stubEnv('FONTDUE_URL', 'https://env.fontdue.com');
101
+ const fetchMock = mockFetch(() => ({
102
+ status: 200,
103
+ json: async () => ({
104
+ data: {}
105
+ })
106
+ }));
107
+ const fetchGraphql = createFontdueFetch();
108
+ await fetchGraphql('Q', 'query Q { __typename }');
109
+ expect(fetchMock.mock.calls[0][0]).toBe('https://env.fontdue.com/graphql?query=Q');
110
+ });
111
+ it('throws a helpful error when no URL is configured (resolved per call)', async () => {
112
+ mockFetch(() => ({
113
+ status: 200,
114
+ json: async () => ({
115
+ data: {}
116
+ })
117
+ }));
118
+ const fetchGraphql = createFontdueFetch();
119
+ await expect(fetchGraphql('Q', 'query Q { __typename }')).rejects.toThrow(/no Fontdue URL configured/);
120
+ });
121
+ describe('Next data cache tags', () => {
122
+ it('opts into force-cache + tags when cacheTags are given', async () => {
123
+ var _init$next;
124
+ const fetchMock = mockFetch(() => ({
125
+ status: 200,
126
+ json: async () => ({
127
+ data: {}
128
+ })
129
+ }));
130
+ const fetchGraphql = createFontdueFetch({
131
+ url: 'https://acme.fontdue.com',
132
+ cacheTags: ['graphql:acme.fontdue.com']
133
+ });
134
+ await fetchGraphql('Q', 'query Q { __typename }');
135
+ const init = fetchMock.mock.calls[0][1];
136
+ expect(init.cache).toBe('force-cache');
137
+ // The global `graphql` tag is prepended automatically.
138
+ expect((_init$next = init.next) === null || _init$next === void 0 ? void 0 : _init$next.tags).toEqual(['graphql', 'graphql:acme.fontdue.com']);
139
+ });
140
+ it('leaves the fetch uncached when no cacheTags are given', async () => {
141
+ const fetchMock = mockFetch(() => ({
142
+ status: 200,
143
+ json: async () => ({
144
+ data: {}
145
+ })
146
+ }));
147
+ const fetchGraphql = createFontdueFetch({
148
+ url: 'https://acme.fontdue.com'
149
+ });
150
+ await fetchGraphql('Q', 'query Q { __typename }');
151
+ const init = fetchMock.mock.calls[0][1];
152
+ expect(init.cache).toBeUndefined();
153
+ expect(init.next).toBeUndefined();
154
+ });
155
+ it('treats an empty cacheTags list as uncached (preview renders)', async () => {
156
+ const fetchMock = mockFetch(() => ({
157
+ status: 200,
158
+ json: async () => ({
159
+ data: {}
160
+ })
161
+ }));
162
+ const fetchGraphql = createFontdueFetch({
163
+ url: 'https://acme.fontdue.com',
164
+ cacheTags: []
165
+ });
166
+ await fetchGraphql('Q', 'query Q { __typename }');
167
+ const init = fetchMock.mock.calls[0][1];
168
+ expect(init.cache).toBeUndefined();
169
+ expect(init.next).toBeUndefined();
170
+ });
171
+ });
172
+ describe('per-render config (the Next slot / ambient resolver)', () => {
173
+ it('resolves url, headers and cacheTags from the config', async () => {
174
+ var _init$next2;
175
+ const fetchMock = mockFetch(() => ({
176
+ status: 200,
177
+ json: async () => ({
178
+ data: {}
179
+ })
180
+ }));
181
+ withConfig({
182
+ url: 'https://tenant.fontdue.com',
183
+ headers: {
184
+ 'x-forwarded-host': 'tenant.fontdue.com'
185
+ },
186
+ cacheTags: ['graphql:tenant.fontdue.com']
187
+ });
188
+
189
+ // No options: a module-level fetcher picks up the current render's config.
190
+ const fetchGraphql = createFontdueFetch();
191
+ await fetchGraphql('Q', 'query Q { __typename }');
192
+ const [url, init] = fetchMock.mock.calls[0];
193
+ expect(url).toBe('https://tenant.fontdue.com/graphql?query=Q');
194
+ expect(init.headers['x-forwarded-host']).toBe('tenant.fontdue.com');
195
+ expect(init.cache).toBe('force-cache');
196
+ expect((_init$next2 = init.next) === null || _init$next2 === void 0 ? void 0 : _init$next2.tags).toEqual(['graphql', 'graphql:tenant.fontdue.com']);
197
+ });
198
+ it('explicit options override the config (url and cacheTags)', async () => {
199
+ const fetchMock = mockFetch(() => ({
200
+ status: 200,
201
+ json: async () => ({
202
+ data: {}
203
+ })
204
+ }));
205
+ withConfig({
206
+ url: 'https://tenant.fontdue.com',
207
+ cacheTags: ['graphql:tenant.fontdue.com']
208
+ });
209
+
210
+ // Explicit url + empty cacheTags (e.g. a preview render) win.
211
+ const fetchGraphql = createFontdueFetch({
212
+ url: 'https://explicit.fontdue.com',
213
+ cacheTags: []
214
+ });
215
+ await fetchGraphql('Q', 'query Q { __typename }');
216
+ const [url, init] = fetchMock.mock.calls[0];
217
+ expect(url).toBe('https://explicit.fontdue.com/graphql?query=Q');
218
+ expect(init.cache).toBeUndefined();
219
+ expect(init.next).toBeUndefined();
220
+ });
221
+ });
222
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import fontdueImageLoader from '../next/image-loader.js';
3
+
4
+ // The loader reads NEXT_PUBLIC_FONTDUE_IMAGE_HOST / _ORIGINS at call time, so
5
+ // each test stubs them and we clear stubs afterwards.
6
+ afterEach(() => {
7
+ vi.unstubAllEnvs();
8
+ });
9
+ function load(src) {
10
+ let origins = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : '';
11
+ let host = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'img.fontdue.xyz';
12
+ vi.stubEnv('NEXT_PUBLIC_FONTDUE_IMAGE_HOST', host);
13
+ vi.stubEnv('NEXT_PUBLIC_FONTDUE_IMAGE_ORIGINS', origins);
14
+ return fontdueImageLoader({
15
+ src,
16
+ width: 800,
17
+ quality: 75
18
+ });
19
+ }
20
+ const transformed = src => `https://img.fontdue.xyz/cdn-cgi/image/width=800,quality=75,format=auto/${src}`;
21
+ describe('fontdueImageLoader', () => {
22
+ it('transforms any absolute src when no allowlist is set', () => {
23
+ const src = 'https://anywhere.example/a.jpg';
24
+ expect(load(src)).toBe(transformed(src));
25
+ });
26
+ it('serves relative srcs as-is', () => {
27
+ expect(load('/logo.svg', 'cdn.fontdue.xyz')).toBe('/logo.svg');
28
+ });
29
+ it('serves srcs as-is when no transform host is configured', () => {
30
+ const src = 'https://cdn.fontdue.xyz/a.jpg';
31
+ expect(load(src, 'cdn.fontdue.xyz', '')).toBe(src);
32
+ });
33
+ describe('origins allowlist', () => {
34
+ it('transforms an exact hostname match and skips a non-match', () => {
35
+ const ok = 'https://cdn.fontdue.xyz/a.jpg';
36
+ const no = 'https://other.example/a.jpg';
37
+ expect(load(ok, 'cdn.fontdue.xyz')).toBe(transformed(ok));
38
+ expect(load(no, 'cdn.fontdue.xyz')).toBe(no);
39
+ });
40
+ it('matches a "*." wildcard against any subdomain', () => {
41
+ const cdn = 'https://cdn.fontdue.xyz/a.jpg';
42
+ const assets = 'https://assets.fontdue.xyz/a.jpg';
43
+ expect(load(cdn, '*.fontdue.xyz')).toBe(transformed(cdn));
44
+ expect(load(assets, '*.fontdue.xyz')).toBe(transformed(assets));
45
+ });
46
+ it('does not let a wildcard match the apex or a lookalike domain', () => {
47
+ const apex = 'https://fontdue.xyz/a.jpg';
48
+ const lookalike = 'https://evilfontdue.xyz/a.jpg';
49
+ expect(load(apex, '*.fontdue.xyz')).toBe(apex);
50
+ expect(load(lookalike, '*.fontdue.xyz')).toBe(lookalike);
51
+ });
52
+ it('ignores an optional scheme on allowlist entries', () => {
53
+ const src = 'https://cdn.fontdue.xyz/a.jpg';
54
+ expect(load(src, 'https://cdn.fontdue.xyz')).toBe(transformed(src));
55
+ expect(load(src, 'https://*.fontdue.xyz')).toBe(transformed(src));
56
+ });
57
+ it('supports a comma-separated list', () => {
58
+ const src = 'https://assets.fontdue.xyz/a.jpg';
59
+ expect(load(src, 'cdn.fontdue.xyz, assets.fontdue.xyz')).toBe(transformed(src));
60
+ });
61
+ });
62
+ });
@@ -63,4 +63,26 @@ describe('createNetworkFetch (server)', () => {
63
63
  expect(options.headers['x-forwarded-host']).toBe('acme.fontdue.com');
64
64
  expect(options.next.tags).toContain('graphql:acme.fontdue.com');
65
65
  });
66
+ it('forwards per-call options.headers (e.g. a preview Bearer token)', async () => {
67
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
68
+ const fetchMock = vi.fn(async () => ({
69
+ json: async () => ({
70
+ data: {}
71
+ })
72
+ }));
73
+ vi.stubGlobal('fetch', fetchMock);
74
+
75
+ // This is the non-RSC path: the render-scoped serverConfig store is a
76
+ // no-op outside an RSC render, so apps forward the token per call instead.
77
+ const {
78
+ createNetworkFetch
79
+ } = await import("../relay/environment.js");
80
+ await createNetworkFetch({
81
+ headers: {
82
+ authorization: 'Bearer preview-tok'
83
+ }
84
+ })(request, {});
85
+ const [, options] = fetchMock.mock.calls[0];
86
+ expect(options.headers.authorization).toBe('Bearer preview-tok');
87
+ });
66
88
  });