fontdue-js 3.0.0-alpha8 → 3.0.0-alpha9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 2.28.0
2
+
3
+ - Checkout now captures the buyer's **analytics consent and ad attribution** on the order. When the cart or store-modal checkout opens, fontdue-js sends the consent-banner state, anonymous ID, Meta browser IDs (the `_fbp`/`_fbc` cookies), and the page URL to the server, where they're stored on the order. This lets Fontdue emit a server-side *purchase* conversion event (Facebook Conversions API, Google Ads) when the order completes — completion happens in a Stripe webhook, outside the browser — while respecting the buyer's cookie-consent choice: if consent was declined, no identifiers are sent and nothing is forwarded to ad platforms. The call is fire-and-forget and never affects checkout.
4
+
5
+ ## 2.27.0
6
+
7
+ - The `TypeTester` `truncate` option now accepts a **number** to cap the sample paragraph at N lines (in addition to `false` = off and `true` = 1 line). Truncation is now ascender/descender-safe at any line-height — it reads the font's vertical metrics so accents and descenders are never clipped — and flows the clipped text into columns, so multi-column truncated specimens render correctly and side-by-side testers keep their toolbars aligned.
8
+ - The `TypeTester` **family selector now lazy-loads its data**. The family list is fetched on first dropdown open (shared once per page) and per-family styles load on demand, instead of fetching the whole font library up front. This substantially speeds up the initial render of pages with type testers, especially with several standalone testers. The dropdown shows a “Loading…” state while data loads.
9
+
1
10
  ## 2.26.1
2
11
 
3
12
  - Fixed a jitter in the `TypeTester` variable-font axis sliders: dragging a slider no longer shifts the handle position as the instance name and value beside it change width. The instance name now settles shortly after dragging stops, and the value reserves a fixed width for its range.
package/README.md CHANGED
@@ -53,7 +53,18 @@ The shape of step 3 and 4 is the only thing that changes between frameworks.
53
53
  <details>
54
54
  <summary><b>Next.js (App Router)</b></summary>
55
55
 
