fontdue-js 3.0.0-alpha7 → 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 +9 -0
- package/README.md +84 -1
- package/dist/__generated__/orderTrackingUpdateOrderTrackingMutation.graphql.js +1 -8
- package/dist/__tests__/networkFetch.test.js +66 -0
- package/dist/__tests__/nextAdapter.test.js +447 -0
- package/dist/components/Cart/CartOrder.js +9 -1
- package/dist/components/Cart/orderTracking.js +8 -15
- package/dist/components/FontStyle/index.d.ts +2 -0
- package/dist/components/FontStyle/index.js +4 -2
- package/dist/components/TypeTester/index.js +3 -1
- package/dist/components/TypeTester/useTypeTesterStyler.d.ts +3 -1
- package/dist/components/TypeTester/useTypeTesterStyler.js +70 -20
- package/dist/components/elements/StoreModalUnifiedCheckout.js +8 -0
- package/dist/components/useFontStyle.d.ts +7 -0
- package/dist/components/useFontStyle.js +2 -1
- package/dist/next/config.d.ts +41 -0
- package/dist/next/config.js +193 -0
- package/dist/next/image-loader.d.ts +7 -0
- package/dist/next/image-loader.js +39 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +10 -0
- package/dist/next/revalidate.d.ts +1 -0
- package/dist/next/revalidate.js +37 -0
- package/dist/next/tenant.d.ts +26 -0
- package/dist/next/tenant.js +169 -0
- package/dist/relay/environment.js +11 -1
- package/dist/relay/serverConfig.d.ts +6 -0
- package/dist/vite.js +2 -0
- package/package.json +5 -2
- package/types/next-cache.d.ts +6 -0
- package/types/next-navigation.d.ts +6 -0
- package/vitest.config.ts +4 -0
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
27
|
-
mutation: (
|
|
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:
|
|
31
|
-
anonymousId:
|
|
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
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
interface RouteHas {
|
|
2
|
+
type: 'header' | 'query' | 'cookie' | 'host';
|
|
3
|
+
key?: string;
|
|
4
|
+
value?: string;
|
|
5
|
+
}
|
|
6
|
+
interface Rewrite {
|
|
7
|
+
source: string;
|
|
8
|
+
destination: string;
|
|
9
|
+
has?: RouteHas[];
|
|
10
|
+
missing?: RouteHas[];
|
|
11
|
+
}
|
|
12
|
+
interface RewriteGroups {
|
|
13
|
+
beforeFiles: Rewrite[];
|
|
14
|
+
afterFiles: Rewrite[];
|
|
15
|
+
fallback: Rewrite[];
|
|
16
|
+
}
|
|
17
|
+
type RewritesResult = Rewrite[] | Partial<RewriteGroups>;
|
|
18
|
+
interface NextConfigLike {
|
|
19
|
+
rewrites?: () => Promise<RewritesResult> | RewritesResult;
|
|
20
|
+
images?: {
|
|
21
|
+
remotePatterns?: unknown[];
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
};
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
export declare function tenantRewrites(): Rewrite[];
|
|
27
|
+
export declare function withFontdue<C extends NextConfigLike>(nextConfig?: C): C & {
|
|
28
|
+
rewrites(): Promise<RewriteGroups>;
|
|
29
|
+
images: {
|
|
30
|
+
remotePatterns: unknown[];
|
|
31
|
+
dangerouslyAllowSVG: boolean;
|
|
32
|
+
loader: "custom";
|
|
33
|
+
loaderFile: string;
|
|
34
|
+
} | {
|
|
35
|
+
remotePatterns: unknown[];
|
|
36
|
+
dangerouslyAllowSVG: boolean;
|
|
37
|
+
unoptimized: boolean;
|
|
38
|
+
};
|
|
39
|
+
htmlLimitedBots: RegExp;
|
|
40
|
+
};
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// withFontdue(nextConfig): next.config wrapper that installs everything a
|
|
2
|
+
// Fontdue storefront needs — host→path tenant rewrites, image settings, and
|
|
3
|
+
// workarounds for Next behaviors that would otherwise break Fontdue pages.
|
|
4
|
+
// Import it from next.config.mjs (this package is ESM):
|
|
5
|
+
//
|
|
6
|
+
// import { withFontdue } from 'fontdue-js/next/config';
|
|
7
|
+
// export default withFontdue({ /* your config */ });
|
|
8
|
+
//
|
|
9
|
+
// This module is evaluated at config-load time, so it must not import React,
|
|
10
|
+
// Relay, or anything else from the component tree.
|
|
11
|
+
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { join, relative } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
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
|
+
|
|
23
|
+
// Minimal structural types so this module doesn't need `next` installed to
|
|
24
|
+
// type-check; the shapes match next/dist/lib/load-custom-routes.
|
|
25
|
+
|
|
26
|
+
// Every page lives under the /[domain]/... route tree so Next renders and
|
|
27
|
+
// caches each tenant's pages independently. These rewrites turn the request's
|
|
28
|
+
// host into that leading path segment:
|
|
29
|
+
//
|
|
30
|
+
// acme.fontdue.com/fonts/foo → /acme.fontdue.com/fonts/foo (internal)
|
|
31
|
+
//
|
|
32
|
+
// In single-tenant mode the domain is constant (from NEXT_PUBLIC_FONTDUE_URL)
|
|
33
|
+
// so the app behaves exactly like a plain single-site Next app.
|
|
34
|
+
//
|
|
35
|
+
// This is done with config rewrites rather than middleware on purpose:
|
|
36
|
+
// middleware rewrites bypass the ISR page cache on self-hosted next start,
|
|
37
|
+
// turning every request into a full render. beforeFiles rewrites go through
|
|
38
|
+
// the normal routing layer and keep per-tenant ISR working. (beforeFiles also
|
|
39
|
+
// runs before app routes are matched, so the internal /[domain] paths can't
|
|
40
|
+
// be reached directly with a mismatching Host — /evil.com on acme's domain
|
|
41
|
+
// becomes /acme.fontdue.com/evil.com, which 404s.)
|
|
42
|
+
//
|
|
43
|
+
// X-Forwarded-Host (set by the Fontdue proxy in front of this service) wins
|
|
44
|
+
// over Host. The hostname charset is constrained in the patterns; anything
|
|
45
|
+
// else falls through to a 404.
|
|
46
|
+
//
|
|
47
|
+
// beforeFiles rules CHAIN: each rule is evaluated in order against the
|
|
48
|
+
// already-rewritten path, so a rewrite must produce a path no later rule can
|
|
49
|
+
// match. Tenant domains always contain a dot, so the catch-all path rule
|
|
50
|
+
// refuses any path whose first segment contains a dot — that makes the
|
|
51
|
+
// rewritten /acme.fontdue.com/... inert. robots.txt and sitemap.xml (dotted
|
|
52
|
+
// first segments we DO want to serve) get their own explicit rules, which run
|
|
53
|
+
// first. Side effect: a page slug containing a dot can't be routed at the
|
|
54
|
+
// top level.
|
|
55
|
+
const TENANT_HOST = '(?<tenant>[a-zA-Z0-9][a-zA-Z0-9.-]*)';
|
|
56
|
+
const DOTLESS_PATH = '/:path((?!api/|_next/|favicon\\.ico)(?![^/]*\\.[^/]*(?:/|$)).*)';
|
|
57
|
+
|
|
58
|
+
// One rule set rewriting onto `dest` (either a fixed /domain or the /:tenant
|
|
59
|
+
// capture from `has`). `conditions` is {has?, missing?}.
|
|
60
|
+
function rewriteRules(dest) {
|
|
61
|
+
let conditions = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
62
|
+
return [{
|
|
63
|
+
source: '/',
|
|
64
|
+
destination: dest,
|
|
65
|
+
...conditions
|
|
66
|
+
}, {
|
|
67
|
+
source: '/robots.txt',
|
|
68
|
+
destination: `${dest}/robots.txt`,
|
|
69
|
+
...conditions
|
|
70
|
+
}, {
|
|
71
|
+
source: '/sitemap.xml',
|
|
72
|
+
destination: `${dest}/sitemap.xml`,
|
|
73
|
+
...conditions
|
|
74
|
+
}, {
|
|
75
|
+
source: DOTLESS_PATH,
|
|
76
|
+
destination: `${dest}/:path`,
|
|
77
|
+
...conditions
|
|
78
|
+
}];
|
|
79
|
+
}
|
|
80
|
+
export function tenantRewrites() {
|
|
81
|
+
const fontdueUrl = process.env.NEXT_PUBLIC_FONTDUE_URL;
|
|
82
|
+
const isMultiTenant = process.env.FONTDUE_MULTI_TENANT === '1';
|
|
83
|
+
if (!isMultiTenant) {
|
|
84
|
+
return rewriteRules(`/${new URL(fontdueUrl).host}`);
|
|
85
|
+
}
|
|
86
|
+
const forwardedHost = {
|
|
87
|
+
type: 'header',
|
|
88
|
+
key: 'x-forwarded-host',
|
|
89
|
+
value: `${TENANT_HOST}(:.*)?`
|
|
90
|
+
};
|
|
91
|
+
const noForwardedHost = {
|
|
92
|
+
type: 'header',
|
|
93
|
+
key: 'x-forwarded-host'
|
|
94
|
+
};
|
|
95
|
+
const host = {
|
|
96
|
+
type: 'host',
|
|
97
|
+
value: TENANT_HOST
|
|
98
|
+
};
|
|
99
|
+
return [...rewriteRules('/:tenant', {
|
|
100
|
+
has: [forwardedHost]
|
|
101
|
+
}),
|
|
102
|
+
// Host-based rules only apply when X-Forwarded-Host is absent, so the
|
|
103
|
+
// two sets can't both rewrite one request.
|
|
104
|
+
...rewriteRules('/:tenant', {
|
|
105
|
+
has: [host],
|
|
106
|
+
missing: [noForwardedHost]
|
|
107
|
+
})];
|
|
108
|
+
}
|
|
109
|
+
async function resolveRewrites(rewrites) {
|
|
110
|
+
const result = (await (rewrites === null || rewrites === void 0 ? void 0 : rewrites())) ?? [];
|
|
111
|
+
// A plain array from rewrites() is afterFiles, per Next's contract.
|
|
112
|
+
if (Array.isArray(result)) {
|
|
113
|
+
return {
|
|
114
|
+
beforeFiles: [],
|
|
115
|
+
afterFiles: result,
|
|
116
|
+
fallback: []
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
beforeFiles: result.beforeFiles ?? [],
|
|
121
|
+
afterFiles: result.afterFiles ?? [],
|
|
122
|
+
fallback: result.fallback ?? []
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export function withFontdue() {
|
|
126
|
+
let nextConfig = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
127
|
+
const fontdueUrl = process.env.NEXT_PUBLIC_FONTDUE_URL;
|
|
128
|
+
const isMultiTenant = process.env.FONTDUE_MULTI_TENANT === '1';
|
|
129
|
+
if (!isMultiTenant && !fontdueUrl) {
|
|
130
|
+
throw new Error('Set NEXT_PUBLIC_FONTDUE_URL (single-tenant) or FONTDUE_MULTI_TENANT=1 (multi-tenant).');
|
|
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
|
+
}
|
|
136
|
+
const userImages = nextConfig.images ?? {};
|
|
137
|
+
return {
|
|
138
|
+
...nextConfig,
|
|
139
|
+
async rewrites() {
|
|
140
|
+
const user = await resolveRewrites(nextConfig.rewrites);
|
|
141
|
+
if (!domainTree) return user;
|
|
142
|
+
return {
|
|
143
|
+
// App rules run first, against the public (pre-tenant) path; because
|
|
144
|
+
// beforeFiles rules chain, a dotless path they produce is then
|
|
145
|
+
// prefixed by the tenant rules like any direct request. afterFiles
|
|
146
|
+
// and fallback rules run after the tenant prefix is applied, so
|
|
147
|
+
// their sources must match the internal /[domain]/... form.
|
|
148
|
+
beforeFiles: [...user.beforeFiles, ...tenantRewrites()],
|
|
149
|
+
afterFiles: user.afterFiles,
|
|
150
|
+
fallback: user.fallback
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
images: {
|
|
154
|
+
// With a Cloudflare image transformation host configured, optimization
|
|
155
|
+
// moves to its edge (image-loader.ts) and the in-process optimizer —
|
|
156
|
+
// and sharp — stay out of the deployment entirely. The host must be in
|
|
157
|
+
// the env at build time (the loader is inlined into the client bundle)
|
|
158
|
+
// as well as at serve time (this config runs again under next start).
|
|
159
|
+
//
|
|
160
|
+
// Without one, in multi-tenant dev the GraphQL origin is typically a
|
|
161
|
+
// container or tunnel from which tenant hostnames don't resolve
|
|
162
|
+
// publicly, so the optimizer can't fetch the originals — serve them
|
|
163
|
+
// unoptimized there.
|
|
164
|
+
...(process.env.NEXT_PUBLIC_FONTDUE_IMAGE_HOST ? {
|
|
165
|
+
loader: 'custom',
|
|
166
|
+
// Next resolves loaderFile against the project root and rejects
|
|
167
|
+
// absolute paths, so point into this package relative to cwd.
|
|
168
|
+
loaderFile: relative(process.cwd(), fileURLToPath(new URL('./image-loader.js', import.meta.url)))
|
|
169
|
+
} : {
|
|
170
|
+
unoptimized: isMultiTenant && process.env.NODE_ENV !== 'production'
|
|
171
|
+
}),
|
|
172
|
+
dangerouslyAllowSVG: true,
|
|
173
|
+
...userImages,
|
|
174
|
+
remotePatterns: [...(fontdueUrl ? [{
|
|
175
|
+
protocol: 'https',
|
|
176
|
+
hostname: new URL(fontdueUrl).hostname
|
|
177
|
+
}] : []), {
|
|
178
|
+
protocol: 'https',
|
|
179
|
+
hostname: '*.fontdue.com'
|
|
180
|
+
},
|
|
181
|
+
// Multi-tenant: logos and images can live on any tenant custom
|
|
182
|
+
// domain. The URLs all come from the Fontdue CMS, not user input.
|
|
183
|
+
...(isMultiTenant ? [{
|
|
184
|
+
protocol: 'https',
|
|
185
|
+
hostname: '**'
|
|
186
|
+
}] : []), ...(userImages.remotePatterns ?? [])]
|
|
187
|
+
},
|
|
188
|
+
// Treat every user agent as HTML-limited so metadata rendering blocks
|
|
189
|
+
// the response. Streamed metadata locks in a 200 status before
|
|
190
|
+
// generateMetadata's notFound() can produce a real 404.
|
|
191
|
+
htmlLimitedBots: nextConfig.htmlLimitedBots ?? /.*/
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Custom next/image loader that serves images through a Cloudflare image
|
|
2
|
+
// transformation host (developers.cloudflare.com/images/transform-images)
|
|
3
|
+
// instead of the in-process Next optimizer, so the deployment needs neither
|
|
4
|
+
// the /_next/image endpoint nor sharp. withFontdue activates it when
|
|
5
|
+
// NEXT_PUBLIC_FONTDUE_IMAGE_HOST is set; see config.ts.
|
|
6
|
+
//
|
|
7
|
+
// NEXT_PUBLIC_FONTDUE_IMAGE_ORIGINS (comma-separated hostnames) should
|
|
8
|
+
// mirror the transformation host's allowed-origins list. Sources on other
|
|
9
|
+
// hosts — e.g. the /logo endpoint a site serves from its own (possibly
|
|
10
|
+
// customer-owned) domain, which can't be allowlisted — are served as-is
|
|
11
|
+
// rather than as transform URLs Cloudflare would refuse (ERROR 9401).
|
|
12
|
+
//
|
|
13
|
+
// Next bundles this file into the client build and inlines the NEXT_PUBLIC_
|
|
14
|
+
// reads at build time, so it must stay dependency-free, and the variables
|
|
15
|
+
// have to be present when `next build` runs (not just at serve time).
|
|
16
|
+
|
|
17
|
+
function transformable(src) {
|
|
18
|
+
const origins = process.env.NEXT_PUBLIC_FONTDUE_IMAGE_ORIGINS;
|
|
19
|
+
if (!origins) return true;
|
|
20
|
+
let hostname;
|
|
21
|
+
try {
|
|
22
|
+
hostname = new URL(src).hostname;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return origins.split(',').some(origin => origin.trim() === hostname);
|
|
27
|
+
}
|
|
28
|
+
export default function fontdueImageLoader(_ref) {
|
|
29
|
+
let {
|
|
30
|
+
src,
|
|
31
|
+
width,
|
|
32
|
+
quality
|
|
33
|
+
} = _ref;
|
|
34
|
+
const host = process.env.NEXT_PUBLIC_FONTDUE_IMAGE_HOST;
|
|
35
|
+
// No transform host, a local asset it couldn't fetch, or a source outside
|
|
36
|
+
// its allowed origins: serve the original, like `unoptimized`.
|
|
37
|
+
if (!host || src.startsWith('/') || !transformable(src)) return src;
|
|
38
|
+
return `https://${host}/cdn-cgi/image/width=${width},quality=${quality ?? 75},format=auto/${src}`;
|
|
39
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { isMultiTenant, isValidDomain, fontdueEndpoint, fontdueServerConfig, configureFontdueRender, prepareFontdueRender, currentFontdueEndpoint, generateStaticParams, type FontdueEndpoint, } from './tenant.js';
|
|
2
|
+
export { setFontdueServerConfig, getFontdueServerConfig, type FontdueServerConfig, } from '../relay/serverConfig.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Server-side entrypoint for Next.js apps (the App Router / RSC adapter).
|
|
2
|
+
// The config-time wrapper lives in 'fontdue-js/next/config' and the deploy
|
|
3
|
+
// hook route handler in 'fontdue-js/next/revalidate'.
|
|
4
|
+
|
|
5
|
+
export { isMultiTenant, isValidDomain, fontdueEndpoint, fontdueServerConfig, configureFontdueRender, prepareFontdueRender, currentFontdueEndpoint, generateStaticParams } from './tenant.js';
|
|
6
|
+
|
|
7
|
+
// The per-render config store consumed by fontdue-js's own server-side
|
|
8
|
+
// fetches. configureFontdueRender covers the common case; these are exported
|
|
9
|
+
// for apps that need to set or inspect the config directly.
|
|
10
|
+
export { setFontdueServerConfig, getFontdueServerConfig } from '../relay/serverConfig.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function POST(request: Request): Promise<Response>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Route handler for Fontdue's deploy hook: re-export it from
|
|
2
|
+
// app/api/revalidate/route.ts:
|
|
3
|
+
//
|
|
4
|
+
// export { POST } from 'fontdue-js/next/revalidate';
|
|
5
|
+
//
|
|
6
|
+
// Fontdue calls it when a site's content changes. Multi-tenant deployments
|
|
7
|
+
// receive the tenant in the URL, e.g.
|
|
8
|
+
// POST /api/revalidate?domain=acme.fontdue.com
|
|
9
|
+
// and only that tenant's cache is purged (pages and embed data share the
|
|
10
|
+
// per-domain tag — see fontdueEndpoint/fontdueServerConfig in ./tenant).
|
|
11
|
+
// Single-tenant deployments use the parameterless form and purge everything
|
|
12
|
+
// carrying the 'graphql' tag.
|
|
13
|
+
|
|
14
|
+
import { revalidateTag } from 'next/cache';
|
|
15
|
+
import { isMultiTenant, isValidDomain } from './tenant.js';
|
|
16
|
+
export async function POST(request) {
|
|
17
|
+
var _URL$searchParams$get;
|
|
18
|
+
const domain = (_URL$searchParams$get = new URL(request.url).searchParams.get('domain')) === null || _URL$searchParams$get === void 0 ? void 0 : _URL$searchParams$get.toLowerCase();
|
|
19
|
+
if (isMultiTenant) {
|
|
20
|
+
if (!domain || !isValidDomain(domain)) {
|
|
21
|
+
return Response.json({
|
|
22
|
+
revalidated: false,
|
|
23
|
+
error: 'Missing or invalid ?domain='
|
|
24
|
+
}, {
|
|
25
|
+
status: 400
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
revalidateTag(`graphql:${domain}`);
|
|
29
|
+
} else {
|
|
30
|
+
revalidateTag('graphql');
|
|
31
|
+
}
|
|
32
|
+
return Response.json({
|
|
33
|
+
revalidated: true,
|
|
34
|
+
domain,
|
|
35
|
+
now: Date.now()
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type FontdueServerConfig } from '../relay/serverConfig.js';
|
|
2
|
+
export declare const isMultiTenant: boolean;
|
|
3
|
+
export declare function isValidDomain(domain: string): boolean;
|
|
4
|
+
export interface FontdueEndpoint {
|
|
5
|
+
/** The site domain this endpoint resolves. */
|
|
6
|
+
domain: string;
|
|
7
|
+
/** Base URL the app's own GraphQL fetches should target. */
|
|
8
|
+
origin: string;
|
|
9
|
+
/** Headers the Fontdue server needs to resolve the tenant. */
|
|
10
|
+
headers: Record<string, string>;
|
|
11
|
+
/**
|
|
12
|
+
* Cache tags for the app's own fetch calls (pass as `next: { tags }`), so
|
|
13
|
+
* the revalidate handler (see ./revalidate) can purge one site at a time.
|
|
14
|
+
*/
|
|
15
|
+
tags: string[];
|
|
16
|
+
}
|
|
17
|
+
export declare function fontdueEndpoint(domain: string): FontdueEndpoint;
|
|
18
|
+
export declare function fontdueServerConfig(domain: string): FontdueServerConfig;
|
|
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 {};
|