fontdue-js 3.0.0-alpha8 → 3.0.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 (146) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +253 -1
  3. package/dist/__generated__/CartOrderCompleteOrderMutation.graphql.d.ts +1 -1
  4. package/dist/__generated__/CartOrderCompleteOrderMutation.graphql.js +9 -3
  5. package/dist/__generated__/CartOrderRemoveDiscountMutation.graphql.d.ts +1 -1
  6. package/dist/__generated__/CartOrderRemoveDiscountMutation.graphql.js +9 -3
  7. package/dist/__generated__/CartOrderUpdateMutation.graphql.d.ts +1 -1
  8. package/dist/__generated__/CartOrderUpdateMutation.graphql.js +9 -3
  9. package/dist/__generated__/CartQuery.graphql.d.ts +1 -1
  10. package/dist/__generated__/CartQuery.graphql.js +9 -3
  11. package/dist/__generated__/CartStateUpdateMutation.graphql.d.ts +1 -1
  12. package/dist/__generated__/CartStateUpdateMutation.graphql.js +9 -3
  13. package/dist/__generated__/CharacterViewerIDQuery.graphql.d.ts +1 -1
  14. package/dist/__generated__/CharacterViewerIDQuery.graphql.js +9 -3
  15. package/dist/__generated__/CharacterViewerSlugQuery.graphql.d.ts +1 -1
  16. package/dist/__generated__/CharacterViewerSlugQuery.graphql.js +9 -3
  17. package/dist/__generated__/CharacterViewerStyleRefetchQuery.graphql.d.ts +1 -1
  18. package/dist/__generated__/CharacterViewerStyleRefetchQuery.graphql.js +9 -3
  19. package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.d.ts +1 -1
  20. package/dist/__generated__/CheckoutUpdateCustomerMutation.graphql.js +9 -3
  21. package/dist/__generated__/CheckoutUpdateOrderMutation.graphql.d.ts +1 -1
  22. package/dist/__generated__/CheckoutUpdateOrderMutation.graphql.js +9 -3
  23. package/dist/__generated__/CollectionAa_Query.graphql.d.ts +1 -1
  24. package/dist/__generated__/CollectionAa_Query.graphql.js +9 -3
  25. package/dist/__generated__/FontFamiliesQuery.graphql.d.ts +1 -1
  26. package/dist/__generated__/FontFamiliesQuery.graphql.js +9 -3
  27. package/dist/__generated__/FontdueAdminToolbarQuery.graphql.d.ts +20 -0
  28. package/dist/__generated__/FontdueAdminToolbarQuery.graphql.js +80 -0
  29. package/dist/__generated__/FontdueAdminToolbarTokenMutation.graphql.d.ts +18 -0
  30. package/dist/__generated__/FontdueAdminToolbarTokenMutation.graphql.js +56 -0
  31. package/dist/__generated__/PrecartAddToCartMutation.graphql.d.ts +1 -1
  32. package/dist/__generated__/PrecartAddToCartMutation.graphql.js +9 -3
  33. package/dist/__generated__/StoreModalCartQuery.graphql.d.ts +1 -1
  34. package/dist/__generated__/StoreModalCartQuery.graphql.js +9 -3
  35. package/dist/__generated__/StoreModalContainerQuery.graphql.d.ts +1 -1
  36. package/dist/__generated__/StoreModalContainerQuery.graphql.js +9 -3
  37. package/dist/__generated__/StoreModalIndexQuery.graphql.d.ts +1 -1
  38. package/dist/__generated__/StoreModalIndexQuery.graphql.js +9 -3
  39. package/dist/__generated__/StoreModalProductQuery.graphql.d.ts +1 -1
  40. package/dist/__generated__/StoreModalProductQuery.graphql.js +9 -3
  41. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.d.ts +1 -1
  42. package/dist/__generated__/StoreModalProductRefetchQuery.graphql.js +9 -3
  43. package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.d.ts +1 -1
  44. package/dist/__generated__/TestFontsFormUpdateCustomerMutation.graphql.js +9 -3
  45. package/dist/__generated__/TypeTesterStandaloneChangedStylesQuery.graphql.d.ts +1 -1
  46. package/dist/__generated__/TypeTesterStandaloneChangedStylesQuery.graphql.js +9 -3
  47. package/dist/__generated__/TypeTesterStandaloneQuery.graphql.d.ts +1 -1
  48. package/dist/__generated__/TypeTesterStandaloneQuery.graphql.js +9 -3
  49. package/dist/__generated__/TypeTestersChangedStylesQuery.graphql.d.ts +1 -1
  50. package/dist/__generated__/TypeTestersChangedStylesQuery.graphql.js +9 -3
  51. package/dist/__generated__/TypeTestersIDQuery.graphql.d.ts +1 -1
  52. package/dist/__generated__/TypeTestersIDQuery.graphql.js +9 -3
  53. package/dist/__generated__/TypeTestersRefetchQuery.graphql.d.ts +1 -1
  54. package/dist/__generated__/TypeTestersRefetchQuery.graphql.js +9 -3
  55. package/dist/__generated__/TypeTestersSlugQuery.graphql.d.ts +1 -1
  56. package/dist/__generated__/TypeTestersSlugQuery.graphql.js +9 -3
  57. package/dist/__generated__/orderTrackingUpdateOrderTrackingMutation.graphql.js +1 -8
  58. package/dist/__generated__/useFontStyle_fontStyle.graphql.d.ts +2 -1
  59. package/dist/__generated__/useFontStyle_fontStyle.graphql.js +8 -2
  60. package/dist/__tests__/createFontdueFetch.test.js +276 -0
  61. package/dist/__tests__/imageLoader.test.js +62 -0
  62. package/dist/__tests__/metricFallback.test.js +74 -0
  63. package/dist/__tests__/networkFetch.test.js +188 -0
  64. package/dist/__tests__/nextAdapter.test.js +273 -18
  65. package/dist/__tests__/preview.test.js +217 -0
  66. package/dist/__tests__/previewServer.test.js +118 -0
  67. package/dist/__tests__/previewState.test.js +63 -0
  68. package/dist/__tests__/serverConfig.test.js +62 -0
  69. package/dist/components/BuyButton/index.d.ts +2 -2
  70. package/dist/components/BuyButton/index.js +3 -3
  71. package/dist/components/Cart/CartOrder.js +9 -1
  72. package/dist/components/Cart/orderTracking.js +8 -15
  73. package/dist/components/CharacterViewer/index.d.ts +2 -2
  74. package/dist/components/CharacterViewer/index.js +20 -11
  75. package/dist/components/ConfigContext.d.ts +21 -2
  76. package/dist/components/ConfigContext.js +12 -2
  77. package/dist/components/ConnectionErrorToolbar.d.ts +1 -0
  78. package/dist/components/ConnectionErrorToolbar.js +106 -0
  79. package/dist/components/FontStyle/index.d.ts +2 -0
  80. package/dist/components/FontStyle/index.js +4 -2
  81. package/dist/components/FontdueAdminToolbar/index.d.ts +2 -0
  82. package/dist/components/FontdueAdminToolbar/index.js +299 -0
  83. package/dist/components/FontdueAdminToolbar/previewState.d.ts +7 -0
  84. package/dist/components/FontdueAdminToolbar/previewState.js +58 -0
  85. package/dist/components/FontdueContextProvider/index.js +4 -2
  86. package/dist/components/FontdueProvider/index.js +6 -1
  87. package/dist/components/FontdueProvider/index.server.d.ts +1 -0
  88. package/dist/components/FontdueProvider/index.server.js +10 -0
  89. package/dist/components/NewsletterSignup/index.d.ts +2 -2
  90. package/dist/components/NewsletterSignup/index.js +2 -2
  91. package/dist/components/Root/index.js +16 -2
  92. package/dist/components/TestFontsForm/index.d.ts +2 -2
  93. package/dist/components/TestFontsForm/index.js +2 -2
  94. package/dist/components/TypeTester/TypeTesterStandalone.d.ts +2 -2
  95. package/dist/components/TypeTester/TypeTesterStandalone.js +2 -2
  96. package/dist/components/TypeTester/index.js +3 -1
  97. package/dist/components/TypeTester/useTypeTesterStyler.d.ts +3 -1
  98. package/dist/components/TypeTester/useTypeTesterStyler.js +70 -20
  99. package/dist/components/TypeTesters/index.d.ts +2 -2
  100. package/dist/components/TypeTesters/index.js +3 -3
  101. package/dist/components/elements/StoreModalUnifiedCheckout.js +8 -0
  102. package/dist/components/useFontStyle.d.ts +8 -0
  103. package/dist/components/useFontStyle.js +14 -4
  104. package/dist/corsError.d.ts +1 -5
  105. package/dist/corsError.js +23 -13
  106. package/dist/data/unicodeNamesUrl.d.ts +2 -0
  107. package/dist/data/unicodeNamesUrl.js +18 -0
  108. package/dist/data/unicodeNamesVersion.d.ts +1 -0
  109. package/dist/data/unicodeNamesVersion.js +4 -0
  110. package/dist/fallbackFontData.d.ts +2 -0
  111. package/dist/fallbackFontData.js +10 -0
  112. package/dist/fontdue.css +231 -4
  113. package/dist/loadFontdueProviderQuery.d.ts +2 -1
  114. package/dist/loadFontdueProviderQuery.js +5 -2
  115. package/dist/metricFallback.d.ts +48 -0
  116. package/dist/metricFallback.js +98 -0
  117. package/dist/next/config.d.ts +1 -5
  118. package/dist/next/config.js +14 -1
  119. package/dist/next/image-loader.js +22 -3
  120. package/dist/next/index.d.ts +1 -2
  121. package/dist/next/index.js +14 -6
  122. package/dist/next/registerSingleTenantResolver.d.ts +1 -0
  123. package/dist/next/registerSingleTenantResolver.js +35 -0
  124. package/dist/next/revalidate.js +1 -1
  125. package/dist/next/tenant.d.ts +10 -2
  126. package/dist/next/tenant.js +111 -16
  127. package/dist/preview/constants.d.ts +9 -0
  128. package/dist/preview/constants.js +117 -0
  129. package/dist/preview/index.d.ts +53 -0
  130. package/dist/preview/index.js +190 -0
  131. package/dist/preview/server.d.ts +20 -0
  132. package/dist/preview/server.js +89 -0
  133. package/dist/relay/environment.d.ts +8 -0
  134. package/dist/relay/environment.js +81 -25
  135. package/dist/relay/loadSerializableQuery.d.ts +13 -3
  136. package/dist/relay/loadSerializableQuery.js +2 -0
  137. package/dist/relay/serverConfig.d.ts +5 -1
  138. package/dist/relay/serverConfig.js +83 -8
  139. package/dist/scripts/publishUnicodeData.js +68 -0
  140. package/dist/scripts/updateUnicodeData.js +41 -6
  141. package/dist/server/index.d.ts +37 -0
  142. package/dist/server/index.js +160 -0
  143. package/package.json +5 -1
  144. package/types/next-headers.d.ts +9 -0
  145. package/types/next-navigation.d.ts +10 -0
  146. package/vitest.config.ts +9 -0