56
- No Vite plugin needed. The simplest setup omits the layout preload — with React Server Components, each fontdue-js component preloads its own query internally on the server and streams to the client.
56
+ No Vite plugin needed wrap your Next config with [`withFontdue`](#nextjs-adapter) instead:
57
+
58
+ ```js
59
+ // next.config.mjs
60
+ import { withFontdue } from "fontdue-js/next/config";
61
+
62
+ export default withFontdue({
63
+ // your Next config
64
+ });
65
+ ```
66
+
67
+ The simplest setup omits the layout preload — with React Server Components, each fontdue-js component preloads its own query internally on the server and streams to the client.
57
68
 
58
69
  ```tsx
59
70
  // app/layout.tsx
@@ -84,6 +95,8 @@ export default function FontPage() {
84
95
  }
85
96
  ```
86
97
 
98
+ Beyond the components, Next.js projects get a few extra entry points — config wrapping, cache revalidation, and helpers for your own GraphQL fetches. See [Next.js adapter](#nextjs-adapter).
99
+
87
100
  Example repo: [`fontdue/fontdue-example-next`](https://github.com/fontdue/fontdue-example-next)
88
101
 
89
102
  </details>
@@ -356,6 +369,76 @@ export default function App() {
356
369
 
357
370
  </details>
358
371
 
372
+ ## Next.js adapter
373
+
374
+ Next.js App Router projects get a few extra entry points beyond the components. The [example repo](https://github.com/fontdue/fontdue-example-next) wires up all of them.
375
+
376
+ ### `withFontdue` — next.config wrapper
377
+
378
+ ```js
379
+ // next.config.mjs
380
+ import { withFontdue } from "fontdue-js/next/config";
381
+
382
+ export default withFontdue({
383
+ // your Next config
384
+ });
385
+ ```
386
+
387
+ What it installs:
388
+
389
+ - **Image settings** — `images.remotePatterns` entries for Fontdue's image hosts (plus `dangerouslyAllowSVG`, since font specimens are often SVGs), merged with your own `images` config.
390
+ - **Correct 404 statuses** — Next's streamed metadata locks in a `200` response before a `notFound()` thrown during `generateMetadata` can take effect ([vercel/next.js#82041](https://github.com/vercel/next.js/issues/82041)). `withFontdue` sets `htmlLimitedBots` to match every user agent so metadata rendering blocks the response and missing pages come out as real 404s.
391
+
392
+ The rest of your config — `rewrites` included — passes through unchanged.
393
+
394
+ #### Optional: image optimization on Cloudflare
395
+
396
+ If you have a Cloudflare zone with [image transformations](https://developers.cloudflare.com/images/transform-images/) enabled, set:
397
+
398
+ ```shell
399
+ NEXT_PUBLIC_FONTDUE_IMAGE_HOST=img.your-domain.com
400
+ NEXT_PUBLIC_FONTDUE_IMAGE_ORIGINS=cdn.fontdue.com
401
+ ```
402
+
403
+ `next/image` optimization then moves to the Cloudflare edge, and your deployment needs neither the `/_next/image` endpoint nor sharp. `NEXT_PUBLIC_FONTDUE_IMAGE_ORIGINS` (comma-separated hostnames) should mirror the transformation host's allowed source origins — sources on other hosts are served as originals rather than as transform URLs Cloudflare would refuse. Both variables must be present when `next build` runs (the loader is inlined into the client bundle), not just at serve time.
404
+
405
+ ### Updating content: `/api/revalidate`
406
+
407
+ fontdue-js's server-side fetches are cached by Next and tagged `graphql`. Re-export the deploy-hook route handler:
408
+
409
+ ```ts
410
+ // app/api/revalidate/route.ts
411
+ export { POST } from "fontdue-js/next/revalidate";
412
+ ```
413
+
414
+ and set the Deploy hook URL in your Fontdue admin (Settings → Website settings) to `https://your-site.example/api/revalidate`. Fontdue calls it whenever your site's content changes, purging everything tagged `graphql` so the next request renders fresh.
415
+
416
+ fontdue-js's own server-side fetches opt into Next's data cache (and the `graphql` tag) automatically — static pages revalidated by the deploy hook is the intended way to run a Fontdue site, not dynamic rendering. Give your own fetches the same treatment; `currentFontdueEndpoint()` below shows how.
417
+
418
+ ### Your own GraphQL fetches
419
+
420
+ `currentFontdueEndpoint()` (from `fontdue-js/next`) describes the endpoint your own server-side [GraphQL](https://docs.fontdue.com/graphql-api) fetches should use — the base origin, required headers, and the cache tags that tie them into `/api/revalidate`:
421
+
422
+ ```ts
423
+ import { currentFontdueEndpoint } from "fontdue-js/next";
424
+
425
+ export async function fetchGraphql(query: string, variables?: unknown) {
426
+ const endpoint = currentFontdueEndpoint();
427
+ const response = await fetch(`${endpoint.origin}/graphql`, {
428
+ method: "POST",
429
+ body: JSON.stringify({ query, variables }),
430
+ headers: { "content-type": "application/json", ...endpoint.headers },
431
+ // Cache explicitly (Next 15 doesn't cache fetch by default), tagged so
432
+ // the deploy hook purges this too.
433
+ cache: "force-cache",
434
+ next: { tags: endpoint.tags },
435
+ });
436
+ return (await response.json()).data;
437
+ }
438
+ ```
439
+
440
+ The shape is exported as `type FontdueEndpoint`.
441
+
359
442
  ## UI config
360
443
 
361
444
  Most components accept a `config` object that controls UI behavior — type-tester options (`selectable`, `priceBar`, size ranges, OpenType-feature UI…), store-modal layout, form styling, analytics tracking, and more. See the [full config reference](https://docs.fontdue.com/fontduejs#b3dec49aa08240bba2b4c71a67c08333).
@@ -1,9 +1,3 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- exports.default = void 0;
7
1
  /**
8
2
  * @generated SignedSource<<017a8a724b3a0fd0918153ce04d07f68>>
9
3
  * @lightSyntaxTransform
@@ -68,5 +62,4 @@ const node = function () {
68
62
  };
69
63
  }();
70
64
  node.hash = "d59a127a7f140424f507ae549731bac7";
71
- var _default = node;
72
- exports.default = _default;
65
+ export default node;
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ // environment.ts reads env at module load, so stub env and re-import fresh.
3
+ beforeEach(() => {
4
+ vi.resetModules();
5
+ vi.unstubAllEnvs();
6
+ vi.unstubAllGlobals();
7
+ });
8
+ const request = {
9
+ name: 'TestQuery',
10
+ text: 'query TestQuery { viewer { id } }'
11
+ };
12
+ describe('createNetworkFetch (server)', () => {
13
+ it('opts fetches into the Next data cache, tagged for revalidation', async () => {
14
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
15
+ const fetchMock = vi.fn(async () => ({
16
+ json: async () => ({
17
+ data: {}
18
+ })
19
+ }));
20
+ vi.stubGlobal('fetch', fetchMock);
21
+ const {
22
+ createNetworkFetch
23
+ } = await import("../relay/environment.js");
24
+ await createNetworkFetch()(request, {});
25
+ expect(fetchMock).toHaveBeenCalledTimes(1);
26
+ const [url, options] = fetchMock.mock.calls[0];
27
+ expect(url).toBe('https://acme.fontdue.com/graphql?queryName=TestQuery');
28
+ // Without an explicit cache option Next 15 treats the fetch as no-store
29
+ // and silently makes every page fully dynamic — the static + revalidate
30
+ // pattern depends on this.
31
+ expect(options.cache).toBe('force-cache');
32
+ expect(options.next.tags).toContain('graphql');
33
+ expect(options.next.tags).toContain('operation:TestQuery');
34
+ });
35
+ it('applies the per-render server config tags and headers', async () => {
36
+ vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
37
+ const fetchMock = vi.fn(async () => ({
38
+ json: async () => ({
39
+ data: {}
40
+ })
41
+ }));
42
+ vi.stubGlobal('fetch', fetchMock);
43
+
44
+ // React.cache doesn't memoize outside a React render, so the store is
45
+ // mocked rather than set through setFontdueServerConfig.
46
+ vi.doMock('../relay/serverConfig', async importActual => ({
47
+ ...(await importActual()),
48
+ getFontdueServerConfig: () => ({
49
+ url: 'http://app:4000',
50
+ headers: {
51
+ 'x-forwarded-host': 'acme.fontdue.com'
52
+ },
53
+ cacheTags: ['graphql:acme.fontdue.com']
54
+ })
55
+ }));
56
+ const {
57
+ createNetworkFetch
58
+ } = await import("../relay/environment.js");
59
+ await createNetworkFetch()(request, {});
60
+ vi.doUnmock('../relay/serverConfig');
61
+ const [url, options] = fetchMock.mock.calls[0];
62
+ expect(url).toBe('http://app:4000/graphql?queryName=TestQuery');
63
+ expect(options.headers['x-forwarded-host']).toBe('acme.fontdue.com');
64
+ expect(options.next.tags).toContain('graphql:acme.fontdue.com');
65
+ });
66
+ });
@@ -1,4 +1,7 @@
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.
@@ -15,10 +18,19 @@ const revalidateTag = vi.fn();
15
18
  vi.mock('next/cache', () => ({
16
19
  revalidateTag: tag => revalidateTag(tag)
17
20
  }));
21
+
22
+ // Next's notFound() throws a sentinel the framework catches; the mock does
23
+ // the same so tests can assert on it.
24
+ vi.mock('next/navigation', () => ({
25
+ notFound: () => {
26
+ throw new Error('NEXT_NOT_FOUND');
27
+ }
28
+ }));
18
29
  beforeEach(() => {
19
30
  vi.resetModules();
20
31
  revalidateTag.mockClear();
21
32
  vi.unstubAllEnvs();
33
+ vi.restoreAllMocks();
22
34
  });
23
35
  function stubSingleTenant() {
24
36
  let url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'https://acme.fontdue.com';
@@ -59,6 +71,7 @@ describe('fontdueEndpoint', () => {
59
71
  fontdueEndpoint
60
72
  } = await importTenant();
61
73
  expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
74
+ domain: 'acme.fontdue.com',
62
75
  origin: 'https://acme.fontdue.com',
63
76
  headers: {},
64
77
  tags: ['graphql', 'graphql:acme.fontdue.com']
@@ -81,6 +94,7 @@ describe('fontdueEndpoint', () => {
81
94
  fontdueEndpoint
82
95
  } = await importTenant();
83
96
  expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
97
+ domain: 'acme.fontdue.com',
84
98
  origin: 'http://app:4000',
85
99
  headers: {
86
100
  'x-forwarded-host': 'acme.fontdue.com',
@@ -147,11 +161,107 @@ describe('configureFontdueRender', () => {
147
161
  'x-forwarded-host': 'acme.fontdue.com',
148
162
  'x-fontdue-proxy-secret': 's3cret'
149
163
  },
150
- cacheTags: ['graphql:acme.fontdue.com']
164
+ cacheTags: ['graphql:acme.fontdue.com'],
165
+ domain: 'acme.fontdue.com'
151
166
  });
152
167
  });
153
168
  });
169
+ describe('prepareFontdueRender', () => {
170
+ const props = params => ({
171
+ params: Promise.resolve(params)
172
+ });
173
+ it('configures the render and returns the endpoint', async () => {
174
+ stubMultiTenant({
175
+ origin: 'http://app:4000'
176
+ });
177
+ const {
178
+ prepareFontdueRender
179
+ } = await importTenant();
180
+ const endpoint = await prepareFontdueRender(props({
181
+ domain: 'acme.fontdue.com',
182
+ slug: 'sans'
183
+ }));
184
+ expect(endpoint.origin).toBe('http://app:4000');
185
+ expect(endpoint.tags).toContain('graphql:acme.fontdue.com');
186
+ });
187
+ it('404s invalid or missing domains', async () => {
188
+ stubMultiTenant();
189
+ const {
190
+ prepareFontdueRender
191
+ } = await importTenant();
192
+ await expect(prepareFontdueRender(props({
193
+ domain: 'not a domain'
194
+ }))).rejects.toThrow('NEXT_NOT_FOUND');
195
+ await expect(prepareFontdueRender(props({
196
+ slug: 'sans'
197
+ }))).rejects.toThrow('NEXT_NOT_FOUND');
198
+ });
199
+ });
200
+ describe('currentFontdueEndpoint', () => {
201
+ it('multi-tenant: throws when no render config was set', async () => {
202
+ stubMultiTenant({
203
+ origin: 'http://app:4000'
204
+ });
205
+ const {
206
+ currentFontdueEndpoint
207
+ } = await importTenant();
208
+ expect(() => currentFontdueEndpoint()).toThrow(/prepareFontdueRender/);
209
+ });
210
+ it('single-tenant: derives the endpoint from NEXT_PUBLIC_FONTDUE_URL', async () => {
211
+ stubSingleTenant('https://acme.fontdue.com');
212
+ const {
213
+ currentFontdueEndpoint
214
+ } = await importTenant();
215
+ expect(currentFontdueEndpoint()).toEqual({
216
+ domain: 'acme.fontdue.com',
217
+ origin: 'https://acme.fontdue.com',
218
+ headers: {},
219
+ tags: ['graphql', 'graphql:acme.fontdue.com']
220
+ });
221
+ });
222
+ it('uses the render-configured domain when set', async () => {
223
+ stubMultiTenant({
224
+ origin: 'http://app:4000'
225
+ });
226
+ vi.doMock('../relay/serverConfig', async importActual => ({
227
+ ...(await importActual()),
228
+ getFontdueServerConfig: () => ({
229
+ domain: 'acme.fontdue.com'
230
+ })
231
+ }));
232
+ const {
233
+ currentFontdueEndpoint
234
+ } = await importTenant();
235
+ expect(currentFontdueEndpoint().headers['x-forwarded-host']).toBe('acme.fontdue.com');
236
+ vi.doUnmock('../relay/serverConfig');
237
+ });
238
+ });
239
+ describe('generateStaticParams', () => {
240
+ it('returns no build-time params (everything renders on demand)', async () => {
241
+ stubMultiTenant();
242
+ const {
243
+ generateStaticParams
244
+ } = await importTenant();
245
+ await expect(generateStaticParams()).resolves.toEqual([]);
246
+ });
247
+ });
248
+
249
+ // withFontdue detects the route-tree shape from the working directory; give
250
+ // it one with or without src/app/[domain].
251
+ function mockAppDir(_ref) {
252
+ let {
253
+ domainTree
254
+ } = _ref;
255
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fontdue-app-'));
256
+ fs.mkdirSync(path.join(dir, domainTree ? 'src/app/[domain]' : 'src/app'), {
257
+ recursive: true
258
+ });
259
+ vi.spyOn(process, 'cwd').mockReturnValue(dir);
260
+ }
154
261
  describe('withFontdue', () => {
262
+ beforeEach(() => mockAppDir({
263
+ domainTree: true
264
+ }));
155
265
  it('throws when neither mode is configured', async () => {
156
266
  vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
157
267
  vi.stubEnv('FONTDUE_MULTI_TENANT', '');
@@ -267,6 +377,36 @@ describe('withFontdue', () => {
267
377
  htmlLimitedBots: /Googlebot/
268
378
  }).htmlLimitedBots).toEqual(/Googlebot/);
269
379
  });
380
+ it('single-tenant flat tree: installs no tenant rewrites', async () => {
381
+ mockAppDir({
382
+ domainTree: false
383
+ });
384
+ stubSingleTenant('https://acme.fontdue.com');
385
+ const {
386
+ withFontdue
387
+ } = await importConfig();
388
+ const userRule = {
389
+ source: '/old',
390
+ destination: '/new'
391
+ };
392
+ const rewrites = await withFontdue({
393
+ rewrites: async () => ({
394
+ beforeFiles: [userRule]
395
+ })
396
+ }).rewrites();
397
+ expect(rewrites.beforeFiles).toEqual([userRule]);
398
+ expect(rewrites.afterFiles).toEqual([]);
399
+ });
400
+ it('multi-tenant flat tree: refuses to start', async () => {
401
+ mockAppDir({
402
+ domainTree: false
403
+ });
404
+ stubMultiTenant();
405
+ const {
406
+ withFontdue
407
+ } = await importConfig();
408
+ expect(() => withFontdue({})).toThrow(/\[domain\]/);
409
+ });
270
410
  });
271
411
  describe('revalidate POST', () => {
272
412
  it('multi-tenant: purges only the tenant tag', async () => {
@@ -4,7 +4,7 @@ import _CartOrderRemoveDiscountMutation from "../../__generated__/CartOrderRemov
4
4
  import _CartOrderUpdateMutation from "../../__generated__/CartOrderUpdateMutation.graphql.js";
5
5
  import _CartOrder_UpdateErrors from "../../__generated__/CartOrder_UpdateErrors.graphql.js";
6
6
  import _CartOrderCompleteOrderMutation from "../../__generated__/CartOrderCompleteOrderMutation.graphql.js";
7
- import React, { Fragment, useRef, useState } from 'react';
7
+ import React, { Fragment, useEffect, useRef, useState } from 'react';
8
8
  import { commitMutation, graphql, useFragment, useRelayEnvironment } from 'react-relay';
9
9
  import Checkout from './Checkout.js';
10
10
  import CheckoutSteps from './CheckoutSteps.js';
@@ -21,6 +21,7 @@ import { textVariablesAllHaveText } from './utils.js';
21
21
  import { useStripe } from '@stripe/react-stripe-js';
22
22
  import { useDispatch } from 'react-redux';
23
23
  import CartState from './CartState.js';
24
+ import { sendOrderTracking } from './orderTracking.js';
24
25
 
25
26
  // note here we're only updating the order (by id).
26
27
  // careful not to update the current customer's reference
@@ -44,6 +45,13 @@ const CartOrder = _ref => {
44
45
  const environment = useRelayEnvironment();
45
46
  const stripe = useStripe();
46
47
  const dispatch = useDispatch();
48
+
49
+ // Capture analytics consent + attribution on the order for the purchase
50
+ // event the server emits on completion (from a Stripe webhook, where no
51
+ // browser context exists).
52
+ useEffect(() => {
53
+ if (open) sendOrderTracking(environment);
54
+ }, [open, environment]);
47
55
  const order = useFragment((_CartOrder_order.hash && _CartOrder_order.hash !== "25ef000c40000c4f76f973ac6ac7f593" && console.error("The definition of 'CartOrder_order' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _CartOrder_order), orderKey);
48
56
  const viewer = useFragment((_CartOrder_viewer.hash && _CartOrder_viewer.hash !== "8cf5ab5a33a474483a6c231cc44b0df1" && console.error("The definition of 'CartOrder_viewer' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _CartOrder_viewer), viewerKey);
49
57
 
@@ -1,13 +1,6 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- exports.sendOrderTracking = sendOrderTracking;
7
- var _orderTrackingUpdateOrderTrackingMutation2 = _interopRequireDefault(require("../../__generated__/orderTrackingUpdateOrderTrackingMutation.graphql"));
8
- var _reactRelay = require("react-relay");
9
- var _consent = require("../ConsentBanner/consent");
10
- function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
1
+ import _orderTrackingUpdateOrderTrackingMutation from "../../__generated__/orderTrackingUpdateOrderTrackingMutation.graphql.js";
2
+ import { commitMutation, graphql } from 'react-relay';
3
+ import { hasConsent, getClientAnonymousId } from '../ConsentBanner/consent.js';
11
4
  function readCookie(name) {
12
5
  const match = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'));
13
6
  return match ? decodeURIComponent(match[1]) : undefined;
@@ -21,14 +14,14 @@ function readCookie(name) {
21
14
  *
22
15
  * Fire-and-forget: tracking must never break checkout.
23
16
  */
24
- function sendOrderTracking(environment) {
17
+ export function sendOrderTracking(environment) {
25
18
  try {
26
- (0, _reactRelay.commitMutation)(environment, {
27
- mutation: (_orderTrackingUpdateOrderTrackingMutation2.default.hash && _orderTrackingUpdateOrderTrackingMutation2.default.hash !== "d59a127a7f140424f507ae549731bac7" && console.error("The definition of 'orderTrackingUpdateOrderTrackingMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _orderTrackingUpdateOrderTrackingMutation2.default),
19
+ commitMutation(environment, {
20
+ mutation: (_orderTrackingUpdateOrderTrackingMutation.hash && _orderTrackingUpdateOrderTrackingMutation.hash !== "d59a127a7f140424f507ae549731bac7" && console.error("The definition of 'orderTrackingUpdateOrderTrackingMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _orderTrackingUpdateOrderTrackingMutation),
28
21
  variables: {
29
22
  input: {
30
- analyticsConsent: (0, _consent.hasConsent)('analytics'),
31
- anonymousId: (0, _consent.getClientAnonymousId)(),
23
+ analyticsConsent: hasConsent('analytics'),
24
+ anonymousId: getClientAnonymousId(),
32
25
  fbp: readCookie('_fbp'),
33
26
  fbc: readCookie('_fbc'),
34
27
  url: window.location.href
@@ -1,4 +1,5 @@
1
1
  import React, { type JSX } from 'react';
2
+ import { VerticalMetrics } from '../useFontStyle.js';
2
3
  import { FontStyle_fontStyle$key } from '../../__generated__/FontStyle_fontStyle.graphql.js';
3
4
  interface FontStyleProps {
4
5
  fontStyle: FontStyle_fontStyle$key;
@@ -7,6 +8,7 @@ interface FontStyleProps {
7
8
  children: ((props: {
8
9
  loaded: boolean;
9
10
  style: React.CSSProperties;
11
+ verticalMetrics: VerticalMetrics | null;
10
12
  }) => React.ReactNode) | React.ReactNode;
11
13
  }
12
14
  export default function FontStyle({ Component, fontStyle: fontStyleKey, style, children, ...rest }: Omit<React.HTMLAttributes<HTMLDivElement | HTMLSpanElement>, 'children'> & FontStyleProps): JSX.Element;
@@ -13,7 +13,8 @@ export default function FontStyle(_ref) {
13
13
  const fontStyle = useFragment((_FontStyle_fontStyle.hash && _FontStyle_fontStyle.hash !== "7891603bea1a3e40f40297bf1697eb3c" && console.error("The definition of 'FontStyle_fontStyle' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _FontStyle_fontStyle), fontStyleKey);
14
14
  const {
15
15
  style: fontStyleStyle,
16
- loaded
16
+ loaded,
17
+ verticalMetrics
17
18
  } = useFontStyle({
18
19
  fontStyle
19
20
  });
@@ -26,6 +27,7 @@ export default function FontStyle(_ref) {
26
27
  };
27
28
  return /*#__PURE__*/React.createElement(Component, renderProps, typeof children === 'function' ? children({
28
29
  loaded,
29
- style: fontStyleStyle
30
+ style: fontStyleStyle,
31
+ verticalMetrics
30
32
  }) : children);
31
33
  }
@@ -142,12 +142,14 @@ const TypeTester = _ref => {
142
142
  }, _ref3 => {
143
143
  let {
144
144
  loaded,
145
- style
145
+ style,
146
+ verticalMetrics
146
147
  } = _ref3;
147
148
  if (loaded) {
148
149
  return /*#__PURE__*/React.createElement(TypeTesterContent, _extends({}, props, {
149
150
  ref: contentRef,
150
151
  fontStyles: style,
152
+ verticalMetrics: verticalMetrics,
151
153
  truncate: config.truncate,
152
154
  direction: props.direction,
153
155
  min: config.size.min,
@@ -1,6 +1,7 @@
1
1
  import { EditorState } from 'draft-js';
2
2
  import { Alignment } from './types.js';
3
3
  import { VariableSettings } from '../../utils.js';
4
+ import { VerticalMetrics } from '../useFontStyle.js';
4
5
  export type ColumnsConfig = false | {
5
6
  count: 'auto' | number;
6
7
  width: string;
@@ -21,13 +22,14 @@ export interface UseTypeTesterStylerProps {
21
22
  focused: boolean;
22
23
  truncate: boolean | number;
23
24
  fontStyles: React.CSSProperties;
25
+ verticalMetrics?: VerticalMetrics | null;
24
26
  content: EditorState;
25
27
  contentEdited: boolean;
26
28
  alignment: Alignment;
27
29
  variableSettings: VariableSettings | null;
28
30
  columns: ColumnsConfig;
29
31
  }
30
- declare const useTypeTesterStyler: ({ size, autofit, autofitOnChange, features, setSize, min, max, truncate, focused, lineHeight, letterSpacing, fontStyles, content, contentEdited, alignment, variableSettings, columns, }: UseTypeTesterStylerProps) => {
32
+ declare const useTypeTesterStyler: ({ size, autofit, autofitOnChange, features, setSize, min, max, truncate, focused, lineHeight, letterSpacing, fontStyles, verticalMetrics, content, contentEdited, alignment, variableSettings, columns, }: UseTypeTesterStylerProps) => {
31
33
  ref: import("react").RefObject<HTMLDivElement | null>;
32
34
  style: import("react").CSSProperties;
33
35
  };
@@ -15,6 +15,7 @@ const useTypeTesterStyler = _ref => {
15
15
  lineHeight,
16
16
  letterSpacing,
17
17
  fontStyles,
18
+ verticalMetrics,
18
19
  content,
19
20
  contentEdited,
20
21
  alignment,
@@ -58,33 +59,82 @@ const useTypeTesterStyler = _ref => {
58
59
  const truncateLines = truncate === true ? 1 : typeof truncate === 'number' ? Math.max(0, Math.floor(truncate)) : 0;
59
60
  const shouldTruncate = truncateLines > 0 && !focused;
60
61
 
61
- // The trailing ellipsis needs `-webkit-line-clamp`, which can't coexist with
62
- // CSS multi-column. `columns` is set by default, so the ellipsis is only
63
- // available in single-column mode; otherwise the text is hard-clipped.
64
- const ellipsis = shouldTruncate && !columns;
62
+ // How far a line's ink spills past its line-box, top and bottom, as a fraction
63
+ // of the font-size (em). `fontLoader` feeds these exact metrics into the
64
+ // @font-face as ascent/descent-override, so this matches the browser's line
65
+ // geometry precisely. It is 0 once the line-height is at least the font's
66
+ // natural content height ((ascender − descender) / unitsPerEm) — i.e. for any
67
+ // normal reading line-height — and only grows for tight, overlapping setting.
68
+ // Kept in em (not px) so it scales with the *rendered* size, including the
69
+ // `--type-tester--adjustment` that shrinks the preview on mobile.
70
+ const lineOverflowEm = shouldTruncate && verticalMetrics && verticalMetrics.unitsPerEm > 0 ? Math.max(0, ((verticalMetrics.ascender - verticalMetrics.descender) / verticalMetrics.unitsPerEm - lineHeight) / 2) : 0;
71
+
72
+ // Columns are what make truncation overflow flow sideways — into clipped,
73
+ // off-screen columns — instead of stacking below the last visible line. That
74
+ // is what lets the metric padding reveal descenders/ascenders cleanly, and it
75
+ // means a multi-column tester (e.g. a 3-column specimen) truncates to
76
+ // `truncateLines` lines *per column*. When truncating with columns disabled we
77
+ // still establish a single column so the clipped overflow behaves the same way.
78
+ const columnStyle = columns ? {
79
+ columnCount: columns.count,
80
+ columnWidth: columns.width,
81
+ columnGap: columns.gap
82
+ } : undefined;
83
+
84
+ // A glyph's side bearings — ink that sits outside its advance width, like a
85
+ // leading lowercase 'j' or an italic overhang — would be shorn by the inline
86
+ // clip. Per-glyph bearings aren't in the font metrics, so reserve a flat 0.1em
87
+ // on each inline edge and cancel it with a negative margin so
88
+ // wrapping and text position stay put.
89
+ const sideBearing = shouldTruncate ? '0.1em' : '0px';
65
90
  const style = {
66
91
  fontSize: autofit ? `${size}px` : `calc(var(--type-tester--adjustment, 1) * ${size}px)`,
67
92
  lineHeight: `${lineHeight}`,
68
- // Reserve exactly `truncateLines` lines so the height is predictable and
69
- // side-by-side testers keep their toolbars aligned, then clip the overflow.
70
- height: shouldTruncate ? `${size * lineHeight * truncateLines}px` : 'auto',
71
- overflow: shouldTruncate ? 'hidden' : 'visible',
72
- ...(ellipsis && {
73
- display: '-webkit-box',
74
- WebkitBoxOrient: 'vertical',
75
- WebkitLineClamp: truncateLines
93
+ ...(shouldTruncate ? {
94
+ // Cap each column at `truncateLines` lines. `max-height` (not `height`)
95
+ // lets shorter paragraphs collapse to their content instead of padding
96
+ // out to N lines of whitespace. Columns send the clipped overflow
97
+ // sideways (into off-screen columns) rather than below, so the last
98
+ // visible line of every column is never partially covered by the next
99
+ // one. That lets the symmetric `lineOverflowEm` padding fully reveal the
100
+ // first line's ascenders/accents and the last line's descenders at any
101
+ // line-height; the negative margins cancel that padding in layout so a
102
+ // collapsed paragraph still occupies exactly its line count.
103
+ //
104
+ // Everything is in `em` so it tracks the rendered font-size (including
105
+ // the mobile `--type-tester--adjustment`); the `+ 2px` is slack that
106
+ // keeps sub-pixel rounding from dropping the Nth line (the extra never
107
+ // reveals line N+1 because that line is clipped out to the side).
108
+ boxSizing: 'border-box',
109
+ maxHeight: `calc(${truncateLines * lineHeight + 2 * lineOverflowEm}em + 2px)`,
110
+ paddingTop: `${lineOverflowEm}em`,
111
+ paddingBottom: `${lineOverflowEm}em`,
112
+ paddingLeft: sideBearing,
113
+ paddingRight: sideBearing,
114
+ marginTop: `${-lineOverflowEm}em`,
115
+ marginBottom: `${-lineOverflowEm}em`,
116
+ overflow: 'hidden',
117
+ // Fill each column to its full N lines before spilling to the next, and
118
+ // allow a final column with a single line — otherwise the default
119
+ // `widows: 2` / `column-fill: balance` rebalance a 4-line paragraph into
120
+ // two columns of 2, showing N−1 lines instead of N.
121
+ columnFill: 'auto',
122
+ widows: 1,
123
+ orphans: 1,
124
+ ...(columnStyle ?? {
125
+ columnCount: 1
126
+ })
127
+ } : {
128
+ height: 'auto',
129
+ overflow: 'visible',
130
+ ...columnStyle
76
131
  }),
77
132
  fontFeatureSettings,
78
- marginRight: alignment !== 'right' ? -sideBuffer : 0,
79
- marginLeft: alignment === 'right' ? -sideBuffer : 0,
133
+ marginRight: `calc(${alignment !== 'right' ? -sideBuffer : 0}px - ${sideBearing})`,
134
+ marginLeft: `calc(${alignment === 'right' ? -sideBuffer : 0}px - ${sideBearing})`,
80
135
  letterSpacing: `${letterSpacing}em`,
81
136
  fontVariationSettings,
82
- textAlign: alignment,
83
- ...(columns && {
84
- columnCount: columns.count,
85
- columnWidth: columns.width,
86
- columnGap: columns.gap
87
- })
137
+ textAlign: alignment
88
138
  };
89
139
  return {
90
140
  ref,
@@ -75,6 +75,7 @@ import StripeLogo from '../StripeLogo.js';
75
75
  import { Check, X } from '../Icons/index.js';
76
76
  import ConfigContext from '../ConfigContext.js';
77
77
  import { Price } from '../Price/index.js';
78
+ import { sendOrderTracking } from '../Cart/orderTracking.js';
78
79
 
79
80
  /* const LICENSEE_REQUIRED_FIELDS = ['organization', 'country'] as (keyof Identity)[]; */
80
81
 
@@ -274,6 +275,13 @@ export default function StoreModalUnifiedCheckout(_ref2) {
274
275
  }));
275
276
  }, [setLicenseeIdentity]);
276
277
  const environment = useRelayEnvironment();
278
+
279
+ // Capture analytics consent + attribution on the order for the purchase
280
+ // event the server emits on completion (from a Stripe webhook, where no
281
+ // browser context exists).
282
+ useEffect(() => {
283
+ sendOrderTracking(environment);
284
+ }, [environment]);
277
285
  const [error, setError] = useState(null);
278
286
  const [errorsObject, setErrorsObject] = useState(null);
279
287
  const [customerErrorsObject, setCustomerErrorsObject] = useState(null);
@@ -3,8 +3,15 @@ import { useFontStyle_fontStyle$key } from '../__generated__/useFontStyle_fontSt
3
3
  interface UseFontStyle_props {
4
4
  fontStyle: useFontStyle_fontStyle$key | null | undefined;
5
5
  }
6
+ export type VerticalMetrics = {
7
+ readonly unitsPerEm: number;
8
+ readonly ascender: number;
9
+ readonly descender: number;
10
+ readonly lineGap: number | null;
11
+ };
6
12
  declare const useFontStyle: ({ fontStyle: fontStyleKey, }: UseFontStyle_props) => {
7
13
  style: React.CSSProperties;
8
14
  loaded: boolean;
15
+ verticalMetrics: VerticalMetrics | null;
9
16
  };
10
17
  export default useFontStyle;
@@ -45,7 +45,8 @@ const useFontStyle = _ref => {
45
45
  };
46
46
  return {
47
47
  style: cssStyle,
48
- loaded
48
+ loaded,
49
+ verticalMetrics: verticalMetrics ?? null
49
50
  };
50
51
  };
51
52
  export default useFontStyle;
@@ -25,11 +25,7 @@ interface NextConfigLike {
25
25
  }
26
26
  export declare function tenantRewrites(): Rewrite[];
27
27
  export declare function withFontdue<C extends NextConfigLike>(nextConfig?: C): C & {
28
- rewrites(): Promise<{
29
- beforeFiles: Rewrite[];
30
- afterFiles: Rewrite[];
31
- fallback: Rewrite[];
32
- }>;
28
+ rewrites(): Promise<RewriteGroups>;
33
29
  images: {
34
30
  remotePatterns: unknown[];
35
31
  dangerouslyAllowSVG: boolean;
@@ -9,9 +9,17 @@
9
9
  // This module is evaluated at config-load time, so it must not import React,
10
10
  // Relay, or anything else from the component tree.
11
11
 
12
- import { relative } from 'node:path';
12
+ import { existsSync } from 'node:fs';
13
+ import { join, relative } from 'node:path';
13
14
  import { fileURLToPath } from 'node:url';
14
15
 
16
+ // The tenant rewrites only make sense when the app routes live under the
17
+ // /[domain]/... tree. A single-tenant app with a flat tree (the ejected
18
+ // fontdue/example-next shape) routes normally and needs none of them.
19
+ function hasDomainTree() {
20
+ return ['src/app/[domain]', 'app/[domain]'].some(dir => existsSync(join(process.cwd(), dir)));
21
+ }
22
+
15
23
  // Minimal structural types so this module doesn't need `next` installed to
16
24
  // type-check; the shapes match next/dist/lib/load-custom-routes.
17
25
 
@@ -121,11 +129,16 @@ export function withFontdue() {
121
129
  if (!isMultiTenant && !fontdueUrl) {
122
130
  throw new Error('Set NEXT_PUBLIC_FONTDUE_URL (single-tenant) or FONTDUE_MULTI_TENANT=1 (multi-tenant).');
123
131
  }
132
+ const domainTree = hasDomainTree();
133
+ if (isMultiTenant && !domainTree) {
134
+ throw new Error('FONTDUE_MULTI_TENANT=1 requires the app routes to live under src/app/[domain]/ (one cache entry per domain).');
135
+ }
124
136
  const userImages = nextConfig.images ?? {};
125
137
  return {
126
138
  ...nextConfig,
127
139
  async rewrites() {
128
140
  const user = await resolveRewrites(nextConfig.rewrites);
141
+ if (!domainTree) return user;
129
142
  return {
130
143
  // App rules run first, against the public (pre-tenant) path; because
131
144
  // beforeFiles rules chain, a dotless path they produce is then
@@ -1,2 +1,2 @@
1
- export { isMultiTenant, isValidDomain, fontdueEndpoint, fallbackSiteUrl, fontdueServerConfig, configureFontdueRender, type FontdueEndpoint, } from './tenant.js';
1
+ export { isMultiTenant, isValidDomain, fontdueEndpoint, fontdueServerConfig, configureFontdueRender, prepareFontdueRender, currentFontdueEndpoint, generateStaticParams, type FontdueEndpoint, } from './tenant.js';
2
2
  export { setFontdueServerConfig, getFontdueServerConfig, type FontdueServerConfig, } from '../relay/serverConfig.js';
@@ -2,7 +2,7 @@
2
2
  // The config-time wrapper lives in 'fontdue-js/next/config' and the deploy
3
3
  // hook route handler in 'fontdue-js/next/revalidate'.
4
4
 
5
- export { isMultiTenant, isValidDomain, fontdueEndpoint, fallbackSiteUrl, fontdueServerConfig, configureFontdueRender } from './tenant.js';
5
+ export { isMultiTenant, isValidDomain, fontdueEndpoint, fontdueServerConfig, configureFontdueRender, prepareFontdueRender, currentFontdueEndpoint, generateStaticParams } from './tenant.js';
6
6
 
7
7
  // The per-render config store consumed by fontdue-js's own server-side
8
8
  // fetches. configureFontdueRender covers the common case; these are exported
@@ -2,6 +2,8 @@ import { type FontdueServerConfig } from '../relay/serverConfig.js';
2
2
  export declare const isMultiTenant: boolean;
3
3
  export declare function isValidDomain(domain: string): boolean;
4
4
  export interface FontdueEndpoint {
5
+ /** The site domain this endpoint resolves. */
6
+ domain: string;
5
7
  /** Base URL the app's own GraphQL fetches should target. */
6
8
  origin: string;
7
9
  /** Headers the Fontdue server needs to resolve the tenant. */
@@ -13,6 +15,12 @@ export interface FontdueEndpoint {
13
15
  tags: string[];
14
16
  }
15
17
  export declare function fontdueEndpoint(domain: string): FontdueEndpoint;
16
- export declare function fallbackSiteUrl(domain: string): string;
17
18
  export declare function fontdueServerConfig(domain: string): FontdueServerConfig;
18
19
  export declare function configureFontdueRender(domain: string): FontdueEndpoint | null;
20
+ interface RenderProps {
21
+ params: Promise<Record<string, string | string[] | undefined>>;
22
+ }
23
+ export declare function prepareFontdueRender(props: RenderProps): Promise<FontdueEndpoint>;
24
+ export declare function currentFontdueEndpoint(): FontdueEndpoint;
25
+ export declare function generateStaticParams(): Promise<never[]>;
26
+ export {};
@@ -24,7 +24,8 @@
24
24
  // /graphql on the page's own origin — so multi-tenant mode needs no
25
25
  // NEXT_PUBLIC_FONTDUE_URL at all.
26
26
 
27
- import { setFontdueServerConfig } from '../relay/serverConfig.js';
27
+ import { notFound } from 'next/navigation';
28
+ import { setFontdueServerConfig, getFontdueServerConfig } from '../relay/serverConfig.js';
28
29
  export const isMultiTenant = process.env.FONTDUE_MULTI_TENANT === '1';
29
30
  const singleTenantUrl = process.env.NEXT_PUBLIC_FONTDUE_URL;
30
31
  const internalOrigin = process.env.FONTDUE_ORIGIN;
@@ -41,17 +42,16 @@ export function isValidDomain(domain) {
41
42
  export function fontdueEndpoint(domain) {
42
43
  const tags = ['graphql', `graphql:${domain}`];
43
44
  if (!isMultiTenant) {
44
- if (!singleTenantUrl) {
45
- throw new Error('fontdue-js/next: set NEXT_PUBLIC_FONTDUE_URL (single-tenant) or FONTDUE_MULTI_TENANT=1 (multi-tenant).');
46
- }
47
45
  return {
48
- origin: singleTenantUrl,
46
+ domain,
47
+ origin: requireSingleTenantUrl(),
49
48
  headers: {},
50
49
  tags
51
50
  };
52
51
  }
53
52
  if (internalOrigin) {
54
53
  return {
54
+ domain,
55
55
  origin: internalOrigin,
56
56
  headers: {
57
57
  'x-forwarded-host': domain,
@@ -63,15 +63,17 @@ export function fontdueEndpoint(domain) {
63
63
  };
64
64
  }
65
65
  return {
66
+ domain,
66
67
  origin: `https://${domain}`,
67
68
  headers: {},
68
69
  tags
69
70
  };
70
71
  }
71
-
72
- // metadataBase / sitemap fallback when the site URL setting is empty.
73
- export function fallbackSiteUrl(domain) {
74
- return isMultiTenant ? `https://${domain}` : 'http://localhost:3000';
72
+ function requireSingleTenantUrl() {
73
+ if (!singleTenantUrl) {
74
+ throw new Error('fontdue-js/next: set NEXT_PUBLIC_FONTDUE_URL (single-tenant) or FONTDUE_MULTI_TENANT=1 (multi-tenant).');
75
+ }
76
+ return singleTenantUrl;
75
77
  }
76
78
 
77
79
  // Per-render config for fontdue-js's own server-side fetches: same endpoint
@@ -86,7 +88,8 @@ export function fontdueServerConfig(domain) {
86
88
  return {
87
89
  url: origin,
88
90
  headers,
89
- cacheTags: [`graphql:${domain}`]
91
+ cacheTags: [`graphql:${domain}`],
92
+ domain
90
93
  };
91
94
  }
92
95
 
@@ -102,4 +105,65 @@ export function configureFontdueRender(domain) {
102
105
  if (!isValidDomain(domain)) return null;
103
106
  setFontdueServerConfig(fontdueServerConfig(domain));
104
107
  return fontdueEndpoint(domain);
108
+ }
109
+
110
+ // The props every page, layout and generateMetadata receives. Any params
111
+ // object is structurally compatible; only `domain` is read.
112
+
113
+ // The one line at the top of every page, layout and generateMetadata body
114
+ // in the [domain] route tree:
115
+ //
116
+ // await prepareFontdueRender(props);
117
+ //
118
+ // Reads the request's site from the [domain] route param, 404s anything
119
+ // that isn't a plain hostname (stray paths can reach the catch-all route
120
+ // with their first segment as the "domain"), and points every Fontdue fetch
121
+ // in the rest of this render pass — the app's fetches via
122
+ // currentFontdueEndpoint and fontdue-js's embedded-component preloads — at
123
+ // that site.
124
+ //
125
+ // It must run per entry point, not only in the layout: a soft navigation
126
+ // re-renders just the page segment, and the per-render store starts empty
127
+ // on every pass. Forgetting it is loud, not subtle: currentFontdueEndpoint
128
+ // throws in multi-tenant mode when no render config was set.
129
+ //
130
+ // Route handlers are not React renders — the render-scoped store doesn't
131
+ // exist there, so use the returned endpoint explicitly instead of relying
132
+ // on currentFontdueEndpoint.
133
+ export async function prepareFontdueRender(props) {
134
+ const {
135
+ domain
136
+ } = await props.params;
137
+ const endpoint = typeof domain === 'string' ? configureFontdueRender(domain) : null;
138
+ if (!endpoint) notFound();
139
+ return endpoint;
140
+ }
141
+
142
+ // Endpoint for the app's own GraphQL fetches in this render pass: whatever
143
+ // prepareFontdueRender configured, or — in a single-tenant app without the
144
+ // [domain] tree, where prepareFontdueRender is never called — the
145
+ // NEXT_PUBLIC_FONTDUE_URL site. Throwing rather than guessing in
146
+ // multi-tenant mode turns a forgotten prepareFontdueRender into an
147
+ // unmissable error instead of a silent wrong-site fetch on soft
148
+ // navigations.
149
+ export function currentFontdueEndpoint() {
150
+ var _getFontdueServerConf;
151
+ const domain = (_getFontdueServerConf = getFontdueServerConfig()) === null || _getFontdueServerConf === void 0 ? void 0 : _getFontdueServerConf.domain;
152
+ if (domain) return fontdueEndpoint(domain);
153
+ if (isMultiTenant) {
154
+ throw new Error('fontdue-js/next: no render config set — call prepareFontdueRender(props) at the top of every page, layout and generateMetadata that fetches.');
155
+ }
156
+ return fontdueEndpoint(new URL(requireSingleTenantUrl()).host);
157
+ }
158
+
159
+ // Re-export from routes in the [domain] tree:
160
+ //
161
+ // export { generateStaticParams } from 'fontdue-js/next';
162
+ //
163
+ // Domains aren't known at build time, so nothing is prerendered — but
164
+ // providing generateStaticParams is what opts a dynamic route into
165
+ // static-on-demand rendering (generated on first request, then cached until
166
+ // revalidated) instead of fully dynamic rendering.
167
+ export async function generateStaticParams() {
168
+ return [];
105
169
  }
@@ -4,7 +4,7 @@ import { getFontdueServerConfig } from './serverConfig.js';
4
4
 
5
5
  // `__FONTDUE_JS_VERSION__` is replaced by an inline babel plugin
6
6
  // (defineVersionPlugin in .babelrc.cjs) with the literal package.json#version.
7
- const version = "3.0.0-alpha8";
7
+ const version = "3.0.0-alpha9";
8
8
  const IS_SERVER = typeof window === typeof undefined;
9
9
 
10
10
  // Read env from either process.env (Node/Next.js) or import.meta.env (Vite/Astro).
@@ -63,6 +63,16 @@ export function createNetworkFetch(options) {
63
63
  query: request.text,
64
64
  variables
65
65
  }),
66
+ // Server-side fetches are all site content (per-session data is
67
+ // fetched client-side only), so opt them into Next's data cache —
68
+ // without this, Next 15 treats them as no-store and silently makes
69
+ // every page fully dynamic. The tags below let the revalidate
70
+ // handler purge them when content changes. Inert outside Next:
71
+ // Node's fetch accepts and ignores this cache mode, and the
72
+ // browser path doesn't set it.
73
+ ...(IS_SERVER ? {
74
+ cache: 'force-cache'
75
+ } : {}),
66
76
  // @ts-ignore
67
77
  next: {
68
78
  tags: ['graphql', ...((serverConfig === null || serverConfig === void 0 ? void 0 : serverConfig.cacheTags) ?? []), `operation:${request.name}`]
@@ -5,6 +5,12 @@ export interface FontdueServerConfig {
5
5
  headers?: Record<string, string>;
6
6
  /** Extra Next.js fetch cache tags applied to every server-side GraphQL fetch. */
7
7
  cacheTags?: string[];
8
+ /**
9
+ * The site domain this render is for, when known. Set by
10
+ * fontdue-js/next's prepareFontdueRender so render-scoped helpers
11
+ * (currentFontdueEndpoint) can recover it.
12
+ */
13
+ domain?: string;
8
14
  }
9
15
  export declare function setFontdueServerConfig(config: FontdueServerConfig): void;
10
16
  export declare function getFontdueServerConfig(): FontdueServerConfig | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fontdue-js",
3
- "version": "3.0.0-alpha8",
3
+ "version": "3.0.0-alpha9",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "npm run relay && run-p build-js build-css build-ts",
@@ -0,0 +1,6 @@
1
+ // Minimal declaration so src/next/tenant.ts type-checks without `next`
2
+ // installed (it's the host app's dependency; this package only ever runs the
3
+ // import inside a Next.js server).
4
+ declare module 'next/navigation' {
5
+ export function notFound(): never;
6
+ }
package/vitest.config.ts CHANGED
@@ -2,6 +2,10 @@ import { defineConfig } from 'vitest/config';
2
2
  import path from 'path';
3
3
 
4
4
  export default defineConfig({
5
+ // Stand-in for the babel define plugin that inlines the package version.
6
+ define: {
7
+ __FONTDUE_JS_VERSION__: JSON.stringify('0.0.0-test'),
8
+ },
5
9
  test: {
6
10
  alias: {
7
11
  '__generated__': path.resolve(__dirname, 'src/__generated__'),