fontdue-js 3.0.0-alpha11 → 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 (35) 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 +14 -0
  6. package/README.md +144 -17
  7. package/dist/__tests__/createFontdueFetch.test.js +154 -3
  8. package/dist/__tests__/networkFetch.test.js +81 -2
  9. package/dist/__tests__/nextAdapter.test.js +249 -40
  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 +122 -49
  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 +15 -3
  31. package/dist/server/index.js +77 -31
  32. package/package.json +1 -1
  33. package/types/next-headers.d.ts +9 -0
  34. package/types/next-navigation.d.ts +4 -0
  35. 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,17 +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
+ }
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
+ };
27
64
  }
28
65
  }));
29
66
  beforeEach(() => {
30
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__;
31
72
  revalidateTag.mockClear();
32
73
  vi.unstubAllEnvs();
33
74
  vi.restoreAllMocks();
75
+ draft.enabled = false;
76
+ draft.token = undefined;
77
+ draft.cookiesError = undefined;
34
78
  });
35
79
  function stubSingleTenant() {
36
80
  let url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'https://acme.fontdue.com';
@@ -64,13 +108,13 @@ describe('isValidDomain', () => {
64
108
  expect(isValidDomain('a'.repeat(254) + '.example')).toBe(false);
65
109
  });
66
110
  });