@@ -0,0 +1,188 @@
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 (production)', async () => {
14
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
15
+ vi.stubEnv('NODE_ENV', 'production');
16
+ const fetchMock = vi.fn(async () => ({
17
+ json: async () => ({
18
+ data: {}
19
+ })
20
+ }));
21
+ vi.stubGlobal('fetch', fetchMock);
22
+ const {
23
+ createNetworkFetch
24
+ } = await import("../relay/environment.js");
25
+ await createNetworkFetch()(request, {});
26
+ expect(fetchMock).toHaveBeenCalledTimes(1);
27
+ const [url, options] = fetchMock.mock.calls[0];
28
+ expect(url).toBe('https://acme.fontdue.com/graphql?queryName=TestQuery');
29
+ // Without an explicit cache option Next 15 treats the fetch as no-store
30
+ // and silently makes every page fully dynamic — the static + revalidate
31
+ // pattern depends on this.
32
+ expect(options.cache).toBe('force-cache');
33
+ expect(options.next.tags).toContain('graphql');
34
+ expect(options.next.tags).toContain('operation:TestQuery');
35
+ });
36
+ it('skips the data cache in development so local dev is always fresh', async () => {
37
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
38
+ vi.stubEnv('NODE_ENV', 'development');
39
+ const fetchMock = vi.fn(async () => ({
40
+ json: async () => ({
41
+ data: {}
42
+ })
43
+ }));
44
+ vi.stubGlobal('fetch', fetchMock);
45
+ const {
46
+ createNetworkFetch
47
+ } = await import("../relay/environment.js");
48
+ await createNetworkFetch()(request, {});
49
+ const [, options] = fetchMock.mock.calls[0];
50
+ // `next dev`'s data cache + revalidateTag don't reliably refresh, so dev
51
+ // fetches stay out of it (no-store) and every render fetches fresh.
52
+ expect(options.cache).toBe('no-store');
53
+ expect(options.next).toBeUndefined();
54
+ });
55
+ it('applies the per-render server config tags and headers', async () => {
56
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
57
+ vi.stubEnv('NODE_ENV', 'production');
58
+ const fetchMock = vi.fn(async () => ({
59
+ json: async () => ({
60
+ data: {}
61
+ })
62
+ }));
63
+ vi.stubGlobal('fetch', fetchMock);
64
+
65
+ // React.cache doesn't memoize outside a React render, so the store is
66
+ // mocked rather than set through setFontdueServerConfig. The network layer
67
+ // reads it through resolveFontdueServerConfig (awaited per fetch).
68
+ vi.doMock('../relay/serverConfig', async importActual => ({
69
+ ...(await importActual()),
70
+ resolveFontdueServerConfig: async () => ({
71
+ url: 'http://app:4000',
72
+ headers: {
73
+ 'x-forwarded-host': 'acme.fontdue.com'
74
+ },
75
+ cacheTags: ['graphql:acme.fontdue.com']
76
+ })
77
+ }));
78
+ const {
79
+ createNetworkFetch
80
+ } = await import("../relay/environment.js");
81
+ await createNetworkFetch()(request, {});
82
+ vi.doUnmock('../relay/serverConfig');
83
+ const [url, options] = fetchMock.mock.calls[0];
84
+ expect(url).toBe('http://app:4000/graphql?queryName=TestQuery');
85
+ expect(options.headers['x-forwarded-host']).toBe('acme.fontdue.com');
86
+ expect(options.next.tags).toContain('graphql:acme.fontdue.com');
87
+ });
88
+ it('forwards per-call options.headers (e.g. a preview Bearer token)', async () => {
89
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
90
+ const fetchMock = vi.fn(async () => ({
91
+ json: async () => ({
92
+ data: {}
93
+ })
94
+ }));
95
+ vi.stubGlobal('fetch', fetchMock);
96
+
97
+ // This is the non-RSC path: the render-scoped serverConfig store is a
98
+ // no-op outside an RSC render, so apps forward the token per call instead.
99
+ const {
100
+ createNetworkFetch
101
+ } = await import("../relay/environment.js");
102
+ await createNetworkFetch({
103
+ headers: {
104
+ authorization: 'Bearer preview-tok'
105
+ }
106
+ })(request, {});
107
+ const [, options] = fetchMock.mock.calls[0];
108
+ expect(options.headers.authorization).toBe('Bearer preview-tok');
109
+ });
110
+ });
111
+ function headersOf(fetchMock) {
112
+ return fetchMock.mock.calls[0][1].headers;
113
+ }
114
+ describe('createNetworkFetch (fontdue-preview header)', () => {
115
+ it('sends fontdue-preview: false on a public server fetch (no token)', async () => {
116
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
117
+ const fetchMock = vi.fn(async () => ({
118
+ json: async () => ({
119
+ data: {}
120
+ })
121
+ }));
122
+ vi.stubGlobal('fetch', fetchMock);
123
+ const {
124
+ createNetworkFetch
125
+ } = await import("../relay/environment.js");
126
+ await createNetworkFetch()(request, {});
127
+ expect(headersOf(fetchMock)['fontdue-preview']).toBe('false');
128
+ });
129
+ it('sends fontdue-preview: true when a preview Bearer token is forwarded (server)', async () => {
130
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
131
+ const fetchMock = vi.fn(async () => ({
132
+ json: async () => ({
133
+ data: {}
134
+ })
135
+ }));
136
+ vi.stubGlobal('fetch', fetchMock);
137
+ const {
138
+ createNetworkFetch
139
+ } = await import("../relay/environment.js");
140
+ await createNetworkFetch({
141
+ headers: {
142
+ authorization: 'Bearer tok'
143
+ }
144
+ })(request, {});
145
+ expect(headersOf(fetchMock)['fontdue-preview']).toBe('true');
146
+ });
147
+ it('on the client, sends true only when the preview marker cookie is set', async () => {
148
+ // typeof window must be defined at module load for IS_SERVER to be false.
149
+ vi.stubGlobal('window', {
150
+ addEventListener: () => {}
151
+ });
152
+ vi.stubGlobal('document', {
153
+ cookie: 'fontdue_preview=1'
154
+ });
155
+ vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', 'https://acme.fontdue.com');
156
+ const fetchMock = vi.fn(async () => ({
157
+ json: async () => ({
158
+ data: {}
159
+ })
160
+ }));
161
+ vi.stubGlobal('fetch', fetchMock);
162
+ const {
163
+ createNetworkFetch
164
+ } = await import("../relay/environment.js");
165
+ await createNetworkFetch()(request, {});
166
+ expect(headersOf(fetchMock)['fontdue-preview']).toBe('true');
167
+ });
168
+ it('on the client, sends false when the marker cookie is absent (logged-in admin browsing normally)', async () => {
169
+ vi.stubGlobal('window', {
170
+ addEventListener: () => {}
171
+ });
172
+ vi.stubGlobal('document', {
173
+ cookie: 'other=1'
174
+ });
175
+ vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', 'https://acme.fontdue.com');
176
+ const fetchMock = vi.fn(async () => ({
177
+ json: async () => ({
178
+ data: {}
179
+ })
180
+ }));
181
+ vi.stubGlobal('fetch', fetchMock);
182
+ const {
183
+ createNetworkFetch
184
+ } = await import("../relay/environment.js");
185
+ await createNetworkFetch()(request, {});
186
+ expect(headersOf(fetchMock)['fontdue-preview']).toBe('false');
187
+ });
188
+ });
@@ -1,10 +1,18 @@
1
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';
2
5
 
