fontdue-js 3.0.0-alpha12 → 3.0.0-alpha13

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 (34) hide show
  1. package/.playwright-mcp/console-2026-06-15T09-14-00-118Z.log +84 -0
  2. package/.playwright-mcp/console-2026-06-15T09-25-42-726Z.log +2 -0
  3. package/.playwright-mcp/console-2026-06-15T09-25-47-707Z.log +1 -0
  4. package/.playwright-mcp/page-2026-06-15T09-14-01-054Z.yml +13 -0
  5. package/CHANGELOG.md +2 -2
  6. package/README.md +25 -50
  7. package/dist/__tests__/createFontdueFetch.test.js +33 -0
  8. package/dist/__tests__/networkFetch.test.js +81 -2
  9. package/dist/__tests__/nextAdapter.test.js +195 -50
  10. package/dist/__tests__/serverConfig.test.js +62 -0
  11. package/dist/components/ConfigContext.d.ts +3 -0
  12. package/dist/components/ConfigContext.js +5 -2
  13. package/dist/components/FontdueAdminToolbar/index.js +72 -16
  14. package/dist/components/FontdueProvider/index.server.d.ts +1 -0
  15. package/dist/components/FontdueProvider/index.server.js +10 -0
  16. package/dist/fontdue.css +59 -0
  17. package/dist/next/index.d.ts +1 -2
  18. package/dist/next/index.js +16 -6
  19. package/dist/next/registerSingleTenantResolver.d.ts +1 -0
  20. package/dist/next/registerSingleTenantResolver.js +36 -0
  21. package/dist/next/revalidate.js +1 -1
  22. package/dist/next/tenant.d.ts +6 -4
  23. package/dist/next/tenant.js +106 -69
  24. package/dist/preview/constants.d.ts +2 -0
  25. package/dist/preview/constants.js +20 -1
  26. package/dist/relay/environment.d.ts +2 -0
  27. package/dist/relay/environment.js +67 -38
  28. package/dist/relay/serverConfig.d.ts +6 -4
  29. package/dist/relay/serverConfig.js +81 -19
  30. package/dist/server/index.d.ts +1 -1
  31. package/dist/server/index.js +27 -15
  32. package/package.json +1 -1
  33. package/types/next-navigation.d.ts +4 -0
  34. package/vitest.config.ts +5 -0
@@ -8,6 +8,11 @@ import path from 'node:path';
8
8
  async function importTenant() {
9
9
  return await import("../next/tenant.js");
10
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
+ }
11
16
  async function importConfig() {
12
17
  return await import("../next/config.js");
13
18
  }
@@ -20,36 +25,56 @@ vi.mock('next/cache', () => ({
20
25
  }));
21
26
 
22
27
  // Next's notFound() throws a sentinel the framework catches; the mock does
23
- // the same so tests can assert on it.
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.
24
32
  vi.mock('next/navigation', () => ({
25
33
  notFound: () => {
26
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
+ }
27
41
  }
28
42
  }));
29
43
 
30
44
  // Draft mode + the preview token cookie, controllable per test. Default: not
31
- // previewing, so prepareFontdueRender takes the public (cached) path.
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).
32
48
  const draft = vi.hoisted(() => ({
33
49
  enabled: false,
34
- token: undefined
50
+ token: undefined,
51
+ cookiesError: undefined
35
52
  }));
36
53
  vi.mock('next/headers', () => ({
37
54
  draftMode: async () => ({
38
55
  isEnabled: draft.enabled
39
56
  }),
40
- cookies: async () => ({
41
- get: name => name === 'fontdue_preview_token' && draft.token ? {
42
- value: draft.token
43
- } : undefined
44
- })
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
+ }
45
65
  }));
46
66
  beforeEach(() => {
47
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__;
48
72
  revalidateTag.mockClear();
49
73
  vi.unstubAllEnvs();
50
74
  vi.restoreAllMocks();
51
75
  draft.enabled = false;
52
76
  draft.token = undefined;
77
+ draft.cookiesError = undefined;
53
78
  });
54
79
  function stubSingleTenant() {
55
80
  let url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'https://acme.fontdue.com';
@@ -83,13 +108,13 @@ describe('isValidDomain', () => {
83
108
  expect(isValidDomain('a'.repeat(254) + '.example')).toBe(false);
84
109
  });
85
110
  });