67
- describe('fontdueEndpoint', () => {
111
+ describe('endpointForDomain', () => {
68
112
  it('single-tenant: targets NEXT_PUBLIC_FONTDUE_URL with no headers', async () => {
69
113
  stubSingleTenant('https://acme.fontdue.com');
70
114
  const {
71
- fontdueEndpoint
115
+ endpointForDomain
72
116
  } = await importTenant();
73
- expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
117
+ expect(endpointForDomain('acme.fontdue.com')).toEqual({
74
118
  domain: 'acme.fontdue.com',
75
119
  origin: 'https://acme.fontdue.com',
76
120
  headers: {},
@@ -81,9 +125,9 @@ describe('fontdueEndpoint', () => {
81
125
  vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
82
126
  vi.stubEnv('FONTDUE_MULTI_TENANT', '');
83
127
  const {
84
- fontdueEndpoint
128
+ endpointForDomain
85
129
  } = await importTenant();
86
- expect(() => fontdueEndpoint('acme.fontdue.com')).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
130
+ expect(() => endpointForDomain('acme.fontdue.com')).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
87
131
  });
88
132
  it('multi-tenant: forwards the host to FONTDUE_ORIGIN with the proxy secret', async () => {
89
133
  stubMultiTenant({
@@ -91,9 +135,9 @@ describe('fontdueEndpoint', () => {
91
135
  secret: 's3cret'
92
136
  });
93
137
  const {
94
- fontdueEndpoint
138
+ endpointForDomain
95
139
  } = await importTenant();
96
- expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
140
+ expect(endpointForDomain('acme.fontdue.com')).toEqual({
97
141
  domain: 'acme.fontdue.com',
98
142
  origin: 'http://app:4000',
99
143
  headers: {
@@ -108,18 +152,18 @@ describe('fontdueEndpoint', () => {
108
152
  origin: 'http://app:4000'
109
153
  });
110
154
  const {
111
- fontdueEndpoint
155
+ endpointForDomain
112
156
  } = await importTenant();
113
- expect(fontdueEndpoint('acme.fontdue.com').headers).toEqual({
157
+ expect(endpointForDomain('acme.fontdue.com').headers).toEqual({
114
158
  'x-forwarded-host': 'acme.fontdue.com'
115
159
  });
116
160
  });
117
161
  it('multi-tenant: falls back to the tenant public URL without FONTDUE_ORIGIN', async () => {
118
162
  stubMultiTenant();
119
163
  const {
120
- fontdueEndpoint
164
+ endpointForDomain
121
165
  } = await importTenant();
122
- expect(fontdueEndpoint('acme.fontdue.com').origin).toBe('https://acme.fontdue.com');
166
+ expect(endpointForDomain('acme.fontdue.com').origin).toBe('https://acme.fontdue.com');
123
167
  });
124
168
  });
125
169
  describe('configureFontdueRender', () => {
@@ -131,10 +175,10 @@ describe('configureFontdueRender', () => {
131
175
  configureFontdueRender
132
176
  } = await importTenant();
133
177
  const {
134
- getFontdueServerConfig
178
+ getFontdueSlotConfig
135
179
  } = await import("../relay/serverConfig.js");
136
180
  expect(configureFontdueRender('not a domain')).toBeNull();
137
- expect(getFontdueServerConfig()).toBeUndefined();
181
+ expect(getFontdueSlotConfig()).toBeUndefined();
138
182
  });
139
183
  it('sets the per-render server config and returns the endpoint', async () => {
140
184
  stubMultiTenant({
@@ -144,9 +188,6 @@ describe('configureFontdueRender', () => {
144
188
  const {
145
189
  configureFontdueRender
146
190
  } = await importTenant();
147
- const {
148
- getFontdueServerConfig
149
- } = await import("../relay/serverConfig.js");
150
191
  const endpoint = configureFontdueRender('acme.fontdue.com');
151
192
  expect(endpoint === null || endpoint === void 0 ? void 0 : endpoint.origin).toBe('http://app:4000');
152
193
  // Outside an RSC render React.cache doesn't memoize, so the write is a
@@ -166,7 +207,7 @@ describe('configureFontdueRender', () => {
166
207
  });
167
208
  });
168
209
  });
169
- describe('prepareFontdueRender', () => {
210
+ describe('__prepareFontdueRender', () => {
170
211
  const props = params => ({
171
212
  params: Promise.resolve(params)
172
213
  });
@@ -175,9 +216,9 @@ describe('prepareFontdueRender', () => {
175
216
  origin: 'http://app:4000'
176
217
  });
177
218
  const {
178
- prepareFontdueRender
219
+ __prepareFontdueRender
179
220
  } = await importTenant();
180
- const endpoint = await prepareFontdueRender(props({
221
+ const endpoint = await __prepareFontdueRender(props({
181
222
  domain: 'acme.fontdue.com',
182
223
  slug: 'sans'
183
224
  }));
@@ -187,32 +228,209 @@ describe('prepareFontdueRender', () => {
187
228
  it('404s invalid or missing domains', async () => {
188
229
  stubMultiTenant();
189
230
  const {
190
- prepareFontdueRender
231
+ __prepareFontdueRender
191
232
  } = await importTenant();
192
- await expect(prepareFontdueRender(props({
233
+ await expect(__prepareFontdueRender(props({
193
234
  domain: 'not a domain'
194
235
  }))).rejects.toThrow('NEXT_NOT_FOUND');
195
- await expect(prepareFontdueRender(props({
236
+ await expect(__prepareFontdueRender(props({
196
237
  slug: 'sans'
197
238
  }))).rejects.toThrow('NEXT_NOT_FOUND');
198
239
  });
240
+
241
+ // React.cache doesn't memoize outside an RSC render (see configureFontdueRender
242
+ // above), so the slot write is a no-op here — capture what __prepareFontdueRender
243
+ // passes to setFontdueServerConfig instead.
244
+ async function captureRenderConfig(params) {
245
+ let captured;
246
+ vi.doMock('../relay/serverConfig', async importActual => ({
247
+ ...(await importActual()),
248
+ setFontdueServerConfig: c => {
249
+ captured = c;
250
+ }
251
+ }));
252
+ const {
253
+ __prepareFontdueRender
254
+ } = await importTenant();
255
+ await __prepareFontdueRender(props(params));
256
+ vi.doUnmock('../relay/serverConfig');
257
+ return captured;
258
+ }
259
+ it('public render: sets the tenant config with cache tags and no token', async () => {
260
+ var _config$headers;
261
+ stubMultiTenant({
262
+ origin: 'http://app:4000'
263
+ });
264
+ const config = await captureRenderConfig({
265
+ domain: 'acme.fontdue.com'
266
+ });
267
+ expect(config.cacheTags).toEqual(['graphql:acme.fontdue.com']);
268
+ expect((_config$headers = config.headers) === null || _config$headers === void 0 ? void 0 : _config$headers.authorization).toBeUndefined();
269
+ });
270
+ it('preview render: folds the token into headers and drops cache tags', async () => {
271
+ var _config$headers2;
272
+ stubMultiTenant({
273
+ origin: 'http://app:4000'
274
+ });
275
+ draft.enabled = true;
276
+ draft.token = 'admin-tok';
277
+ const config = await captureRenderConfig({
278
+ domain: 'acme.fontdue.com'
279
+ });
280
+ // Embeds (createNetworkFetch) and the app's fetch (createFontdueFetch) both
281
+ // read this, so both reveal hidden fonts; no tags keeps the render live.
282
+ expect((_config$headers2 = config.headers) === null || _config$headers2 === void 0 ? void 0 : _config$headers2.authorization).toBe('Bearer admin-tok');
283
+ expect(config.cacheTags).toBeUndefined();
284
+ });
199
285
  });
200
- 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', () => {
201
419
  it('multi-tenant: throws when no render config was set', async () => {
202
420
  stubMultiTenant({
203
421
  origin: 'http://app:4000'
204
422
  });
205
423
  const {
206
- currentFontdueEndpoint
424
+ fontdueEndpoint
207
425
  } = await importTenant();
208
- expect(() => currentFontdueEndpoint()).toThrow(/prepareFontdueRender/);
426
+ expect(() => fontdueEndpoint()).toThrow(/__prepareFontdueRender/);
209
427
  });
210
428
  it('single-tenant: derives the endpoint from NEXT_PUBLIC_FONTDUE_URL', async () => {
211
429
  stubSingleTenant('https://acme.fontdue.com');
212
430
  const {
213
- currentFontdueEndpoint
431
+ fontdueEndpoint
214
432
  } = await importTenant();
215
- expect(currentFontdueEndpoint()).toEqual({
433
+ expect(fontdueEndpoint()).toEqual({
216
434
  domain: 'acme.fontdue.com',
217
435
  origin: 'https://acme.fontdue.com',
218
436
  headers: {},
@@ -225,26 +443,17 @@ describe('currentFontdueEndpoint', () => {
225
443
  });
226
444
  vi.doMock('../relay/serverConfig', async importActual => ({
227
445
  ...(await importActual()),
228
- getFontdueServerConfig: () => ({
446
+ getFontdueSlotConfig: () => ({
229
447
  domain: 'acme.fontdue.com'
230
448
  })
231
449
  }));
232
450
  const {
233
- currentFontdueEndpoint
451
+ fontdueEndpoint
234
452
  } = await importTenant();
235
- expect(currentFontdueEndpoint().headers['x-forwarded-host']).toBe('acme.fontdue.com');
453
+ expect(fontdueEndpoint().headers['x-forwarded-host']).toBe('acme.fontdue.com');
236
454
  vi.doUnmock('../relay/serverConfig');
237
455
  });
238
456
  });
239
- describe('generateStaticParams', () => {
240
- it('returns no build-time params (everything renders on demand)', async () => {
241
- stubMultiTenant();
242
- const {
243
- generateStaticParams
244
- } = await importTenant();
245
- await expect(generateStaticParams()).resolves.toEqual([]);
246
- });
247
- });
248
457
 
249
458
  // withFontdue detects the route-tree shape from the working directory; give
250
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
  }