3
6
  // The next adapter modules read process.env at module load, so each test
4
7
  // group stubs the env and re-imports them fresh.
5
8
  async function importTenant() {
6
9
  return await import("../next/tenant.js");
7
10
  }
11
+ // Importing this module registers the single-tenant ambient resolver as a side
12
+ // effect, the same way the FontdueProvider react-server entrypoint does.
13
+ async function importRegister() {
14
+ return await import("../next/registerSingleTenantResolver.js");
15
+ }
8
16
  async function importConfig() {
9
17
  return await import("../next/config.js");
10
18
  }
@@ -15,10 +23,58 @@ const revalidateTag = vi.fn();
15
23
  vi.mock('next/cache', () => ({
16
24
  revalidateTag: tag => revalidateTag(tag)
17
25
  }));
26
+
27
+ // Next's notFound() throws a sentinel the framework catches; the mock does
28
+ // the same so tests can assert on it. unstable_rethrow re-throws Next's
29
+ // internal control-flow errors (dynamic bailout, notFound, redirect) and is a
30
+ // no-op for anything else — the mock keys off the `digest` convention Next
31
+ // uses to tag those errors.
32
+ vi.mock('next/navigation', () => ({
33
+ notFound: () => {
34
+ throw new Error('NEXT_NOT_FOUND');
35
+ },
36
+ unstable_rethrow: error => {
37
+ const digest = error === null || error === void 0 ? void 0 : error.digest;
38
+ if (typeof digest === 'string' && (digest.startsWith('DYNAMIC_SERVER_USAGE') || digest.startsWith('NEXT_') || digest.startsWith('BAILOUT_TO_CLIENT_SIDE_RENDERING'))) {
39
+ throw error;
40
+ }
41
+ }
42
+ }));
43
+
44
+ // Draft mode + the preview token cookie, controllable per test. Default: not
45
+ // previewing, so __prepareFontdueRender takes the public (cached) path. Set
46
+ // cookiesError to make cookies() throw (e.g. simulate Next's dynamic bailout
47
+ // during a prerender pass).
48
+ const draft = vi.hoisted(() => ({
49
+ enabled: false,
50
+ token: undefined,
51
+ cookiesError: undefined
52
+ }));
53
+ vi.mock('next/headers', () => ({
54
+ draftMode: async () => ({
55
+ isEnabled: draft.enabled
56
+ }),
57
+ cookies: async () => {
58
+ if (draft.cookiesError) throw draft.cookiesError;
59
+ return {
60
+ get: name => name === 'fontdue_preview_token' && draft.token ? {
61
+ value: draft.token
62
+ } : undefined
63
+ };
64
+ }
65
+ }));
18
66
  beforeEach(() => {
19
67
  vi.resetModules();
68
+ // The server-config store (incl. the ambient resolver) is anchored on
69
+ // globalThis, so it survives resetModules; clear it so a resolver registered
70
+ // by one test can't leak into the next.
71
+ delete globalThis.__fontdueServerConfigStore__;
20
72
  revalidateTag.mockClear();
21
73
  vi.unstubAllEnvs();
74
+ vi.restoreAllMocks();
75
+ draft.enabled = false;
76
+ draft.token = undefined;
77
+ draft.cookiesError = undefined;
22
78
  });
