fontdue-js 3.0.5 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/__generated__/FeatureTesterCard_fontStyle.graphql.d.ts +23 -0
  3. package/dist/__generated__/FeatureTesterCard_fontStyle.graphql.js +62 -0
  4. package/dist/__generated__/FeatureTesterStandaloneQuery.graphql.d.ts +25 -0
  5. package/dist/__generated__/FeatureTesterStandaloneQuery.graphql.js +240 -0
  6. package/dist/__generated__/FeatureTestersIdQuery.graphql.d.ts +21 -0
  7. package/dist/__generated__/FeatureTestersIdQuery.graphql.js +344 -0
  8. package/dist/__generated__/FeatureTestersSlugQuery.graphql.d.ts +25 -0
  9. package/dist/__generated__/FeatureTestersSlugQuery.graphql.js +364 -0
  10. package/dist/__generated__/FeatureTesters_collection.graphql.d.ts +39 -0
  11. package/dist/__generated__/FeatureTesters_collection.graphql.js +145 -0
  12. package/dist/__generated__/NodePasswordFormAccessNodeMutation.graphql.d.ts +2 -1
  13. package/dist/__generated__/NodePasswordFormAccessNodeMutation.graphql.js +10 -4
  14. package/dist/__generated__/NodePasswordFormElementIDQuery.graphql.d.ts +21 -0
  15. package/dist/__generated__/NodePasswordFormElementIDQuery.graphql.js +86 -0
  16. package/dist/__generated__/NodePasswordFormElementSlugQuery.graphql.d.ts +25 -0
  17. package/dist/__generated__/NodePasswordFormElementSlugQuery.graphql.js +108 -0
  18. package/dist/__generated__/TypeTestersIDQuery.graphql.d.ts +1 -1
  19. package/dist/__generated__/TypeTestersIDQuery.graphql.js +9 -3
  20. package/dist/__generated__/TypeTestersRefetchQuery.graphql.d.ts +1 -1
  21. package/dist/__generated__/TypeTestersRefetchQuery.graphql.js +10 -4
  22. package/dist/__generated__/TypeTestersSlugQuery.graphql.d.ts +1 -1
  23. package/dist/__generated__/TypeTestersSlugQuery.graphql.js +9 -3
  24. package/dist/__generated__/TypeTesters_collection.graphql.d.ts +2 -1
  25. package/dist/__generated__/TypeTesters_collection.graphql.js +8 -2
  26. package/dist/__tests__/createFontdueFetch.test.js +128 -4
  27. package/dist/__tests__/highlight.test.js +141 -0
  28. package/dist/__tests__/middleware.test.js +132 -0
  29. package/dist/components/FeatureTester/FeatureTesterCard.d.ts +30 -0
  30. package/dist/components/FeatureTester/FeatureTesterCard.js +167 -0
  31. package/dist/components/FeatureTester/FeatureTesterElement.d.ts +19 -0
  32. package/dist/components/FeatureTester/FeatureTesterElement.js +42 -0
  33. package/dist/components/FeatureTester/FeatureTesters.d.ts +43 -0
  34. package/dist/components/FeatureTester/FeatureTesters.js +156 -0
  35. package/dist/components/FeatureTester/FeatureTesters.server.d.ts +9 -0
  36. package/dist/components/FeatureTester/FeatureTesters.server.js +40 -0
  37. package/dist/components/FeatureTester/FeatureTestersElement.d.ts +10 -0
  38. package/dist/components/FeatureTester/FeatureTestersElement.js +27 -0
  39. package/dist/components/FeatureTester/highlight.d.ts +19 -0
  40. package/dist/components/FeatureTester/highlight.js +71 -0
  41. package/dist/components/FeatureTester/index.d.ts +26 -0
  42. package/dist/components/FeatureTester/index.js +66 -0
  43. package/dist/components/FeatureTester/index.server.d.ts +7 -0
  44. package/dist/components/FeatureTester/index.server.js +30 -0
  45. package/dist/components/NodePasswordForm/NodePasswordFormElement.d.ts +3 -0
  46. package/dist/components/NodePasswordForm/NodePasswordFormElement.js +65 -0
  47. package/dist/components/NodePasswordForm/index.d.ts +16 -1
  48. package/dist/components/NodePasswordForm/index.js +30 -59
  49. package/dist/components/Root/index.js +2 -1
  50. package/dist/components/TypeTesters/index.js +5 -1
  51. package/dist/fontdue.css +145 -0
  52. package/dist/hooks/useFeatureTesterAutofit.d.ts +17 -0
  53. package/dist/hooks/useFeatureTesterAutofit.js +106 -0
  54. package/dist/next/index.d.ts +2 -0
  55. package/dist/next/index.js +16 -1
  56. package/dist/next/registerSingleTenantResolver.js +13 -1
  57. package/dist/next/tenant.d.ts +1 -0
  58. package/dist/next/tenant.js +27 -2
  59. package/dist/nodeAccess.d.ts +18 -0
  60. package/dist/nodeAccess.js +84 -0
  61. package/dist/preview/server.d.ts +1 -20
  62. package/dist/preview/server.js +16 -85
  63. package/dist/relay/environment.js +8 -2
  64. package/dist/relay/serverConfig.d.ts +3 -0
  65. package/dist/relay/serverConfig.js +22 -1
  66. package/dist/server/index.d.ts +8 -0
  67. package/dist/server/index.js +90 -7
  68. package/dist/server/middleware.d.ts +37 -0
  69. package/dist/server/middleware.js +144 -0
  70. package/package.json +8 -2