86
- describe('fontdueEndpoint', () => {
111
+ describe('endpointForDomain', () => {
87
112
  it('single-tenant: targets NEXT_PUBLIC_FONTDUE_URL with no headers', async () => {
88
113
  stubSingleTenant('https://acme.fontdue.com');
89
114
  const {
90
- fontdueEndpoint
115
+ endpointForDomain
91
116
  } = await importTenant();
92
- expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
117
+ expect(endpointForDomain('acme.fontdue.com')).toEqual({
93
118
  domain: 'acme.fontdue.com',
94
119
  origin: 'https://acme.fontdue.com',
95
120
  headers: {},
@@ -100,9 +125,9 @@ describe('fontdueEndpoint', () => {
100
125
  vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
101
126
  vi.stubEnv('FONTDUE_MULTI_TENANT', '');
102
127
  const {
103
- fontdueEndpoint
128
+ endpointForDomain
104
129
  } = await importTenant();
105
- expect(() => fontdueEndpoint('acme.fontdue.com')).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
130
+ expect(() => endpointForDomain('acme.fontdue.com')).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
106
131
  });
107
132
  it('multi-tenant: forwards the host to FONTDUE_ORIGIN with the proxy secret', async () => {
108
133
  stubMultiTenant({
@@ -110,9 +135,9 @@ describe('fontdueEndpoint', () => {
110
135
  secret: 's3cret'
111
136
  });
112
137
  const {
113
- fontdueEndpoint
138
+ endpointForDomain
114
139
  } = await importTenant();
115
- expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
140
+ expect(endpointForDomain('acme.fontdue.com')).toEqual({
116
141
  domain: 'acme.fontdue.com',
117
142
  origin: 'http://app:4000',
118
143
  headers: {
@@ -127,18 +152,18 @@ describe('fontdueEndpoint', () => {
127
152
  origin: 'http://app:4000'
128
153
  });
129
154
  const {
130
- fontdueEndpoint
155
+ endpointForDomain
131
156
  } = await importTenant();
132
- expect(fontdueEndpoint('acme.fontdue.com').headers).toEqual({
157
+ expect(endpointForDomain('acme.fontdue.com').headers).toEqual({
133
158
  'x-forwarded-host': 'acme.fontdue.com'
134
159
  });
135
160
  });
136
161
  it('multi-tenant: falls back to the tenant public URL without FONTDUE_ORIGIN', async () => {
137
162
  stubMultiTenant();
138
163
  const {
139
- fontdueEndpoint
164
+ endpointForDomain
140
165
  } = await importTenant();
141
- expect(fontdueEndpoint('acme.fontdue.com').origin).toBe('https://acme.fontdue.com');
166
+ expect(endpointForDomain('acme.fontdue.com').origin).toBe('https://acme.fontdue.com');
142
167
  });
143
168
  });
144
169
  describe('configureFontdueRender', () => {
@@ -150,10 +175,10 @@ describe('configureFontdueRender', () => {
150
175
  configureFontdueRender
151
176
  } = await importTenant();
152
177
  const {
153
- getFontdueServerConfig
178
+ getFontdueSlotConfig
154
179
  } = await import("../relay/serverConfig.js");
155
180
  expect(configureFontdueRender('not a domain')).toBeNull();
156
- expect(getFontdueServerConfig()).toBeUndefined();
181
+ expect(getFontdueSlotConfig()).toBeUndefined();
157
182
  });
158
183
  it('sets the per-render server config and returns the endpoint', async () => {
159
184
  stubMultiTenant({
@@ -163,9 +188,6 @@ describe('configureFontdueRender', () => {
163
188
  const {
164
189
  configureFontdueRender
165
190
  } = await importTenant();
166
- const {
167
- getFontdueServerConfig
168
- } = await import("../relay/serverConfig.js");
169
191
  const endpoint = configureFontdueRender('acme.fontdue.com');
170
192
  expect(endpoint === null || endpoint === void 0 ? void 0 : endpoint.origin).toBe('http://app:4000');
171
193
  // Outside an RSC render React.cache doesn't memoize, so the write is a
@@ -185,7 +207,7 @@ describe('configureFontdueRender', () => {
185
207
  });
186
208
  });
187
209
  });
188
- describe('prepareFontdueRender', () => {
210
+ describe('__prepareFontdueRender', () => {
189
211
  const props = params => ({
190
212
  params: Promise.resolve(params)
191
213
  });
@@ -194,9 +216,9 @@ describe('prepareFontdueRender', () => {
194
216
  origin: 'http://app:4000'
195
217
  });
196
218
  const {
197
- prepareFontdueRender
219
+ __prepareFontdueRender
198
220
  } = await importTenant();
199
- const endpoint = await prepareFontdueRender(props({
221
+ const endpoint = await __prepareFontdueRender(props({
200
222
  domain: 'acme.fontdue.com',
201
223
  slug: 'sans'
202
224
  }));
@@ -206,18 +228,18 @@ describe('prepareFontdueRender', () => {
206
228
  it('404s invalid or missing domains', async () => {
207
229
  stubMultiTenant();
208
230
  const {
209
- prepareFontdueRender
231
+ __prepareFontdueRender
210
232
  } = await importTenant();
211
- await expect(prepareFontdueRender(props({
233
+ await expect(__prepareFontdueRender(props({
212
234
  domain: 'not a domain'
213
235
  }))).rejects.toThrow('NEXT_NOT_FOUND');
214
- await expect(prepareFontdueRender(props({
236
+ await expect(__prepareFontdueRender(props({
215
237
  slug: 'sans'
216
238
  }))).rejects.toThrow('NEXT_NOT_FOUND');
217
239
  });
218
240
 
219
241
  // React.cache doesn't memoize outside an RSC render (see configureFontdueRender
220
- // above), so the slot write is a no-op here — capture what prepareFontdueRender
242
+ // above), so the slot write is a no-op here — capture what __prepareFontdueRender
221
243
  // passes to setFontdueServerConfig instead.
222
244
  async function captureRenderConfig(params) {
223
245
  let captured;
@@ -228,9 +250,9 @@ describe('prepareFontdueRender', () => {
228
250
  }
229
251
  }));
230
252
  const {
231
- prepareFontdueRender
253
+ __prepareFontdueRender
232
254
  } = await importTenant();
233
- await prepareFontdueRender(props(params));
255
+ await __prepareFontdueRender(props(params));
234
256
  vi.doUnmock('../relay/serverConfig');
235
257
  return captured;
236
258
  }
@@ -261,22 +283,154 @@ describe('prepareFontdueRender', () => {
261
283
  expect(config.cacheTags).toBeUndefined();
262
284
  });
263
285
  });
264
- describe('currentFontdueEndpoint', () => {
286
+ describe('configureFontduePreview (single-tenant)', () => {
287
+ async function captureConfig() {
288
+ let captured;
289
+ vi.doMock('../relay/serverConfig', async importActual => ({
290
+ ...(await importActual()),
291
+ setFontdueServerConfig: c => {
292
+ captured = c;
293
+ }
294
+ }));
295
+ const {
296
+ configureFontduePreview
297
+ } = await importTenant();
298
+ const endpoint = await configureFontduePreview();
299
+ vi.doUnmock('../relay/serverConfig');
300
+ return {
301
+ captured,
302
+ endpoint
303
+ };
304
+ }
305
+ it('public render: configures the NEXT_PUBLIC_FONTDUE_URL site with cache tags', async () => {
306
+ var _captured$headers;
307
+ stubSingleTenant('https://acme.fontdue.com');
308
+ const {
309
+ captured,
310
+ endpoint
311
+ } = await captureConfig();
312
+ expect(endpoint.origin).toBe('https://acme.fontdue.com');
313
+ expect(captured.url).toBe('https://acme.fontdue.com');
314
+ expect(captured.cacheTags).toEqual(['graphql:acme.fontdue.com']);
315
+ expect((_captured$headers = captured.headers) === null || _captured$headers === void 0 ? void 0 : _captured$headers.authorization).toBeUndefined();
316
+ });
317
+ it('preview render: forwards the token and drops cache tags', async () => {
318
+ var _captured$headers2;
319
+ stubSingleTenant('https://acme.fontdue.com');
320
+ draft.enabled = true;
321
+ draft.token = 'admin-tok';
322
+ const {
323
+ captured
324
+ } = await captureConfig();
325
+ expect((_captured$headers2 = captured.headers) === null || _captured$headers2 === void 0 ? void 0 : _captured$headers2.authorization).toBe('Bearer admin-tok');
326
+ expect(captured.cacheTags).toBeUndefined();
327
+ });
328
+ it('throws a helpful error when NEXT_PUBLIC_FONTDUE_URL is unset', async () => {
329
+ stubMultiTenant();
330
+ const {
331
+ configureFontduePreview
332
+ } = await importTenant();
333
+ await expect(configureFontduePreview()).rejects.toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
334
+ });
335
+ });
336
+ describe('single-tenant ambient resolver (no per-render call)', () => {
337
+ // Importing registerSingleTenantResolver registers the resolver as a module
338
+ // side effect — the FontdueProvider RSC entrypoint does this import, so
339
+ // merely mounting the provider wires it up. No
340
+ // configureFontduePreview()/__prepareFontdueRender() call is made in any of
341
+ // these tests; the config is pulled at fetch time.
342
+ it('public render: feeds the env URL + cache tags, no token', async () => {
343
+ var _config$headers3;
344
+ stubSingleTenant('https://acme.fontdue.com');
345
+ await importRegister();
346
+ const {
347
+ resolveFontdueServerConfig
348
+ } = await import("../relay/serverConfig.js");
349
+ const config = await resolveFontdueServerConfig();
350
+ expect(config === null || config === void 0 ? void 0 : config.url).toBe('https://acme.fontdue.com');
351
+ expect(config === null || config === void 0 ? void 0 : config.cacheTags).toEqual(['graphql:acme.fontdue.com']);
352
+ expect(config === null || config === void 0 ? void 0 : (_config$headers3 = config.headers) === null || _config$headers3 === void 0 ? void 0 : _config$headers3.authorization).toBeUndefined();
353
+ });
354
+ it('preview render: folds in the admin token and drops cache tags', async () => {
355
+ var _config$headers4;
356
+ stubSingleTenant('https://acme.fontdue.com');
357
+ draft.enabled = true;
358
+ draft.token = 'admin-tok';
359
+ await importRegister();
360
+ const {
361
+ resolveFontdueServerConfig
362
+ } = await import("../relay/serverConfig.js");
363
+ const config = await resolveFontdueServerConfig();
364
+ // This is what reveals hidden fonts in an embedded component's own preload
365
+ // on a page that never calls the app's GraphQL fetcher.
366
+ 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');
367
+ expect(config === null || config === void 0 ? void 0 : config.cacheTags).toBeUndefined();
368
+ });
369
+ it('multi-tenant: resolver stays out of the way so the slot drives config', async () => {
370
+ stubMultiTenant({
371
+ origin: 'http://app:4000'
372
+ });
373
+ await importRegister();
374
+ const {
375
+ resolveFontdueServerConfig
376
+ } = await import("../relay/serverConfig.js");
377
+ expect(await resolveFontdueServerConfig()).toBeUndefined();
378
+ });
379
+
380
+ // Regression for FD-712: a preview render that begins as a static/prerender
381
+ // pass must NOT swallow Next's dynamic bailout — re-throwing it is what takes
382
+ // the route off the full-route cache so the embeds' preloads re-run with the
383
+ // token. If this were swallowed, every server fetch (including a <BuyButton>
384
+ // node(id) @required(THROW) preload) would silently get the public view.
385
+ it('preview render: propagates Next’s dynamic bailout instead of dropping the token', async () => {
386
+ stubSingleTenant('https://acme.fontdue.com');
387
+ draft.enabled = true;
388
+ draft.token = 'admin-tok';
389
+ draft.cookiesError = Object.assign(new Error('Dynamic server usage'), {
390
+ digest: 'DYNAMIC_SERVER_USAGE'
391
+ });
392
+ await importRegister();
393
+ const {
394
+ resolveFontdueServerConfig
395
+ } = await import("../relay/serverConfig.js");
396
+ await expect(resolveFontdueServerConfig()).rejects.toBe(draft.cookiesError);
397
+ });
398
+
399
+ // A non-control-flow throw (e.g. no request scope at all) is not a bailout,
400
+ // so it's swallowed and the render degrades to the public (token-less) config
401
+ // rather than crashing.
402
+ it('preview render: swallows a non-control-flow throw and falls back to public', async () => {
403
+ var _config$headers5;
404
+ stubSingleTenant('https://acme.fontdue.com');
405
+ draft.enabled = true;
406
+ draft.token = 'admin-tok';
407
+ draft.cookiesError = new Error('no request scope');
408
+ await importRegister();
409
+ const {
410
+ resolveFontdueServerConfig
411
+ } = await import("../relay/serverConfig.js");
412
+ const config = await resolveFontdueServerConfig();
413
+ expect(config === null || config === void 0 ? void 0 : config.url).toBe('https://acme.fontdue.com');
414
+ expect(config === null || config === void 0 ? void 0 : config.cacheTags).toEqual(['graphql:acme.fontdue.com']);
415
+ expect(config === null || config === void 0 ? void 0 : (_config$headers5 = config.headers) === null || _config$headers5 === void 0 ? void 0 : _config$headers5.authorization).toBeUndefined();
416
+ });
417
+ });
418
+ describe('fontdueEndpoint', () => {
265
419
  it('multi-tenant: throws when no render config was set', async () => {
266
420
  stubMultiTenant({
267
421
  origin: 'http://app:4000'
268
422
  });
269
423
  const {
270
- currentFontdueEndpoint
424
+ fontdueEndpoint
271
425
  } = await importTenant();
272
- expect(() => currentFontdueEndpoint()).toThrow(/prepareFontdueRender/);
426
+ expect(() => fontdueEndpoint()).toThrow(/__prepareFontdueRender/);
273
427
  });
274
428
  it('single-tenant: derives the endpoint from NEXT_PUBLIC_FONTDUE_URL', async () => {
275
429
  stubSingleTenant('https://acme.fontdue.com');
276
430
  const {
277
- currentFontdueEndpoint
431
+ fontdueEndpoint
278
432
  } = await importTenant();
279
- expect(currentFontdueEndpoint()).toEqual({
433
+ expect(fontdueEndpoint()).toEqual({
280
434
  domain: 'acme.fontdue.com',
281
435
  origin: 'https://acme.fontdue.com',
282
436
  headers: {},
@@ -289,26 +443,17 @@ describe('currentFontdueEndpoint', () => {
289
443
  });
290
444
  vi.doMock('../relay/serverConfig', async importActual => ({
291
445
  ...(await importActual()),
292
- getFontdueServerConfig: () => ({
446
+ getFontdueSlotConfig: () => ({
293
447
  domain: 'acme.fontdue.com'
294
448
  })
295
449
  }));
296
450
  const {
297
- currentFontdueEndpoint
451
+ fontdueEndpoint
298
452
  } = await importTenant();
299
- expect(currentFontdueEndpoint().headers['x-forwarded-host']).toBe('acme.fontdue.com');
453
+ expect(fontdueEndpoint().headers['x-forwarded-host']).toBe('acme.fontdue.com');
300
454
  vi.doUnmock('../relay/serverConfig');
301
455
  });
302
456
  });
303
- describe('generateStaticParams', () => {
304
- it('returns no build-time params (everything renders on demand)', async () => {
305
- stubMultiTenant();
306
- const {
307
- generateStaticParams
308
- } = await importTenant();
309
- await expect(generateStaticParams()).resolves.toEqual([]);
310
- });
311
- });
312
457
 
313
458
  // withFontdue detects the route-tree shape from the working directory; give
314
459
  // it one with or without src/app/[domain].
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+
3
+ // The render-scoped slot and the ambient resolver are anchored on globalThis so
4
+ // they survive this module being bundled into more than one Next.js server
5
+ // chunk (the app/provider chunk vs. a per-embed chunk), which would otherwise
6
+ // give each chunk its own copy of the module-level state. Re-importing the
7
+ // module after vi.resetModules() faithfully simulates that second chunk: a
8
+ // fresh module instance with its own scope but the same globalThis.
9
+ const STORE_KEY = '__fontdueServerConfigStore__';
10
+ function clearStore() {
11
+ delete globalThis[STORE_KEY];
12
+ }
13
+ beforeEach(() => {
14
+ vi.resetModules();
15
+ clearStore();
16
+ });
17
+ afterEach(clearStore);
18
+ describe('serverConfig store (chunk-duplication safety, FD-712)', () => {
19
+ it('shares the ambient resolver across module instances', async () => {
20
+ var _await$a$resolveFontd, _await$a$resolveFontd2, _await$b$resolveFontd, _await$b$resolveFontd2;
21
+ // First "chunk": register a resolver (what fontdue-js/next does when
22
+ // <FontdueProvider> mounts in single-tenant mode).
23
+ const a = await import("../relay/serverConfig.js");
24
+ a.registerAmbientConfigResolver(async () => ({
25
+ url: 'https://acme.fontdue.com',
26
+ headers: {
27
+ authorization: 'Bearer admin-tok'
28
+ }
29
+ }));
30
+ expect((_await$a$resolveFontd = await a.resolveFontdueServerConfig()) === null || _await$a$resolveFontd === void 0 ? void 0 : (_await$a$resolveFontd2 = _await$a$resolveFontd.headers) === null || _await$a$resolveFontd2 === void 0 ? void 0 : _await$a$resolveFontd2.authorization).toBe('Bearer admin-tok');
31
+
32
+ // Second "chunk": a fresh module instance. Before the fix its module-level
33
+ // `ambientResolver` was undefined here, so an embed preloading from this
34
+ // chunk fetched without the preview token and a hidden-font reveal failed.
35
+ vi.resetModules();
36
+ const b = await import("../relay/serverConfig.js");
37
+ expect(b).not.toBe(a); // genuinely a re-evaluated module
38
+ expect((_await$b$resolveFontd = await b.resolveFontdueServerConfig()) === null || _await$b$resolveFontd === void 0 ? void 0 : (_await$b$resolveFontd2 = _await$b$resolveFontd.headers) === null || _await$b$resolveFontd2 === void 0 ? void 0 : _await$b$resolveFontd2.authorization).toBe('Bearer admin-tok');
39
+ });
40
+ it('reuses one shared store object across re-imports', async () => {
41
+ const a = await import("../relay/serverConfig.js");
42
+ // The store is created lazily on first use, not on import.
43
+ a.registerAmbientConfigResolver(() => undefined);
44
+ const first = globalThis[STORE_KEY];
45
+ expect(first).toBeDefined();
46
+ vi.resetModules();
47
+ const b = await import("../relay/serverConfig.js");
48
+ await b.resolveFontdueServerConfig();
49
+ const second = globalThis[STORE_KEY];
50
+
51
+ // Same store (slot factory + resolver), not a per-instance copy — so the
52
+ // React.cache-backed slot multi-tenant uses (setFontdueServerConfig via
53
+ // __prepareFontdueRender) is one shared cell every chunk reads/writes.
54
+ expect(second).toBe(first);
55
+ });
56
+ it('starts with no resolver (resolution unchanged until something registers)', async () => {
57
+ const {
58
+ resolveFontdueServerConfig
59
+ } = await import("../relay/serverConfig.js");
60
+ expect(await resolveFontdueServerConfig()).toBeUndefined();
61
+ });
62
+ });
@@ -25,6 +25,7 @@ interface TrackingConfig {
25
25
  }
26
26
  interface PreviewConfig {
27
27
  endpoint?: string;
28
+ revalidateEndpoint?: string;
28
29
  }
29
30
  export interface Config {
30
31
  form?: FormConfig;
@@ -108,6 +109,7 @@ export declare const makeConfig: (config?: Config) => {
108
109
  };
109
110
  preview: {
110
111
  endpoint: string;
112
+ revalidateEndpoint: string | undefined;
111
113
  };
112
114
  corsErrorModal: boolean;
113
115
  };
@@ -183,6 +185,7 @@ declare const _default: React.Context<{
183
185
  };
184
186
  preview: {
185
187
  endpoint: string;
188
+ revalidateEndpoint: string | undefined;
186
189
  };
187
190
  corsErrorModal: boolean;
188
191
  }>;
@@ -80,7 +80,7 @@ export const mergeConfig = (base, override) => {
80
80
  return deepMerge(base, override);
81
81
  };
82
82
  export const makeConfig = config => {
83
- var _config$form, _config$storeModal, _config$storeModal2, _config$storeModal3, _config$storeModal4, _config$stripe, _config$tracking, _config$tracking2, _config$tracking3, _config$tracking4, _config$preview;
83
+ var _config$form, _config$storeModal, _config$storeModal2, _config$storeModal3, _config$storeModal4, _config$stripe, _config$tracking, _config$tracking2, _config$tracking3, _config$tracking4, _config$preview, _config$preview2;
84
84
  return {
85
85
  typeTester: makeTypeTesterConfig(config === null || config === void 0 ? void 0 : config.typeTester),
86
86
  form: {
@@ -103,7 +103,10 @@ export const makeConfig = config => {
103
103
  segment: config === null || config === void 0 ? void 0 : (_config$tracking4 = config.tracking) === null || _config$tracking4 === void 0 ? void 0 : _config$tracking4.segment
104
104
  },
105
105
  preview: {
106
- endpoint: (config === null || config === void 0 ? void 0 : (_config$preview = config.preview) === null || _config$preview === void 0 ? void 0 : _config$preview.endpoint) ?? PREVIEW_ENDPOINT
106
+ endpoint: (config === null || config === void 0 ? void 0 : (_config$preview = config.preview) === null || _config$preview === void 0 ? void 0 : _config$preview.endpoint) ?? PREVIEW_ENDPOINT,
107
+ // No default: an unset revalidate endpoint hides the toolbar's refresh
108
+ // button (frameworks without a client-callable purge leave it unset).
109
+ revalidateEndpoint: config === null || config === void 0 ? void 0 : (_config$preview2 = config.preview) === null || _config$preview2 === void 0 ? void 0 : _config$preview2.revalidateEndpoint
107
110
  },
108
111
  corsErrorModal: (config === null || config === void 0 ? void 0 : config.corsErrorModal) ?? true
109
112
  };
@@ -5,7 +5,8 @@ import _FontdueAdminToolbarQuery from "../../__generated__/FontdueAdminToolbarQu
5
5
  import React, { useContext, useEffect, useState } from 'react';
6
6
  import { commitMutation, fetchQuery, graphql, useRelayEnvironment } from 'react-relay';
7
7
  import ConfigContext from '../ConfigContext.js';
8
- import { PREVIEW_ENDPOINT, PREVIEW_MARKER_COOKIE } from '../../preview/constants.js';
8
+ import { PREVIEW_ENDPOINT, hasPreviewMarkerCookie } from '../../preview/constants.js';
9
+ import { fontdueBaseUrl, version } from '../../relay/environment.js';
9
10
  // Admin-only affordance for logged-in foundry admins: reveal unpublished
10
11
  // ("hidden") fonts across the whole storefront. Storefront pages are
11
12
  // server-rendered sessionless and cached, and the provider's own query is
@@ -22,28 +23,31 @@ const adminQuery = (_FontdueAdminToolbarQuery.hash && _FontdueAdminToolbarQuery.
22
23
  // createAdminToken mints a short-lived, stateless admin token from the admin
23
24
  // session; it's handed to the preview route to enter draft mode.
24
25
  const tokenMutation = (_FontdueAdminToolbarTokenMutation.hash && _FontdueAdminToolbarTokenMutation.hash !== "2b82f195747c86d50fd5884d76f3b709" && console.error("The definition of 'FontdueAdminToolbarTokenMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _FontdueAdminToolbarTokenMutation);
25
-
26
- // The preview entry/exit route and the readable marker cookie are part of the
27
- // portable preview contract — see fontdue-js/preview. The route path defaults
28
- // to '/api/preview' but is configurable via FontdueConfig.preview.endpoint so
29
- // non-Next apps can mount it wherever their router expects (the Next adapter's
30
- // draft-mode route, an Astro APIRoute, an RR7 action — all honor the same
31
- // POST-token-to-enter / DELETE-to-exit contract).
32
- function hasPreviewCookie() {
33
- return typeof document !== 'undefined' && document.cookie.split('; ').some(c => c === `${PREVIEW_MARKER_COOKIE}=1`);
34
- }
35
26
  export default function FontdueAdminToolbar() {
36
- var _useContext$preview;
37
27
  const environment = useRelayEnvironment();
38
- const previewEndpoint = ((_useContext$preview = useContext(ConfigContext).preview) === null || _useContext$preview === void 0 ? void 0 : _useContext$preview.endpoint) ?? PREVIEW_ENDPOINT;
28
+ const previewConfig = useContext(ConfigContext).preview;
29
+ // The preview entry/exit route is part of the portable preview contract (see
30
+ // fontdue-js/preview); it defaults to '/api/preview' but is configurable so
31
+ // non-Next apps can mount it where their router expects. The revalidate route
32
+ // is opt-in (no default) — see config.preview.revalidateEndpoint.
33
+ const previewEndpoint = (previewConfig === null || previewConfig === void 0 ? void 0 : previewConfig.endpoint) ?? PREVIEW_ENDPOINT;
34
+ const revalidateEndpoint = previewConfig === null || previewConfig === void 0 ? void 0 : previewConfig.revalidateEndpoint;
35
+
36
+ // Where the admin is signed in: the configured Fontdue origin. Used for the
37
+ // "signed in at" line and the deep link back to the admin. Undefined (e.g.
38
+ // multi-tenant) just drops both gracefully.
39
+ const fontdueUrl = fontdueBaseUrl();
40
+ const fontdueHost = fontdueUrl ? safeHost(fontdueUrl) : null;
41
+ const adminUrl = fontdueUrl ? `${fontdueUrl.replace(/\/+$/, '')}/admin` : null;
39
42
  const [adminName, setAdminName] = useState(null);
40
43
  const [ready, setReady] = useState(false);
41
44
  const [open, setOpen] = useState(false);
42
45
  const [previewing, setPreviewing] = useState(false);
43
46
  const [busy, setBusy] = useState(false);
44
47
  const [error, setError] = useState(null);
48
+ const [notice, setNotice] = useState(null);
45
49
  useEffect(() => {
46
- setPreviewing(hasPreviewCookie());
50
+ setPreviewing(hasPreviewMarkerCookie());
47
51
  // network-only: the provider's preloaded query already wrote a sessionless
48
52
  // `viewer` to the store, so we must go to the network (with the session)
49
53
  // rather than read that cached, admin-blind copy.
@@ -83,6 +87,7 @@ export default function FontdueAdminToolbar() {
83
87
  function enterPreview() {
84
88
  setBusy(true);
85
89
  setError(null);
90
+ setNotice(null);
86
91
  commitMutation(environment, {
87
92
  mutation: tokenMutation,
88
93
  variables: {},
@@ -105,11 +110,32 @@ export default function FontdueAdminToolbar() {
105
110
  async function exitPreview() {
106
111
  setBusy(true);
107
112
  setError(null);
113
+ setNotice(null);
108
114
  await fetch(previewEndpoint, {
109
115
  method: 'DELETE'
110
116
  });
111
117
  window.location.reload();
112
118
  }
119
+
120
+ // Purge the cached storefront so public visitors see freshly published (or
121
+ // newly hidden) content. Targets the configured revalidate route as-is.
122
+ async function refreshCache() {
123
+ if (!revalidateEndpoint) return;
124
+ setBusy(true);
125
+ setError(null);
126
+ setNotice(null);
127
+ try {
128
+ const res = await fetch(revalidateEndpoint, {
129
+ method: 'POST'
130
+ });
131
+ if (!res.ok) throw new Error(`status ${res.status}`);
132
+ setNotice('Public cache refreshed.');
133
+ } catch {
134
+ setError('Could not refresh the cache.');
135
+ } finally {
136
+ setBusy(false);
137
+ }
138
+ }
113
139
  return /*#__PURE__*/React.createElement("div", {
114
140
  className: "fontdue-admin-toolbar",
115
141
  "data-previewing": previewing,
@@ -132,9 +158,29 @@ export default function FontdueAdminToolbar() {
132
158
  "data-testid": "preview-toggle"
133
159
  }), /*#__PURE__*/React.createElement("span", null, "Preview hidden fonts")), /*#__PURE__*/React.createElement("p", {
134
160
  className: "fontdue-admin-toolbar__hint"
135
- }, previewing ? 'Unpublished fonts are visible across the site — only to you.' : 'Reveal unpublished fonts everywhere, only for you.'), error && /*#__PURE__*/React.createElement("p", {
161
+ }, previewing ? 'Unpublished fonts are visible across the site — only to you.' : 'Reveal unpublished fonts everywhere, only for you.'), (revalidateEndpoint || adminUrl) && /*#__PURE__*/React.createElement("div", {
162
+ className: "fontdue-admin-toolbar__actions"
163
+ }, revalidateEndpoint && /*#__PURE__*/React.createElement("button", {
164
+ type: "button",
165
+ className: "fontdue-admin-toolbar__action",
166
+ onClick: refreshCache,
167
+ disabled: busy,
168
+ "data-testid": "revalidate-button"
169
+ }, "Refresh public cache"), adminUrl && /*#__PURE__*/React.createElement("a", {
170
+ className: "fontdue-admin-toolbar__action",
171
+ href: adminUrl,
172
+ target: "_blank",
173
+ rel: "noreferrer",
174
+ "data-testid": "open-admin-link"
175
+ }, "Open Fontdue admin \u2197")), notice && /*#__PURE__*/React.createElement("p", {
176
+ className: "fontdue-admin-toolbar__notice"
177
+ }, notice), error && /*#__PURE__*/React.createElement("p", {
136
178
  className: "fontdue-admin-toolbar__error"
137
- }, error)), /*#__PURE__*/React.createElement("button", {
179
+ }, error), /*#__PURE__*/React.createElement("p", {
180
+ className: "fontdue-admin-toolbar__meta"
181
+ }, fontdueHost ? `Shown because you’re signed in to Fontdue at ${fontdueHost}.` : 'Shown because you’re signed in to Fontdue.', /*#__PURE__*/React.createElement("span", {
182
+ className: "fontdue-admin-toolbar__version"
183
+ }, "fontdue-js ", version))), /*#__PURE__*/React.createElement("button", {
138
184
  type: "button",
139
185
  className: "fontdue-admin-toolbar__button",
140
186
  onClick: () => setOpen(v => !v),
@@ -144,4 +190,14 @@ export default function FontdueAdminToolbar() {
144
190
  className: "fontdue-admin-toolbar__dot",
145
191
  "aria-hidden": "true"
146
192
  }), "Fontdue", previewing ? ' · previewing' : ''));
193
+ }
194
+
195
+ // Hostname of a base URL, or the raw value if it can't be parsed (so a
196
+ // misconfigured URL still shows something rather than throwing).
197
+ function safeHost(url) {
198
+ try {
199
+ return new URL(url).host;
200
+ } catch {
201
+ return url;
202
+ }
147
203
  }
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import type { FontdueProvider_props } from './index.js';
3
+ import '../../next/registerSingleTenantResolver.js';
3
4
  export type { FontdueProvider_props } from './index.js';
4
5
  export type { FontdueProviderPreloadedQuery } from '../../loadFontdueProviderQuery.js';
5
6
  export type { FontdueServerConfig } from '../../relay/serverConfig.js';