23
79
  function stubSingleTenant() {
24
80
  let url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'https://acme.fontdue.com';
@@ -52,13 +108,14 @@ describe('isValidDomain', () => {
52
108
  expect(isValidDomain('a'.repeat(254) + '.example')).toBe(false);
53
109
  });
54
110
  });
55
- describe('fontdueEndpoint', () => {
111
+ describe('endpointForDomain', () => {
56
112
  it('single-tenant: targets NEXT_PUBLIC_FONTDUE_URL with no headers', async () => {
57
113
  stubSingleTenant('https://acme.fontdue.com');
58
114
  const {
59
- fontdueEndpoint
115
+ endpointForDomain
60
116
  } = await importTenant();
61
- expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
117
+ expect(endpointForDomain('acme.fontdue.com')).toEqual({
118
+ domain: 'acme.fontdue.com',
62
119
  origin: 'https://acme.fontdue.com',
63
120
  headers: {},
64
121
  tags: ['graphql', 'graphql:acme.fontdue.com']
@@ -68,9 +125,9 @@ describe('fontdueEndpoint', () => {
68
125
  vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
69
126
  vi.stubEnv('FONTDUE_MULTI_TENANT', '');
70
127
  const {
71
- fontdueEndpoint
128
+ endpointForDomain
72
129
  } = await importTenant();
73
- expect(() => fontdueEndpoint('acme.fontdue.com')).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
130
+ expect(() => endpointForDomain('acme.fontdue.com')).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
74
131
  });
75
132
  it('multi-tenant: forwards the host to FONTDUE_ORIGIN with the proxy secret', async () => {
76
133
  stubMultiTenant({
@@ -78,9 +135,10 @@ describe('fontdueEndpoint', () => {
78
135
  secret: 's3cret'
79
136
  });
80
137
  const {
81
- fontdueEndpoint
138
+ endpointForDomain
82
139
  } = await importTenant();
83
- expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
140
+ expect(endpointForDomain('acme.fontdue.com')).toEqual({
141
+ domain: 'acme.fontdue.com',
84
142
  origin: 'http://app:4000',
85
143
  headers: {
86
144
  'x-forwarded-host': 'acme.fontdue.com',
@@ -94,18 +152,18 @@ describe('fontdueEndpoint', () => {
94
152
  origin: 'http://app:4000'
95
153
  });
96
154
  const {
97
- fontdueEndpoint
155
+ endpointForDomain
98
156
  } = await importTenant();
99
- expect(fontdueEndpoint('acme.fontdue.com').headers).toEqual({
157
+ expect(endpointForDomain('acme.fontdue.com').headers).toEqual({
100
158
  'x-forwarded-host': 'acme.fontdue.com'
101
159
  });
102
160
  });
103
161
  it('multi-tenant: falls back to the tenant public URL without FONTDUE_ORIGIN', async () => {
104
162
  stubMultiTenant();
105
163
  const {
106
- fontdueEndpoint
164
+ endpointForDomain
107
165
  } = await importTenant();
108
- expect(fontdueEndpoint('acme.fontdue.com').origin).toBe('https://acme.fontdue.com');
166
+ expect(endpointForDomain('acme.fontdue.com').origin).toBe('https://acme.fontdue.com');
109
167
  });
110
168
  });
111
169
  describe('configureFontdueRender', () => {
@@ -116,11 +174,8 @@ describe('configureFontdueRender', () => {
116
174
  const {
117
175
  configureFontdueRender
118
176
  } = await importTenant();
119
- const {
120
- getFontdueServerConfig
121
- } = await import("../relay/serverConfig.js");
177
+ // Invalid domain returns null before any setFontdueServerConfig call.
122
178
  expect(configureFontdueRender('not a domain')).toBeNull();
123
- expect(getFontdueServerConfig()).toBeUndefined();
124
179
  });
125
180
  it('sets the per-render server config and returns the endpoint', async () => {
126
181
  stubMultiTenant({
@@ -130,9 +185,6 @@ describe('configureFontdueRender', () => {
130
185
  const {
131
186
  configureFontdueRender
132
187
  } = await importTenant();
133
- const {
134
- getFontdueServerConfig
135
- } = await import("../relay/serverConfig.js");
136
188
  const endpoint = configureFontdueRender('acme.fontdue.com');
137
189
  expect(endpoint === null || endpoint === void 0 ? void 0 : endpoint.origin).toBe('http://app:4000');
138
190
  // Outside an RSC render React.cache doesn't memoize, so the write is a
@@ -151,7 +203,180 @@ describe('configureFontdueRender', () => {
151
203
  });
152
204
  });
153
205
  });
206
+ describe('__prepareFontdueRender', () => {
207
+ const props = params => ({
208
+ params: Promise.resolve(params)
209
+ });
210
+ it('configures the render and returns the endpoint', async () => {
211
+ stubMultiTenant({
212
+ origin: 'http://app:4000'
213
+ });
214
+ const {
215
+ __prepareFontdueRender
216
+ } = await importTenant();
217
+ const endpoint = await __prepareFontdueRender(props({
218
+ domain: 'acme.fontdue.com',
219
+ slug: 'sans'
220
+ }));
221
+ expect(endpoint.origin).toBe('http://app:4000');
222
+ expect(endpoint.tags).toContain('graphql:acme.fontdue.com');
223
+ });
224
+ it('404s invalid or missing domains', async () => {
225
+ stubMultiTenant();
226
+ const {
227
+ __prepareFontdueRender
228
+ } = await importTenant();
229
+ await expect(__prepareFontdueRender(props({
230
+ domain: 'not a domain'
231
+ }))).rejects.toThrow('NEXT_NOT_FOUND');
232
+ await expect(__prepareFontdueRender(props({
233
+ slug: 'sans'
234
+ }))).rejects.toThrow('NEXT_NOT_FOUND');
235
+ });
236
+
237
+ // React.cache doesn't memoize outside an RSC render (see configureFontdueRender
238
+ // above), so the slot write is a no-op here — capture what __prepareFontdueRender
239
+ // passes to setFontdueServerConfig instead.
240
+ async function captureRenderConfig(params) {
241
+ let captured;
242
+ vi.doMock('../relay/serverConfig', async importActual => ({
243
+ ...(await importActual()),
244
+ setFontdueServerConfig: c => {
245
+ captured = c;
246
+ }
247
+ }));
248
+ const {
249
+ __prepareFontdueRender
250
+ } = await importTenant();
251
+ await __prepareFontdueRender(props(params));
252
+ vi.doUnmock('../relay/serverConfig');
253
+ return captured;
254
+ }
255
+ it('public render: sets the tenant config with cache tags and no token', async () => {
256
+ var _config$headers;
257
+ stubMultiTenant({
258
+ origin: 'http://app:4000'
259
+ });
260
+ const config = await captureRenderConfig({
261
+ domain: 'acme.fontdue.com'
262
+ });
263
+ expect(config.cacheTags).toEqual(['graphql:acme.fontdue.com']);
264
+ expect((_config$headers = config.headers) === null || _config$headers === void 0 ? void 0 : _config$headers.authorization).toBeUndefined();
265
+ });
266
+ it('preview render: folds the token into headers and drops cache tags', async () => {
267
+ var _config$headers2;
268
+ stubMultiTenant({
269
+ origin: 'http://app:4000'
270
+ });
271
+ draft.enabled = true;
272
+ draft.token = 'admin-tok';
273
+ const config = await captureRenderConfig({
274
+ domain: 'acme.fontdue.com'
275
+ });
276
+ // Embeds (createNetworkFetch) and the app's fetch (createFontdueFetch) both
277
+ // read this, so both reveal hidden fonts; no tags keeps the render live.
278
+ expect((_config$headers2 = config.headers) === null || _config$headers2 === void 0 ? void 0 : _config$headers2.authorization).toBe('Bearer admin-tok');
279
+ expect(config.cacheTags).toBeUndefined();
280
+ });
281
+ });
282
+ describe('single-tenant ambient resolver (no per-render call)', () => {
283
+ // Importing registerSingleTenantResolver registers the resolver as a module
284
+ // side effect — the FontdueProvider RSC entrypoint does this import, so
285
+ // merely mounting the provider wires it up. No per-render setup call is made
286
+ // in any of these tests; the config is pulled at fetch time.
287
+ it('public render: feeds the env URL + cache tags, no token', async () => {
288
+ var _config$headers3;
289
+ stubSingleTenant('https://acme.fontdue.com');
290
+ await importRegister();
291
+ const {
292
+ resolveFontdueServerConfig
293
+ } = await import("../relay/serverConfig.js");
294
+ const config = await resolveFontdueServerConfig();
295
+ expect(config === null || config === void 0 ? void 0 : config.url).toBe('https://acme.fontdue.com');
296
+ expect(config === null || config === void 0 ? void 0 : config.cacheTags).toEqual(['graphql:acme.fontdue.com']);
297
+ expect(config === null || config === void 0 ? void 0 : (_config$headers3 = config.headers) === null || _config$headers3 === void 0 ? void 0 : _config$headers3.authorization).toBeUndefined();
298
+ });
299
+ it('preview render: folds in the admin token and drops cache tags', async () => {
300
+ var _config$headers4;
301
+ stubSingleTenant('https://acme.fontdue.com');
302
+ draft.enabled = true;
303
+ draft.token = 'admin-tok';
304
+ await importRegister();
305
+ const {
306
+ resolveFontdueServerConfig
307
+ } = await import("../relay/serverConfig.js");
308
+ const config = await resolveFontdueServerConfig();
309
+ // This is what reveals hidden fonts in an embedded component's own preload
310
+ // on a page that never calls the app's GraphQL fetcher.
311
+ expect(config === null || config === void 0 ? void 0 : (_config$headers4 = config.headers) === null || _config$headers4 === void 0 ? void 0 : _config$headers4.authorization).toBe('Bearer admin-tok');
312
+ expect(config === null || config === void 0 ? void 0 : config.cacheTags).toBeUndefined();
313
+ });
314
+ it('multi-tenant: resolver stays out of the way so the slot drives config', async () => {
315
+ stubMultiTenant({
316
+ origin: 'http://app:4000'
317
+ });
318
+ await importRegister();
319
+ const {
320
+ resolveFontdueServerConfig
321
+ } = await import("../relay/serverConfig.js");
322
+ expect(await resolveFontdueServerConfig()).toBeUndefined();
323
+ });
324
+
325
+ // Regression for FD-712: a preview render that begins as a static/prerender
326
+ // pass must NOT swallow Next's dynamic bailout — re-throwing it is what takes
327
+ // the route off the full-route cache so the embeds' preloads re-run with the
328
+ // token. If this were swallowed, every server fetch (including a <BuyButton>
329
+ // node(id) @required(THROW) preload) would silently get the public view.
330
+ it('preview render: propagates Next’s dynamic bailout instead of dropping the token', async () => {
331
+ stubSingleTenant('https://acme.fontdue.com');
332
+ draft.enabled = true;
333
+ draft.token = 'admin-tok';
334
+ draft.cookiesError = Object.assign(new Error('Dynamic server usage'), {
335
+ digest: 'DYNAMIC_SERVER_USAGE'
336
+ });
337
+ await importRegister();
338
+ const {
339
+ resolveFontdueServerConfig
340
+ } = await import("../relay/serverConfig.js");
341
+ await expect(resolveFontdueServerConfig()).rejects.toBe(draft.cookiesError);
342
+ });
343
+
344
+ // A non-control-flow throw (e.g. no request scope at all) is not a bailout,
345
+ // so it's swallowed and the render degrades to the public (token-less) config
346
+ // rather than crashing.
347
+ it('preview render: swallows a non-control-flow throw and falls back to public', async () => {
348
+ var _config$headers5;
349
+ stubSingleTenant('https://acme.fontdue.com');
350
+ draft.enabled = true;
351
+ draft.token = 'admin-tok';
352
+ draft.cookiesError = new Error('no request scope');
353
+ await importRegister();
354
+ const {
355
+ resolveFontdueServerConfig
356
+ } = await import("../relay/serverConfig.js");
357
+ const config = await resolveFontdueServerConfig();
358
+ expect(config === null || config === void 0 ? void 0 : config.url).toBe('https://acme.fontdue.com');
359
+ expect(config === null || config === void 0 ? void 0 : config.cacheTags).toEqual(['graphql:acme.fontdue.com']);
360
+ expect(config === null || config === void 0 ? void 0 : (_config$headers5 = config.headers) === null || _config$headers5 === void 0 ? void 0 : _config$headers5.authorization).toBeUndefined();
361
+ });
362
+ });
363
+
364
+ // withFontdue detects the route-tree shape from the working directory; give
365
+ // it one with or without src/app/[domain].
366
+ function mockAppDir(_ref) {
367
+ let {
368
+ domainTree
369
+ } = _ref;
370
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fontdue-app-'));
371
+ fs.mkdirSync(path.join(dir, domainTree ? 'src/app/[domain]' : 'src/app'), {
372
+ recursive: true
373
+ });
374
+ vi.spyOn(process, 'cwd').mockReturnValue(dir);
375
+ }
154
376
  describe('withFontdue', () => {
377
+ beforeEach(() => mockAppDir({
378
+ domainTree: true
379
+ }));
155
380
  it('throws when neither mode is configured', async () => {
156
381
  vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
157
382
  vi.stubEnv('FONTDUE_MULTI_TENANT', '');
@@ -267,6 +492,36 @@ describe('withFontdue', () => {
267
492
  htmlLimitedBots: /Googlebot/
268
493
  }).htmlLimitedBots).toEqual(/Googlebot/);
269
494
  });
495
+ it('single-tenant flat tree: installs no tenant rewrites', async () => {
496
+ mockAppDir({
497
+ domainTree: false
498
+ });
499
+ stubSingleTenant('https://acme.fontdue.com');
500
+ const {
501
+ withFontdue
502
+ } = await importConfig();
503
+ const userRule = {
504
+ source: '/old',
505
+ destination: '/new'
506
+ };
507
+ const rewrites = await withFontdue({
508
+ rewrites: async () => ({
509
+ beforeFiles: [userRule]
510
+ })
511
+ }).rewrites();
512
+ expect(rewrites.beforeFiles).toEqual([userRule]);
513
+ expect(rewrites.afterFiles).toEqual([]);
514
+ });
515
+ it('multi-tenant flat tree: refuses to start', async () => {
516
+ mockAppDir({
517
+ domainTree: false
518
+ });
519
+ stubMultiTenant();
520
+ const {
521
+ withFontdue
522
+ } = await importConfig();
523
+ expect(() => withFontdue({})).toThrow(/\[domain\]/);
524
+ });
270
525
  });
271
526
  describe('revalidate POST', () => {
272
527
  it('multi-tenant: purges only the tenant tag', async () => {