@@ -1,14 +1,31 @@
1
1
  'use client';
2
2
 
3
- function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
4
- import _NodePasswordFormSlugQuery from "../../__generated__/NodePasswordFormSlugQuery.graphql.js";
5
- import _NodePasswordFormIDQuery from "../../__generated__/NodePasswordFormIDQuery.graphql.js";
6
3
  import _NodePasswordFormAccessNodeMutation from "../../__generated__/NodePasswordFormAccessNodeMutation.graphql.js";
7
4
  import React, { useState } from 'react';
8
- import { graphql, useLazyLoadQuery, useMutation } from 'react-relay';
5
+ import { graphql, useMutation } from 'react-relay';
9
6
  import TextField from '../TextField/index.js';
10
- const accessNodeMutation = (_NodePasswordFormAccessNodeMutation.hash && _NodePasswordFormAccessNodeMutation.hash !== "d0beeb729c358ef4eebd1c8be47ec68e" && console.error("The definition of 'NodePasswordFormAccessNodeMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _NodePasswordFormAccessNodeMutation);
11
- function NodePasswordFormComponent(props) {
7
+ import { EnsureFontdueContext } from '../FontdueContextProvider/index.js';
8
+ import { rememberNodeAccessToken } from '../../nodeAccess.js';
9
+ const accessNodeMutation = (_NodePasswordFormAccessNodeMutation.hash && _NodePasswordFormAccessNodeMutation.hash !== "eb0d3828d65a52c727ef60caa67709a1" && console.error("The definition of 'NodePasswordFormAccessNodeMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _NodePasswordFormAccessNodeMutation);
10
+ /**
11
+ * Password form for a collection you already know is locked. Render it when a
12
+ * collection comes back password-protected (see the per-framework guides): it
13
+ * commits the `accessNode` mutation, remembers the returned access token, and
14
+ * reloads so the now-unlocked collection renders.
15
+ *
16
+ * Unlike the `<fontdue-node-password-form>` custom element (which detects the
17
+ * locked state itself), this does no data fetching of its own, so it renders
18
+ * immediately on the server with no Suspense flash — the server already detected
19
+ * the lock.
20
+ *
21
+ * Self-wraps in `EnsureFontdueContext` so it works as a standalone island (e.g.
22
+ * an Astro `client:load`) without its own provider, and no-ops inside an outer
23
+ * `<FontdueProvider>` (Next/RR7/TanStack).
24
+ */
25
+ export default function NodePasswordForm(props) {
26
+ return /*#__PURE__*/React.createElement(EnsureFontdueContext, null, /*#__PURE__*/React.createElement(NodePasswordFormFields, props));
27
+ }
28
+ function NodePasswordFormFields(props) {
12
29
  const [password, setPassword] = useState('');
13
30
  const [error, setError] = useState(null);
14
31
  const [commitMutation, submitting] = useMutation(accessNodeMutation);
@@ -27,7 +44,14 @@ function NodePasswordFormComponent(props) {
27
44
  onCompleted: res => {
28
45
  var _res$accessNode;
29
46
  if ((_res$accessNode = res.accessNode) !== null && _res$accessNode !== void 0 && _res$accessNode.success) {
47
+ // Persist the token so the next server render unlocks the collection:
48
+ // the Fontdue session cookie that does this for script-tag sites can't
49
+ // ride a proxied or cross-origin server fetch, so we forward this
50
+ // token as a header instead (see fontdue-js nodeAccess).
51
+ rememberNodeAccessToken(res.accessNode.token);
30
52
  location.reload();
53
+ } else {
54
+ setError('Incorrect password');
31
55
  }
32
56
  },
33
57
  onError: error => {
@@ -55,57 +79,4 @@ function NodePasswordFormComponent(props) {
55
79
  disabled: disabled,
56
80
  className: "submit-button"
57
81
  }, "Submit")));
58
- }
59
- const idQuery = (_NodePasswordFormIDQuery.hash && _NodePasswordFormIDQuery.hash !== "2af86f8f6df3d2e6061ea1c9dd777a26" && console.error("The definition of 'NodePasswordFormIDQuery' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _NodePasswordFormIDQuery);
60
- function NodePasswordFormIDQueryRenderer(_ref) {
61
- let {
62
- collectionId,
63
- ...rest
64
- } = _ref;
65
- const data = useLazyLoadQuery(idQuery, {
66
- collectionId
67
- });
68
- // this is counterintuitive, but we've added the @catch directive on the collection,
69
- // and we're expecting it to respond with an error if the node is inaccessible due to being
70
- // password-protected. so, when we encounter the error we're safe to render the password form.
71
- // (we don't want to show the form if the user has already gained access)
72
- if (data.collection.ok === false) return /*#__PURE__*/React.createElement(NodePasswordFormComponent, _extends({}, rest, {
73
- collectionId: collectionId
74
- }));
75
- }
76
- const slugQuery = (_NodePasswordFormSlugQuery.hash && _NodePasswordFormSlugQuery.hash !== "c40faf067007027fa9ba6673350c7202" && console.error("The definition of 'NodePasswordFormSlugQuery' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _NodePasswordFormSlugQuery);
77
- function NodePasswordFormSlugQueryRenderer(_ref2) {
78
- var _data$viewer$slug;
79
- let {
80
- collectionSlug,
81
- ...rest
82
- } = _ref2;
83
- const data = useLazyLoadQuery(slugQuery, {
84
- collectionSlug
85
- });
86
- // see note in NodePasswordFormIDQueryRenderer
87
- if (((_data$viewer$slug = data.viewer.slug) === null || _data$viewer$slug === void 0 ? void 0 : _data$viewer$slug.collection.ok) === false) return /*#__PURE__*/React.createElement(NodePasswordFormComponent, _extends({}, rest, {
88
- collectionSlug: collectionSlug
89
- }));
90
- return null;
91
- }
92
-
93
- // default client export
94
- export default function NodePasswordForm(_ref3) {
95
- let {
96
- collectionId,
97
- collectionSlug,
98
- ...rest
99
- } = _ref3;
100
- if (collectionId) {
101
- return /*#__PURE__*/React.createElement(NodePasswordFormIDQueryRenderer, _extends({
102
- collectionId: collectionId
103
- }, rest));
104
- }
105
- if (collectionSlug) {
106
- return /*#__PURE__*/React.createElement(NodePasswordFormSlugQueryRenderer, _extends({
107
- collectionSlug: collectionSlug
108
- }, rest));
109
- }
110
- throw new Error('NodePasswordForm expected either a collectionId or collectionSlug prop');
111
82
  }
@@ -16,6 +16,7 @@ const customElementMap = {
16
16
  'fontdue-cart': /*#__PURE__*/lazy(() => retryImport(() => import('../Cart/index.js'))),
17
17
  'fontdue-cart-button': /*#__PURE__*/lazy(() => retryImport(() => import('../CartButton/index.js'))),
18
18
  'fontdue-character-viewer': /*#__PURE__*/lazy(() => retryImport(() => import('../CharacterViewer/index.js'))),
19
+ 'fontdue-feature-testers': /*#__PURE__*/lazy(() => retryImport(() => import('../FeatureTester/FeatureTestersElement.js'))),
19
20
  // @ts-ignore
20
21
  'fontdue-collection-aa': /*#__PURE__*/lazy(() => retryImport(() => import('../CollectionAa/index.js'))),
21
22
  // @ts-ignore
@@ -23,7 +24,7 @@ const customElementMap = {
23
24
  'fontdue-font-families': /*#__PURE__*/lazy(() => retryImport(() => import('../FontFamilies/index.js'))),
24
25
  'fontdue-customer-login-form': /*#__PURE__*/lazy(() => retryImport(() => import('../CustomerLoginForm/index.js'))),
25
26
  'fontdue-newsletter-signup': /*#__PURE__*/lazy(() => retryImport(() => import('../NewsletterSignup/NewsletterSignupElement.js'))),
26
- 'fontdue-node-password-form': /*#__PURE__*/lazy(() => retryImport(() => import('../NodePasswordForm/index.js'))),
27
+ 'fontdue-node-password-form': /*#__PURE__*/lazy(() => retryImport(() => import('../NodePasswordForm/NodePasswordFormElement.js'))),
27
28
  // @ts-ignore
28
29
  'fontdue-precart': /*#__PURE__*/lazy(() => retryImport(() => import('../Precart/index.js'))),
29
30
  // @ts-ignore
@@ -75,7 +75,7 @@ function TypeTestersComponent(_ref) {
75
75
  licenseOptionsSpecs,
76
76
  orderVariableSelections
77
77
  } = useLicenseAndOrderVariables();
78
- const [collection, refetch] = useRefetchableFragment((_TypeTesters_collection.hash && _TypeTesters_collection.hash !== "0f8422550dd500368594bfedc5f58bf4" && console.error("The definition of 'TypeTesters_collection' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _TypeTesters_collection), collectionKey);
78
+ const [collection, refetch] = useRefetchableFragment((_TypeTesters_collection.hash && _TypeTesters_collection.hash !== "b247a9b03142716823d36ec52dd965ff" && console.error("The definition of 'TypeTesters_collection' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _TypeTesters_collection), collectionKey);
79
79
  const handleStyleSelect = useCallback((testerId, fontStyleId) => {
80
80
  const newChangedStyles = {
81
81
  ...changedStyles,
@@ -119,6 +119,10 @@ function TypeTestersComponent(_ref) {
119
119
  const direction = node.direction === 'rtl' ? 'rtl' : 'ltr';
120
120
  let alignment = config.initialAlignment;
121
121
  if (direction === 'rtl') alignment = 'right';
122
+ // An explicit per-card alignment from the dashboard wins over both.
123
+ if (node.alignment === 'left' || node.alignment === 'center' || node.alignment === 'right') {
124
+ alignment = node.alignment;
125
+ }
122
126
  acc[node.id] = {
123
127
  content: node.content,
124
128
  size: node.size ? parseFloat(node.size) : 72,
package/dist/fontdue.css CHANGED
@@ -807,6 +807,151 @@ fontdue-type-tester {
807
807
  font-weight: inherit;
808
808
  }
809
809
 
810
+ fontdue-feature-tester,
811
+ fontdue-feature-testers {
812
+ display: block;
813
+ max-width: 100%;
814
+ min-width: 0;
815
+ }
816
+
817
+ .fontdue-feature-tester {
818
+ --_ft-text: var(--fontdue-feature-tester-text, var(--primary_text_color, currentColor));
819
+ --_ft-muted: var(
820
+ --fontdue-feature-tester-muted,
821
+ color-mix(in srgb, var(--_ft-text) 50%, transparent)
822
+ );
823
+ --_ft-highlight: var(--fontdue-feature-tester-highlight, var(--_ft-text));
824
+ --_ft-bg: var(--fontdue-feature-tester-bg, transparent);
825
+ --_ft-border: var(
826
+ --fontdue-feature-tester-border,
827
+ 1px solid var(--horizontal_rule_color, color-mix(in srgb, currentColor 15%, transparent))
828
+ );
829
+ --_ft-radius: var(--fontdue-feature-tester-radius, 0);
830
+ --_ft-padding: var(--fontdue-feature-tester-padding, 18px 0 30px);
831
+ --_ft-align: var(--fontdue-feature-tester-align, left);
832
+ font-size: 1rem;
833
+ color: var(--_ft-text);
834
+ background: var(--_ft-bg);
835
+ border-top: var(--_ft-border);
836
+ border-radius: var(--_ft-radius);
837
+ padding: var(--_ft-padding);
838
+ min-width: 0;
839
+ max-width: 100%;
840
+ }
841
+ .fontdue-feature-tester__bar {
842
+ display: flex;
843
+ align-items: center;
844
+ gap: 14px;
845
+ margin-bottom: 16px;
846
+ }
847
+ .fontdue-feature-tester__toggle {
848
+ background: none;
849
+ color: inherit;
850
+ font: inherit;
851
+ text-decoration: inherit;
852
+ text-align: inherit;
853
+ border: 0;
854
+ border-radius: 0;
855
+ padding: 0;
856
+ margin: 0;
857
+ appearance: none;
858
+ text-transform: inherit;
859
+ }
860
+ .fontdue-feature-tester__toggle:active, .fontdue-feature-tester__toggle:focus {
861
+ outline: none;
862
+ }
863
+ .fontdue-feature-tester__toggle:not(:disabled) {
864
+ cursor: pointer;
865
+ }
866
+ .fontdue-feature-tester__toggle {
867
+ display: inline-flex;
868
+ align-items: center;
869
+ flex: none;
870
+ color: inherit;
871
+ font: inherit;
872
+ }
873
+ .fontdue-feature-tester__toggle:focus-visible .fontdue-feature-tester__switch {
874
+ outline: 1px solid currentcolor;
875
+ outline-offset: 2px;
876
+ }
877
+ .fontdue-feature-tester__switch {
878
+ --_sw-w: var(--fontdue-feature-tester-switch-width, 40px);
879
+ --_sw-h: var(--fontdue-feature-tester-switch-height, 20px);
880
+ --_sw-r: var(--fontdue-feature-tester-switch-radius, 0);
881
+ --_sw-p: var(--fontdue-feature-tester-switch-padding, 2px);
882
+ --_sw-b: var(--fontdue-feature-tester-switch-border-width, 1px);
883
+ --_sw-color: var(--fontdue-feature-tester-switch-color, currentColor);
884
+ --_sw-knob: var(--fontdue-feature-tester-switch-knob, var(--primary_background_color, canvas));
885
+ --_sw-track: var(--fontdue-feature-tester-switch-track, transparent);
886
+ --_sw-travel: calc(var(--_sw-w) - var(--_sw-h));
887
+ position: relative;
888
+ box-sizing: border-box;
889
+ width: var(--_sw-w);
890
+ height: var(--_sw-h);
891
+ flex: none;
892
+ border: var(--_sw-b) solid var(--_sw-color);
893
+ border-radius: var(--_sw-r);
894
+ background: var(--_sw-track);
895
+ transition: background 120ms ease-out;
896
+ }
897
+ .fontdue-feature-tester__switch::after {
898
+ content: "";
899
+ position: absolute;
900
+ box-sizing: border-box;
901
+ top: var(--_sw-p);
902
+ left: var(--_sw-p);
903
+ bottom: var(--_sw-p);
904
+ aspect-ratio: 1;
905
+ border: var(--_sw-b) solid var(--_sw-color);
906
+ border-radius: var(--_sw-r);
907
+ background: transparent;
908
+ transition: transform 120ms ease-out, background 120ms ease-out;
909
+ }
910
+ @media (hover: hover) {
911
+ .fontdue-feature-tester[data-on=false] .fontdue-feature-tester__toggle:hover .fontdue-feature-tester__switch::after {
912
+ background: var(--_sw-color);
913
+ }
914
+ }
915
+ .fontdue-feature-tester__label {
916
+ white-space: nowrap;
917
+ }
918
+ .fontdue-feature-tester[data-toggle=button] .fontdue-feature-tester__label {
919
+ cursor: pointer;
920
+ }
921
+ .fontdue-feature-tester__tag {
922
+ margin-left: auto;
923
+ color: inherit;
924
+ }
925
+ .fontdue-feature-tester[data-on=true] .fontdue-feature-tester__switch {
926
+ background: var(--_sw-color);
927
+ }
928
+ .fontdue-feature-tester[data-on=true] .fontdue-feature-tester__switch::after {
929
+ transform: translateX(var(--_sw-travel));
930
+ border-color: transparent;
931
+ background: var(--_sw-knob);
932
+ }
933
+ .fontdue-feature-tester[data-toggle=hover] .fontdue-feature-tester__switch,
934
+ .fontdue-feature-tester[data-toggle=hover] .fontdue-feature-tester__toggle {
935
+ display: none;
936
+ }
937
+ @media (hover: none) {
938
+ .fontdue-feature-tester[data-toggle=hover] .fontdue-feature-tester__switch,
939
+ .fontdue-feature-tester[data-toggle=hover] .fontdue-feature-tester__toggle {
940
+ display: inline-flex;
941
+ }
942
+ }
943
+ .fontdue-feature-tester__sample {
944
+ white-space: pre-wrap;
945
+ text-align: var(--_ft-align);
946
+ color: var(--_ft-text);
947
+ }
948
+ .fontdue-feature-tester[data-highlighted=true] .fontdue-feature-tester__sample {
949
+ color: var(--_ft-muted);
950
+ }
951
+ .fontdue-feature-tester__hit {
952
+ color: var(--_ft-highlight);
953
+ }
954
+
810
955
  .select-button {
811
956
  background: none;
812
957
  color: inherit;
@@ -0,0 +1,17 @@
1
+ export interface UseFeatureTesterAutofitProps {
2
+ enabled: boolean;
3
+ text: string;
4
+ fontFamily: string | null;
5
+ feature: string;
6
+ fontSize: number;
7
+ min?: number;
8
+ max?: number;
9
+ letterSpacing?: number;
10
+ fontVariationSettings?: string;
11
+ padding?: number;
12
+ }
13
+ export interface UseFeatureTesterAutofitResult {
14
+ ref: React.RefObject<HTMLDivElement | null>;
15
+ fontSize: number;
16
+ }
17
+ export default function useFeatureTesterAutofit({ enabled, text, fontFamily, feature, fontSize, min, max, letterSpacing, fontVariationSettings, padding, }: UseFeatureTesterAutofitProps): UseFeatureTesterAutofitResult;
@@ -0,0 +1,106 @@
1
+ import { useRef, useState, useLayoutEffect } from 'react';
2
+ import useResizeObserver from './useResizeObserver.js';
3
+
4
+ // Autofit for the Feature Tester. Unlike the plain type-tester autofit, the
5
+ // rendered width depends on the toggle state — an alternate glyph is wider or
6
+ // narrower, a ligature collapses, a fraction shrinks. So we measure the sample
7
+ // in BOTH states (feature off and on) and size to fit the WIDER one. The size
8
+ // is then fixed across toggles, so the tester never reflows or overflows when
9
+ // the feature is switched.
10
+ //
11
+ // Measurement must use the DOM (not canvas): canvas `measureText` can't apply
12
+ // `font-feature-settings`, which is exactly the thing that changes the width.
13
+ // Measuring the whole string with the feature applied is exact here, not an
14
+ // over-estimate: a character the feature doesn't touch renders identically
15
+ // whether the feature is on or off, so the two measurements are precisely the
16
+ // tester's two real states.
17
+
18
+ const REF_SIZE = 100;
19
+ let measureEl = null;
20
+ function getMeasureElement() {
21
+ if (!measureEl) {
22
+ measureEl = document.createElement('span');
23
+ measureEl.style.position = 'absolute';
24
+ measureEl.style.top = '-9999px';
25
+ measureEl.style.left = '-9999px';
26
+ measureEl.style.whiteSpace = 'pre';
27
+ measureEl.style.visibility = 'hidden';
28
+ document.body.appendChild(measureEl);
29
+ }
30
+ return measureEl;
31
+ }
32
+ function measure(text, fontFamily, letterSpacing, fontFeatureSettings, fontVariationSettings) {
33
+ const el = getMeasureElement();
34
+ el.style.fontFamily = fontFamily;
35
+ el.style.fontSize = `${REF_SIZE}px`;
36
+ el.style.letterSpacing = `${letterSpacing}em`;
37
+ el.style.fontFeatureSettings = fontFeatureSettings;
38
+ el.style.fontVariationSettings = fontVariationSettings;
39
+ el.textContent = text;
40
+ return el.getBoundingClientRect().width;
41
+ }
42
+ export default function useFeatureTesterAutofit(_ref) {
43
+ let {
44
+ enabled,
45
+ text,
46
+ fontFamily,
47
+ feature,
48
+ fontSize,
49
+ min = 8,
50
+ max = 999,
51
+ letterSpacing = 0,
52
+ fontVariationSettings = '',
53
+ padding = 0
54
+ } = _ref;
55
+ const ref = useRef(null);
56
+ const [size, setSize] = useState(fontSize);
57
+ const [containerWidth, setContainerWidth] = useState(0);
58
+ const [fontVersion, setFontVersion] = useState(0);
59
+ useResizeObserver(ref, entry => setContainerWidth(entry.contentRect.width));
60
+
61
+ // Re-measure once the webfont is available (measuring the fallback would size
62
+ // to the wrong metrics). The font is often already cached — other cards on the
63
+ // page load the same family — so `document.fonts.load` can resolve in a
64
+ // microtask *before* a freshly-measured element reflects the loaded font in
65
+ // layout. Re-measuring on the next animation frame, when layout is font-ready,
66
+ // avoids sizing to a stale fallback measurement that then never gets corrected.
67
+ // Re-measure until the sizing lands on the real webfont metrics. A fresh DOM
68
+ // measurement can lag the font becoming available — the family is often
69
+ // already cached (other cards on the page load it), so `fonts.load`/
70
+ // `fonts.ready` can settle a frame or two before a measured element actually
71
+ // reflects it. Sizing to that stale fallback measurement would then stick.
72
+ // Re-measuring across a short schedule lets the size settle onto the real
73
+ // metrics whenever they land; the extra `fonts.ready` bump covers slow loads
74
+ // beyond the schedule.
75
+ useLayoutEffect(() => {
76
+ if (!enabled || !fontFamily) return;
77
+ let cancelled = false;
78
+ const bump = () => {
79
+ if (!cancelled) setFontVersion(v => v + 1);
80
+ };
81
+ const timers = [0, 80, 200, 500, 1000].map(ms => window.setTimeout(bump, ms));
82
+ document.fonts.ready.then(bump);
83
+ return () => {
84
+ cancelled = true;
85
+ timers.forEach(clearTimeout);
86
+ };
87
+ }, [enabled, fontFamily]);
88
+ useLayoutEffect(() => {
89
+ if (!enabled || !fontFamily) return;
90
+ const available = containerWidth - padding;
91
+ if (available <= 0 || !text) return;
92
+ const off = `"${feature}" 0`;
93
+ const on = `"${feature}" 1`;
94
+ // Widest line across both states — samples can carry authored newlines.
95
+ const refWidth = Math.max(...text.split('\n').flatMap(line => [measure(line, fontFamily, letterSpacing, off, fontVariationSettings), measure(line, fontFamily, letterSpacing, on, fontVariationSettings)]));
96
+ if (refWidth <= 0) return;
97
+
98
+ // Width scales linearly with font-size; floor to avoid a sub-pixel wrap.
99
+ const next = Math.min(Math.max(min, Math.floor(available / (refWidth / REF_SIZE))), max);
100
+ setSize(next);
101
+ }, [enabled, text, fontFamily, feature, letterSpacing, fontVariationSettings, containerWidth, min, max, padding, fontVersion]);
102
+ return {
103
+ ref,
104
+ fontSize: enabled ? size : fontSize
105
+ };
106
+ }
@@ -1 +1,3 @@
1
1
  export { __prepareFontdueRender, type FontdueEndpoint } from './tenant.js';
2
+ import { nodeAccessHeaders } from './tenant.js';
3
+ export { nodeAccessHeaders };
@@ -15,4 +15,19 @@
15
15
  // configureFontdueRender, setFontdueServerConfig) are deliberately not
16
16
  // re-exported here; the modules that need them import them directly from
17
17
  // './tenant.js' / '../relay/serverConfig.js'.
18
- export { __prepareFontdueRender } from './tenant.js';
18
+ export { __prepareFontdueRender } from './tenant.js';
19
+ import { registerNodeAccessRecovery } from '../relay/serverConfig.js';
20
+ import { nodeAccessHeaders } from './tenant.js';
21
+
22
+ // Wire up automatic node-access recovery: when a server fetch comes back
23
+ // `password_protected`, createFontdueFetch calls this to read the visitor's
24
+ // node-access cookie and (if they've unlocked the collection) fold the token
25
+ // into the per-render config + retry — so the page fetch and the embedded server
26
+ // components' preloads resolve, with no per-fetch plumbing in the app. Importing
27
+ // this entry (the next-template does, via __prepareFontdueRender) registers it.
28
+ registerNodeAccessRecovery(() => nodeAccessHeaders());
29
+
30
+ // Escape hatch (rarely needed now recovery is automatic): the visitor's
31
+ // per-collection node-access header for this request, or {} when none. See the
32
+ // password-protected-collections guide.
33
+ export { nodeAccessHeaders };
@@ -15,7 +15,19 @@
15
15
  // stays safe for any non-Next RSC consumer. (Today only Next resolves the
16
16
  // react-server export condition, but the decoupling makes that a structural
17
17
  // invariant rather than an incidental one.)
18
- import { registerAmbientConfigResolver } from '../relay/serverConfig.js';
18
+ import { registerAmbientConfigResolver, registerNodeAccessRecovery } from '../relay/serverConfig.js';
19
+
20
+ // Automatic node-access recovery for single-tenant Next apps (the same hook the
21
+ // multi-tenant entry registers). createFontdueFetch calls this when a fetch
22
+ // comes back `password_protected`; it reads the visitor's node-access cookie via
23
+ // Next's request APIs. Lazy-imported, for the same reason as the resolver below:
24
+ // keep next/headers off this module's static graph.
25
+ registerNodeAccessRecovery(async () => {
26
+ const {
27
+ nodeAccessHeaders
28
+ } = await import('./tenant.js');
29
+ return nodeAccessHeaders();
30
+ });
19
31
  registerAmbientConfigResolver(async () => {
20
32
  // Loaded lazily so next/headers + next/navigation never enter the
21
33
  // provider's static graph; config is read per fetch from Next's request
@@ -23,4 +23,5 @@ interface RenderProps {
23
23
  }
24
24
  export declare function buildRenderConfig(domain: string): Promise<FontdueServerConfig>;
25
25
  export declare function __prepareFontdueRender(props: RenderProps): Promise<FontdueEndpoint>;
26
+ export declare function nodeAccessHeaders(): Promise<Record<string, string>>;
26
27
  export {};
@@ -29,6 +29,7 @@ import { notFound, unstable_rethrow } from 'next/navigation';
29
29
  import { cookies, draftMode } from 'next/headers';
30
30
  import { setFontdueServerConfig } from '../relay/serverConfig.js';
31
31
  import { PREVIEW_TOKEN_COOKIE, previewAuthHeaders } from '../preview/index.js';
32
+ import { NODE_ACCESS_COOKIE, NODE_ACCESS_HEADER } from '../nodeAccess.js';
32
33
  export const isMultiTenant = process.env.FONTDUE_MULTI_TENANT === '1';
33
34
  export const singleTenantUrl = process.env.NEXT_PUBLIC_FONTDUE_URL;
34
35
  const internalOrigin = process.env.FONTDUE_ORIGIN;
@@ -155,6 +156,30 @@ export async function __prepareFontdueRender(props) {
155
156
  return endpointForDomain(domain);
156
157
  }
157
158
 
159
+ // The visitor's per-collection node-access header for the current request, or {}
160
+ // when they haven't unlocked anything. Reads the first-party cookie the password
161
+ // form set and returns it as `{ 'fontdue-node-access': <tokens> }`, ready to
162
+ // spread into a GraphQL fetch's headers so the unlocked collection resolves.
163
+ //
164
+ // Reading cookies() opts the route into dynamic rendering, so call this only
165
+ // when it's actually needed — after a public fetch comes back
166
+ // password-protected — to keep non-protected font pages static/cacheable. The
167
+ // catch mirrors readPreviewHeaders: re-throw Next's control-flow bailout so a
168
+ // protected page renders dynamically instead of being prerendered cookie-blind
169
+ // (which would serve the locked form to a visitor who has unlocked it).
170
+ export async function nodeAccessHeaders() {
171
+ try {
172
+ var _await$cookies$get;
173
+ const value = (_await$cookies$get = (await cookies()).get(NODE_ACCESS_COOKIE)) === null || _await$cookies$get === void 0 ? void 0 : _await$cookies$get.value;
174
+ return value ? {
175
+ [NODE_ACCESS_HEADER]: value
176
+ } : {};
177
+ } catch (error) {
178
+ unstable_rethrow(error);
179
+ return {};
180
+ }
181
+ }
182
+
158
183
  // The admin preview token for this render, as Authorization headers, or
159
184
  // undefined when not previewing. Draft mode gates it: reading draftMode().
160
185
  // isEnabled is static-safe (it returns false during static generation and only
@@ -182,9 +207,9 @@ export async function __prepareFontdueRender(props) {
182
207
  // so unstable_rethrow is a no-op and we fall through to "not previewing".
183
208
  async function readPreviewHeaders() {
184
209
  try {
185
- var _await$cookies$get;
210
+ var _await$cookies$get2;
186
211
  if (!(await draftMode()).isEnabled) return undefined;
187
- const token = (_await$cookies$get = (await cookies()).get(PREVIEW_TOKEN_COOKIE)) === null || _await$cookies$get === void 0 ? void 0 : _await$cookies$get.value;
212
+ const token = (_await$cookies$get2 = (await cookies()).get(PREVIEW_TOKEN_COOKIE)) === null || _await$cookies$get2 === void 0 ? void 0 : _await$cookies$get2.value;
188
213
  return token ? previewAuthHeaders(token) : undefined;
189
214
  } catch (error) {
190
215
  unstable_rethrow(error);
@@ -0,0 +1,18 @@
1
+ export declare const NODE_ACCESS_COOKIE = "fontdue_node_access";
2
+ export declare const NODE_ACCESS_HEADER = "fontdue-node-access";
3
+ /** The node-access cookie value from a Cookie header string, or undefined. */
4
+ export declare function readNodeAccessCookie(cookieHeader: string | null | undefined): string | undefined;
5
+ /**
6
+ * The node-access forwarding header for a Cookie header string, or {} when the
7
+ * cookie is absent. Spread into a server-side GraphQL fetch's headers so a
8
+ * collection the visitor unlocked resolves for them. Returns {} for public
9
+ * (no-cookie) requests, leaving them untouched and cacheable.
10
+ */
11
+ export declare function nodeAccessHeadersFromCookie(cookieHeader: string | null | undefined): Record<string, string>;
12
+ /**
13
+ * Remember a freshly minted access token in the browser's node-access cookie,
14
+ * preserving any collections already unlocked. First-party and JS-readable on
15
+ * purpose (see the module note); SameSite=Lax so it rides top-level navigations,
16
+ * Secure only on https so it still persists in local dev. No-op off the browser.
17
+ */
18
+ export declare function rememberNodeAccessToken(token: string | null | undefined): void;
@@ -0,0 +1,84 @@
1
+ // Per-collection node-access contract.
2
+ //
3
+ // When a visitor unlocks a password-protected collection (the `accessNode`
4
+ // mutation), the server returns a signed token. The browser remembers it in a
5
+ // first-party cookie, and a server-rendered storefront forwards that cookie to
6
+ // GraphQL as a request header so the collection keeps resolving for this visitor.
7
+ //
8
+ // This mirrors the admin-preview token contract (see ./preview/constants), with
9
+ // one deliberate difference: the node-access token is low sensitivity — it only
10
+ // re-grants access the visitor already earned by entering the password — so it is
11
+ // a plain, JS-readable first-party cookie the form sets directly in the browser,
12
+ // not an httpOnly cookie brokered through a server route. That keeps the
13
+ // template surface tiny: no `/api/...` route to mount, just the form plus the
14
+ // transport every framework already runs for preview.
15
+ //
16
+ // Kept free of Web/Node APIs (only `document`, behind a guard) so it can be
17
+ // imported from both server transport and the client component.
18
+
19
+ // First-party cookie the browser carries after unlocking. Value is a
20
+ // comma-separated list of signed tokens, one per unlocked collection.
21
+ export const NODE_ACCESS_COOKIE = 'fontdue_node_access';
22
+
23
+ // Request header a server-rendered storefront forwards the cookie value as, read
24
+ // back by the Fontdue GraphQL server (see FontageWeb.Schema.Context).
25
+ export const NODE_ACCESS_HEADER = 'fontdue-node-access';
26
+
27
+ // ~30 days, matching the token's `max_age` on the server (Fontage.Token
28
+ // `:node_access`). The cookie and the token expire together.
29
+ const NODE_ACCESS_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
30
+
31
+ // Cap on how many unlock tokens the cookie carries. The value is a
32
+ // comma-separated list of ~150-byte signed tokens, so an unbounded list would
33
+ // eventually pass the browser's ~4 KB per-cookie limit and be dropped whole —
34
+ // silently losing every unlock. We keep the most recent few (oldest are also
35
+ // the closest to their 30-day expiry, so the server would drop them anyway) and
36
+ // evict from the front. A visitor unlocking more than this many collections is
37
+ // well outside the intended "share a handful of drafts" use.
38
+ const MAX_NODE_ACCESS_TOKENS = 16;
39
+
40
+ /** The node-access cookie value from a Cookie header string, or undefined. */
41
+ export function readNodeAccessCookie(cookieHeader) {
42
+ if (!cookieHeader) return undefined;
43
+ for (const part of cookieHeader.split(';')) {
44
+ const idx = part.indexOf('=');
45
+ if (idx === -1) continue;
46
+ if (part.slice(0, idx).trim() !== NODE_ACCESS_COOKIE) continue;
47
+ const value = part.slice(idx + 1).trim();
48
+ return value === '' ? undefined : value;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ /**
54
+ * The node-access forwarding header for a Cookie header string, or {} when the
55
+ * cookie is absent. Spread into a server-side GraphQL fetch's headers so a
56
+ * collection the visitor unlocked resolves for them. Returns {} for public
57
+ * (no-cookie) requests, leaving them untouched and cacheable.
58
+ */
59
+ export function nodeAccessHeadersFromCookie(cookieHeader) {
60
+ const value = readNodeAccessCookie(cookieHeader);
61
+ return value ? {
62
+ [NODE_ACCESS_HEADER]: value
63
+ } : {};
64
+ }
65
+
66
+ /**
67
+ * Remember a freshly minted access token in the browser's node-access cookie,
68
+ * preserving any collections already unlocked. First-party and JS-readable on
69
+ * purpose (see the module note); SameSite=Lax so it rides top-level navigations,
70
+ * Secure only on https so it still persists in local dev. No-op off the browser.
71
+ */
72
+ export function rememberNodeAccessToken(token) {
73
+ if (typeof document === 'undefined' || !token) return;
74
+ const existing = readNodeAccessCookie(document.cookie);
75
+ const tokens = existing ? existing.split(',').filter(Boolean) : [];
76
+ if (!tokens.includes(token)) tokens.push(token);
77
+ // Keep the list bounded so it can't grow past the browser's cookie-size limit
78
+ // and get dropped wholesale; the newest unlock is always retained.
79
+ if (tokens.length > MAX_NODE_ACCESS_TOKENS) {
80
+ tokens.splice(0, tokens.length - MAX_NODE_ACCESS_TOKENS);
81
+ }
82
+ const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';
83
+ document.cookie = `${NODE_ACCESS_COOKIE}=${tokens.join(',')}; Path=/; SameSite=Lax${secure}; Max-Age=${NODE_ACCESS_MAX_AGE_SECONDS}`;
84
+ }
@@ -1,20 +1 @@
1
- /** Whether the current async context is an admin preview. */
2
- export declare function isPreviewing(): boolean;
3
- /**
4
- * Preview auth headers for the current async context, or {} when not previewing.
5
- * Spread into a hand-rolled fetch you make inside `runWithPreview`; the
6
- * fontdue-js fetch/preload helpers pick these up automatically and don't need
7
- * it.
8
- */
9
- export declare function ambientPreviewHeaders(): Record<string, string>;
10
- /**
11
- * Run `next` (your framework's render/loader chain) with the request's preview
12
- * token in ambient context, then return its Response. When the request is
13
- * previewing, the response's cache headers are rewritten so it is never stored
14
- * in a shared/CDN cache. Public (no-token) requests pass through untouched and
15
- * stay fully cacheable.
16
- *
17
- * `next` is your middleware's continuation — `next` in Astro, `next` in a React
18
- * Router 7 middleware. It must return (a promise of) the Response.
19
- */
20
- export declare function runWithPreview(request: Request, next: () => Response | Promise<Response>): Promise<Response>;
1
+ export { runWithPreview, isPreviewing, ambientHeaders as ambientPreviewHeaders, } from '../server/middleware.js';