@liiift-studio/sanity-font-manager 2.3.19 → 2.4.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 (42) hide show
  1. package/README.md +437 -437
  2. package/dist/index.js +103 -48
  3. package/dist/index.mjs +103 -48
  4. package/package.json +85 -85
  5. package/src/components/BatchUploadFonts.jsx +640 -639
  6. package/src/components/FontScriptUploaderComponent.jsx +463 -463
  7. package/src/components/GenerateCollectionsPairsComponent.jsx +259 -259
  8. package/src/components/KeyValueInput.jsx +95 -95
  9. package/src/components/KeyValueReferenceInput.jsx +254 -254
  10. package/src/components/NestedObjectArraySelector.jsx +146 -146
  11. package/src/components/PriceInput.jsx +26 -26
  12. package/src/components/PrimaryCollectionGeneratorTypeface.jsx +116 -116
  13. package/src/components/RegenerateSubfamiliesComponent.jsx +185 -185
  14. package/src/components/SetOTF.jsx +87 -87
  15. package/src/components/SingleUploaderTool.jsx +673 -673
  16. package/src/components/StatusDisplay.jsx +26 -26
  17. package/src/components/StyleCountInput.jsx +16 -16
  18. package/src/components/UpdateScriptsComponent.jsx +76 -76
  19. package/src/components/UploadButton.jsx +43 -43
  20. package/src/components/UploadScriptsComponent.jsx +537 -537
  21. package/src/components/VariableInstanceReferencesInput.jsx +190 -190
  22. package/src/hooks/useNestedObjects.js +92 -92
  23. package/src/hooks/useSanityClient.js +9 -9
  24. package/src/index.js +70 -70
  25. package/src/schema/openTypeField.js +1945 -1945
  26. package/src/schema/styleCountField.js +12 -12
  27. package/src/schema/stylesField.js +268 -268
  28. package/src/schema/stylisticSetField.js +301 -301
  29. package/src/utils/generateCssFile.js +205 -205
  30. package/src/utils/generateFontData.js +145 -145
  31. package/src/utils/generateFontFile.js +38 -38
  32. package/src/utils/generateKeywords.js +185 -185
  33. package/src/utils/generateSubset.js +45 -45
  34. package/src/utils/getEmptyFontKit.js +99 -99
  35. package/src/utils/parseVariableFontInstances.js +211 -211
  36. package/src/utils/processFontFiles.js +487 -477
  37. package/src/utils/regenerateFontData.js +146 -146
  38. package/src/utils/sanitizeForSanityId.js +65 -65
  39. package/src/utils/updateFontPrices.js +94 -94
  40. package/src/utils/updateTypefaceDocument.js +149 -160
  41. package/src/utils/uploadFontFiles.js +115 -26
  42. package/src/utils/utils.js +24 -24
@@ -1,205 +1,205 @@
1
- // Builds a @font-face CSS file from a WOFF2 blob — URL or base64 src, variable font axis descriptors, metric-tuned fallback @font-face for CLS reduction
2
- import base64 from 'base-64';
3
- import { Buffer } from 'buffer';
4
- import * as fontkit from 'fontkit';
5
-
6
- function _arrayBufferToBase64(buffer) {
7
- var binary = '';
8
- var bytes = new Uint8Array(buffer);
9
- var len = bytes.byteLength;
10
- for (var i = 0; i < len; i++) {
11
- binary += String.fromCharCode(bytes[i]);
12
- }
13
- return base64.encode(binary);
14
- }
15
-
16
- /**
17
- * Reads variable axes from a fontkit font object and returns:
18
- * { descriptors, skipped }
19
- * where `descriptors` is the CSS string for the @font-face block and
20
- * `skipped` lists axis tags that have no CSS descriptor (opsz, custom axes, etc.)
21
- * so callers can surface them in comments.
22
- *
23
- * Edge cases handled:
24
- * - Degenerate axes (min === max): skipped — no actual variation range
25
- * - ital with max === 0: skipped — axis exists but font has no italic
26
- * - slnt ordering: sorted ascending as CSS spec requires
27
- * - ital + slnt coexistence: slnt takes priority (more expressive)
28
- * - min > max from corrupt font data: clamped with Math.min/Math.max
29
- * @param {Object} font - fontkit font instance
30
- * @returns {{ descriptors: string, skipped: string[] }}
31
- */
32
- export function buildVFDescriptors(font) {
33
- const cssAxes = {}
34
- const skipped = []
35
- try {
36
- const va = font.variationAxes
37
- if (!va) return { descriptors: '', skipped: [] }
38
-
39
- for (const [tag, axis] of Object.entries(va)) {
40
- const lo = Math.min(axis.min, axis.max)
41
- const hi = Math.max(axis.min, axis.max)
42
-
43
- // Skip degenerate axes — no actual range
44
- if (lo === hi) { skipped.push(tag); continue }
45
-
46
- if (tag === 'wght') {
47
- cssAxes['font-weight'] = `${lo} ${hi}`
48
- } else if (tag === 'wdth') {
49
- cssAxes['font-stretch'] = `${lo}% ${hi}%`
50
- } else if (tag === 'slnt') {
51
- // slnt: ascending order required by CSS Fonts Level 4
52
- cssAxes['font-style'] = `oblique ${lo}deg ${hi}deg`
53
- } else if (tag === 'ital' && !cssAxes['font-style']) {
54
- // ital: only emit if font actually has italic range; slnt takes priority
55
- if (hi > 0) cssAxes['font-style'] = 'italic'
56
- else skipped.push(tag)
57
- } else {
58
- // opsz, GRAD, XTRA, XHGT and all other custom axes have no CSS @font-face descriptor
59
- skipped.push(tag)
60
- }
61
- }
62
- } catch (_) {
63
- // axes unreadable — no descriptors
64
- }
65
- const descriptors = Object.entries(cssAxes).map(([k, v]) => `${k}:${v}`).join(';') + (Object.keys(cssAxes).length ? ';' : '')
66
- return { descriptors, skipped }
67
- }
68
-
69
- // Cross-platform fallback font stacks by category.
70
- // Multiple local() sources ensure the first available system font is used.
71
- // Liberation Sans covers Linux; Roboto covers Android; Georgia is universal for serifs.
72
- const FALLBACK_STACKS = {
73
- 'sans-serif': "local('Arial'), local('Helvetica Neue'), local('Roboto'), local('Liberation Sans')",
74
- 'serif': "local('Georgia'), local('Times New Roman'), local('Times')",
75
- 'monospace': "local('Courier New'), local('Courier'), local('Menlo'), local('Monaco')",
76
- // Display and script fonts have no universally suitable system fallback; default to sans-serif
77
- 'default': "local('Arial'), local('Helvetica Neue'), local('Roboto'), local('Liberation Sans')",
78
- };
79
-
80
- // OS/2 sFamilyClass high byte → FALLBACK_STACKS key.
81
- // Classes 1–5,7 = serif variants; 8 = sans-serif; 9 = ornamental; 10 = script; 12 = symbolic.
82
- const FAMILY_CLASS_MAP = {
83
- 1: 'serif', 2: 'serif', 3: 'serif', 4: 'serif', 5: 'serif', 7: 'serif',
84
- 8: 'sans-serif',
85
- };
86
-
87
- // Darden Studio fonts have sFamilyClass: 0 (No Classification) in their OS/2 table,
88
- // so name-based matching is used as the primary signal.
89
- const SERIF_NAMES = /jubilat|corundum|dapifer|birra|daith/i;
90
- const SANS_NAMES = /halyard|gamay|omnes|kit/i;
91
-
92
- /** Detects font category from the font name first, then OS/2 sFamilyClass as fallback */
93
- function detectFontCategory(font, fontName) {
94
- if (fontName && SERIF_NAMES.test(fontName)) return 'serif';
95
- if (fontName && SANS_NAMES.test(fontName)) return 'sans-serif';
96
- try {
97
- // fontkit v2: font['OS/2'] exposes the parsed OS/2 table directly
98
- const familyClass = font['OS/2']?.sFamilyClass ?? 0;
99
- const highByte = (familyClass >> 8) & 0xFF;
100
- return FAMILY_CLASS_MAP[highByte] ?? 'default';
101
- } catch {
102
- return 'default';
103
- }
104
- }
105
-
106
- /** Extracts metric override percentages and detects the category fallback stack from a font ArrayBuffer */
107
- function calcFallbackData(arrayBuffer, fontName) {
108
- try {
109
- let font = fontkit.create(Buffer.from(arrayBuffer));
110
- let upm = font.unitsPerEm;
111
- let category = detectFontCategory(font, fontName);
112
- return {
113
- fallbackSrc: FALLBACK_STACKS[category],
114
- ascentOverride: `${(font.ascent / upm * 100).toFixed(2)}%`,
115
- descentOverride: `${(Math.abs(font.descent) / upm * 100).toFixed(2)}%`,
116
- lineGapOverride: `${(font.lineGap / upm * 100).toFixed(2)}%`,
117
- };
118
- } catch (err) {
119
- console.error('Failed to extract fallback font data:', err);
120
- return {
121
- fallbackSrc: FALLBACK_STACKS['default'],
122
- ascentOverride: '100%',
123
- descentOverride: '0%',
124
- lineGapOverride: '0%',
125
- };
126
- }
127
- }
128
-
129
- export default async function generateCssFile({
130
- woff2File,
131
- fileInput,
132
- language = null,
133
- fileName,
134
- fontName,
135
- variableFont,
136
- weight,
137
- style = 'Normal',
138
- client,
139
- }) {
140
- try {
141
- // Read the file once; reuse the same buffer for base64 encoding and fontkit analysis
142
- let arrayBuffer = await woff2File.arrayBuffer();
143
- let b64 = _arrayBufferToBase64(arrayBuffer);
144
- let fontkitFont = fontkit.create(Buffer.from(arrayBuffer));
145
- let { fallbackSrc, ascentOverride, descentOverride, lineGapOverride } = calcFallbackData(arrayBuffer, fontName);
146
-
147
- let cssString;
148
- if (variableFont) {
149
- let { descriptors, skipped } = buildVFDescriptors(fontkitFont);
150
- // Axes with no CSS @font-face descriptor (opsz needs font-optical-sizing:auto on elements;
151
- // custom axes like GRAD require CSS @font-face syntax not yet standardised).
152
- let skipComment = skipped.length
153
- ? `/* axes present but have no @font-face descriptor: ${skipped.join(', ')}` +
154
- (skipped.includes('opsz') ? ' — add font-optical-sizing:auto to your element CSS' : '') +
155
- ' */'
156
- : ''
157
- cssString = `${skipComment}@font-face{font-family:'${fontName}';src:url(data:application/font-woff2;charset=utf-8;base64,${b64})format('woff2-variations');${descriptors}font-display:swap;}`;
158
- } else {
159
- let fontStyle = style === 'Italic' ? 'italic' : 'normal';
160
- cssString = `@font-face{font-family:'${fontName}';src:url(data:application/font-woff2;charset=utf-8;base64,${b64})format('woff2');font-weight:${weight};font-style:${fontStyle};font-display:swap;}`;
161
- }
162
-
163
- // Fallback @font-face: tunes a category-appropriate system font to match the custom font's
164
- // line metrics (ascent/descent/lineGap), reducing layout shift while the custom font loads.
165
- // Customers who reference fontSrc in their own projects get this fallback automatically.
166
- let fallbackCssString = `@font-face{font-family:'${fontName} Fallback';src:${fallbackSrc};ascent-override:${ascentOverride};descent-override:${descentOverride};line-gap-override:${lineGapOverride};}`;
167
-
168
- let uploadBuffer = Buffer.from(cssString + fallbackCssString, 'utf-8');
169
- let doc = await client.assets.upload('file', uploadBuffer, { filename: fileName + '.css' });
170
-
171
- let newFileInput = language == null ?
172
- {
173
- ...fileInput,
174
- css: {
175
- _type: 'file',
176
- asset: {
177
- _type: 'reference',
178
- _ref: doc._id
179
- }
180
- }
181
- }
182
- :
183
- {
184
- ...fileInput,
185
- [language]: {
186
- ...fileInput[language],
187
- css: {
188
- _type: 'file',
189
- asset: {
190
- _type: 'reference',
191
- _ref: doc._id
192
- }
193
- }
194
- }
195
- }
196
-
197
- return newFileInput;
198
-
199
- }
200
- catch (err) {
201
- console.error(err);
202
- throw err;
203
- }
204
-
205
- }
1
+ // Builds a @font-face CSS file from a WOFF2 blob — URL or base64 src, variable font axis descriptors, metric-tuned fallback @font-face for CLS reduction
2
+ import base64 from 'base-64';
3
+ import { Buffer } from 'buffer';
4
+ import * as fontkit from 'fontkit';
5
+
6
+ function _arrayBufferToBase64(buffer) {
7
+ var binary = '';
8
+ var bytes = new Uint8Array(buffer);
9
+ var len = bytes.byteLength;
10
+ for (var i = 0; i < len; i++) {
11
+ binary += String.fromCharCode(bytes[i]);
12
+ }
13
+ return base64.encode(binary);
14
+ }
15
+
16
+ /**
17
+ * Reads variable axes from a fontkit font object and returns:
18
+ * { descriptors, skipped }
19
+ * where `descriptors` is the CSS string for the @font-face block and
20
+ * `skipped` lists axis tags that have no CSS descriptor (opsz, custom axes, etc.)
21
+ * so callers can surface them in comments.
22
+ *
23
+ * Edge cases handled:
24
+ * - Degenerate axes (min === max): skipped — no actual variation range
25
+ * - ital with max === 0: skipped — axis exists but font has no italic
26
+ * - slnt ordering: sorted ascending as CSS spec requires
27
+ * - ital + slnt coexistence: slnt takes priority (more expressive)
28
+ * - min > max from corrupt font data: clamped with Math.min/Math.max
29
+ * @param {Object} font - fontkit font instance
30
+ * @returns {{ descriptors: string, skipped: string[] }}
31
+ */
32
+ export function buildVFDescriptors(font) {
33
+ const cssAxes = {}
34
+ const skipped = []
35
+ try {
36
+ const va = font.variationAxes
37
+ if (!va) return { descriptors: '', skipped: [] }
38
+
39
+ for (const [tag, axis] of Object.entries(va)) {
40
+ const lo = Math.min(axis.min, axis.max)
41
+ const hi = Math.max(axis.min, axis.max)
42
+
43
+ // Skip degenerate axes — no actual range
44
+ if (lo === hi) { skipped.push(tag); continue }
45
+
46
+ if (tag === 'wght') {
47
+ cssAxes['font-weight'] = `${lo} ${hi}`
48
+ } else if (tag === 'wdth') {
49
+ cssAxes['font-stretch'] = `${lo}% ${hi}%`
50
+ } else if (tag === 'slnt') {
51
+ // slnt: ascending order required by CSS Fonts Level 4
52
+ cssAxes['font-style'] = `oblique ${lo}deg ${hi}deg`
53
+ } else if (tag === 'ital' && !cssAxes['font-style']) {
54
+ // ital: only emit if font actually has italic range; slnt takes priority
55
+ if (hi > 0) cssAxes['font-style'] = 'italic'
56
+ else skipped.push(tag)
57
+ } else {
58
+ // opsz, GRAD, XTRA, XHGT and all other custom axes have no CSS @font-face descriptor
59
+ skipped.push(tag)
60
+ }
61
+ }
62
+ } catch (_) {
63
+ // axes unreadable — no descriptors
64
+ }
65
+ const descriptors = Object.entries(cssAxes).map(([k, v]) => `${k}:${v}`).join(';') + (Object.keys(cssAxes).length ? ';' : '')
66
+ return { descriptors, skipped }
67
+ }
68
+
69
+ // Cross-platform fallback font stacks by category.
70
+ // Multiple local() sources ensure the first available system font is used.
71
+ // Liberation Sans covers Linux; Roboto covers Android; Georgia is universal for serifs.
72
+ const FALLBACK_STACKS = {
73
+ 'sans-serif': "local('Arial'), local('Helvetica Neue'), local('Roboto'), local('Liberation Sans')",
74
+ 'serif': "local('Georgia'), local('Times New Roman'), local('Times')",
75
+ 'monospace': "local('Courier New'), local('Courier'), local('Menlo'), local('Monaco')",
76
+ // Display and script fonts have no universally suitable system fallback; default to sans-serif
77
+ 'default': "local('Arial'), local('Helvetica Neue'), local('Roboto'), local('Liberation Sans')",
78
+ };
79
+
80
+ // OS/2 sFamilyClass high byte → FALLBACK_STACKS key.
81
+ // Classes 1–5,7 = serif variants; 8 = sans-serif; 9 = ornamental; 10 = script; 12 = symbolic.
82
+ const FAMILY_CLASS_MAP = {
83
+ 1: 'serif', 2: 'serif', 3: 'serif', 4: 'serif', 5: 'serif', 7: 'serif',
84
+ 8: 'sans-serif',
85
+ };
86
+
87
+ // Darden Studio fonts have sFamilyClass: 0 (No Classification) in their OS/2 table,
88
+ // so name-based matching is used as the primary signal.
89
+ const SERIF_NAMES = /jubilat|corundum|dapifer|birra|daith/i;
90
+ const SANS_NAMES = /halyard|gamay|omnes|kit/i;
91
+
92
+ /** Detects font category from the font name first, then OS/2 sFamilyClass as fallback */
93
+ function detectFontCategory(font, fontName) {
94
+ if (fontName && SERIF_NAMES.test(fontName)) return 'serif';
95
+ if (fontName && SANS_NAMES.test(fontName)) return 'sans-serif';
96
+ try {
97
+ // fontkit v2: font['OS/2'] exposes the parsed OS/2 table directly
98
+ const familyClass = font['OS/2']?.sFamilyClass ?? 0;
99
+ const highByte = (familyClass >> 8) & 0xFF;
100
+ return FAMILY_CLASS_MAP[highByte] ?? 'default';
101
+ } catch {
102
+ return 'default';
103
+ }
104
+ }
105
+
106
+ /** Extracts metric override percentages and detects the category fallback stack from a font ArrayBuffer */
107
+ function calcFallbackData(arrayBuffer, fontName) {
108
+ try {
109
+ let font = fontkit.create(Buffer.from(arrayBuffer));
110
+ let upm = font.unitsPerEm;
111
+ let category = detectFontCategory(font, fontName);
112
+ return {
113
+ fallbackSrc: FALLBACK_STACKS[category],
114
+ ascentOverride: `${(font.ascent / upm * 100).toFixed(2)}%`,
115
+ descentOverride: `${(Math.abs(font.descent) / upm * 100).toFixed(2)}%`,
116
+ lineGapOverride: `${(font.lineGap / upm * 100).toFixed(2)}%`,
117
+ };
118
+ } catch (err) {
119
+ console.error('Failed to extract fallback font data:', err);
120
+ return {
121
+ fallbackSrc: FALLBACK_STACKS['default'],
122
+ ascentOverride: '100%',
123
+ descentOverride: '0%',
124
+ lineGapOverride: '0%',
125
+ };
126
+ }
127
+ }
128
+
129
+ export default async function generateCssFile({
130
+ woff2File,
131
+ fileInput,
132
+ language = null,
133
+ fileName,
134
+ fontName,
135
+ variableFont,
136
+ weight,
137
+ style = 'Normal',
138
+ client,
139
+ }) {
140
+ try {
141
+ // Read the file once; reuse the same buffer for base64 encoding and fontkit analysis
142
+ let arrayBuffer = await woff2File.arrayBuffer();
143
+ let b64 = _arrayBufferToBase64(arrayBuffer);
144
+ let fontkitFont = fontkit.create(Buffer.from(arrayBuffer));
145
+ let { fallbackSrc, ascentOverride, descentOverride, lineGapOverride } = calcFallbackData(arrayBuffer, fontName);
146
+
147
+ let cssString;
148
+ if (variableFont) {
149
+ let { descriptors, skipped } = buildVFDescriptors(fontkitFont);
150
+ // Axes with no CSS @font-face descriptor (opsz needs font-optical-sizing:auto on elements;
151
+ // custom axes like GRAD require CSS @font-face syntax not yet standardised).
152
+ let skipComment = skipped.length
153
+ ? `/* axes present but have no @font-face descriptor: ${skipped.join(', ')}` +
154
+ (skipped.includes('opsz') ? ' — add font-optical-sizing:auto to your element CSS' : '') +
155
+ ' */'
156
+ : ''
157
+ cssString = `${skipComment}@font-face{font-family:'${fontName}';src:url(data:application/font-woff2;charset=utf-8;base64,${b64})format('woff2-variations');${descriptors}font-display:swap;}`;
158
+ } else {
159
+ let fontStyle = style === 'Italic' ? 'italic' : 'normal';
160
+ cssString = `@font-face{font-family:'${fontName}';src:url(data:application/font-woff2;charset=utf-8;base64,${b64})format('woff2');font-weight:${weight};font-style:${fontStyle};font-display:swap;}`;
161
+ }
162
+
163
+ // Fallback @font-face: tunes a category-appropriate system font to match the custom font's
164
+ // line metrics (ascent/descent/lineGap), reducing layout shift while the custom font loads.
165
+ // Customers who reference fontSrc in their own projects get this fallback automatically.
166
+ let fallbackCssString = `@font-face{font-family:'${fontName} Fallback';src:${fallbackSrc};ascent-override:${ascentOverride};descent-override:${descentOverride};line-gap-override:${lineGapOverride};}`;
167
+
168
+ let uploadBuffer = Buffer.from(cssString + fallbackCssString, 'utf-8');
169
+ let doc = await client.assets.upload('file', uploadBuffer, { filename: fileName + '.css' });
170
+
171
+ let newFileInput = language == null ?
172
+ {
173
+ ...fileInput,
174
+ css: {
175
+ _type: 'file',
176
+ asset: {
177
+ _type: 'reference',
178
+ _ref: doc._id
179
+ }
180
+ }
181
+ }
182
+ :
183
+ {
184
+ ...fileInput,
185
+ [language]: {
186
+ ...fileInput[language],
187
+ css: {
188
+ _type: 'file',
189
+ asset: {
190
+ _type: 'reference',
191
+ _ref: doc._id
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ return newFileInput;
198
+
199
+ }
200
+ catch (err) {
201
+ console.error(err);
202
+ throw err;
203
+ }
204
+
205
+ }