@liiift-studio/sanity-font-manager 2.2.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.
@@ -0,0 +1,259 @@
1
+ // Generates Full Family, Uprights, Italics, and Subfamily collections plus Regular/Italic weight pairs from a typeface's linked fonts
2
+
3
+ import React, { useCallback, useState } from 'react';
4
+ import { Stack, Grid, Flex, Text, Button, Card, Spinner } from '@sanity/ui';
5
+ import { useFormValue } from 'sanity';
6
+ import slugify from 'slugify';
7
+ import { nanoid } from 'nanoid';
8
+
9
+ import { useSanityClient } from '../hooks/useSanityClient';
10
+ import StatusDisplay from './StatusDisplay';
11
+
12
+ /**
13
+ * Generates Full Family, Uprights, Italics, Subfamily collections, and Regular/Italic pairs
14
+ * from a typeface's linked fonts. Replaces existing collections and pairs respectively.
15
+ */
16
+ export const GenerateCollectionsPairsComponent = () => {
17
+ const [status, setStatus] = useState('ready');
18
+ const [ready, setReady] = useState(true);
19
+ const [collectionPrice, setCollectionPrice] = useState(
20
+ process.env.SANITY_STUDIO_DEFAULT_COLLECTION_PRICE || 20
21
+ );
22
+ const [pairPrice, setPairPrice] = useState(
23
+ process.env.SANITY_STUDIO_DEFAULT_PAIR_PRICE || 75
24
+ );
25
+
26
+ const client = useSanityClient();
27
+ const doc_id = useFormValue(['_id']);
28
+ const title = useFormValue(['title']);
29
+ const slug = useFormValue(['slug']);
30
+ const stylesObject = useFormValue(['styles']);
31
+
32
+ /** Creates or replaces a collection document and returns a weak reference to it. */
33
+ const createSanityCollection = useCallback(async (fontsList, collectionSlug, newTitle) => {
34
+ const newSlug = collectionSlug.toLowerCase().trim();
35
+
36
+ const fontRefs = fontsList.map(font => ({
37
+ _key: nanoid(),
38
+ _type: 'reference',
39
+ _ref: font._id ?? font._ref,
40
+ _weak: true,
41
+ }));
42
+
43
+ let preferredStyle = { weight: fontsList[0].weight, style: fontsList[0].style, _ref: fontsList[0]._ref };
44
+ fontsList.forEach(font => {
45
+ if (Number(font.weight) < Number(preferredStyle.weight)) return;
46
+ if (Number(font.weight) === Number(preferredStyle.weight) && preferredStyle.style === 'Italic' && font.style === 'Regular') {
47
+ preferredStyle = { weight: font.weight, style: font.style, _ref: font._id };
48
+ } else if (Number(font.weight) > Number(preferredStyle.weight)) {
49
+ preferredStyle = { weight: font.weight, style: font.style, _ref: font._id };
50
+ }
51
+ });
52
+
53
+ const price = (collectionPrice ? Number(collectionPrice) : 0) * fontRefs.length;
54
+
55
+ await client.createOrReplace({
56
+ _key: nanoid(),
57
+ _id: newSlug,
58
+ _type: 'collection',
59
+ state: 'active',
60
+ type: 'collection',
61
+ preferredStyle: { _type: 'reference', _ref: preferredStyle._ref, _weak: true },
62
+ title: newTitle,
63
+ slug: { _type: 'slug', current: newSlug },
64
+ fonts: fontRefs,
65
+ price,
66
+ }).catch(err => { console.error('Error creating collection:', err.message); });
67
+
68
+ return { _ref: newSlug, _type: 'reference', _weak: true, _key: nanoid() };
69
+ }, [collectionPrice, client]);
70
+
71
+ /** Creates or replaces a pair document and returns a weak reference to it. */
72
+ const createSanityPair = useCallback(async (pair, pairSlug, newTitle) => {
73
+ const newSlug = pairSlug.toLowerCase().trim();
74
+
75
+ const fontRefs = pair.map(font => ({
76
+ _key: nanoid(),
77
+ _type: 'reference',
78
+ _ref: font._id,
79
+ _weak: true,
80
+ }));
81
+
82
+ await client.createOrReplace({
83
+ _key: nanoid(),
84
+ _id: newSlug,
85
+ _type: 'pair',
86
+ preferredStyle: { _type: 'reference', _ref: fontRefs[0]._ref, _weak: true },
87
+ title: newTitle,
88
+ slug: { _type: 'slug', current: newSlug },
89
+ fonts: fontRefs,
90
+ price: pairPrice ? Number(pairPrice) : 0,
91
+ }).catch(err => { console.error('Error creating pair:', err.message); });
92
+
93
+ return { _ref: newSlug, _type: 'reference', _weak: true, _key: nanoid() };
94
+ }, [pairPrice, client]);
95
+
96
+ /** Generates Full Family, Uprights, Italics, and Subfamily collections. */
97
+ const handleGenerateCollections = useCallback(async () => {
98
+ setStatus('Generating collections...');
99
+ setReady(false);
100
+ try {
101
+ const result = await client.fetch(
102
+ `*[_type == "typeface" && _id == $id]{ "fonts": styles.fonts[] -> }[0]`,
103
+ { id: doc_id }
104
+ );
105
+ const sanityFonts = result?.fonts ?? [];
106
+
107
+ const subfamilies = stylesObject?.subfamilies ?? [];
108
+ const totalCollections = subfamilies.length + 3;
109
+
110
+ const fullFamily = [], uprights = [], italics = [];
111
+ for (const font of sanityFonts) {
112
+ fullFamily.push(font);
113
+ if (font.style === 'Regular') uprights.push(font);
114
+ else italics.push(font);
115
+ }
116
+
117
+ const typefacePatch = [];
118
+
119
+ if (fullFamily.length > 1) {
120
+ setStatus(`[1/${totalCollections}] Generating full family collection`);
121
+ typefacePatch.push(await createSanityCollection(fullFamily, `${slug.current}-full-family`, `${title} Full Family`));
122
+ }
123
+ if (uprights.length > 1) {
124
+ setStatus(`[2/${totalCollections}] Generating uprights collection`);
125
+ const ref = await createSanityCollection(uprights, `${slug.current}-uprights`, `${title} Uprights`);
126
+ if (ref) typefacePatch.push(ref);
127
+ }
128
+ if (italics.length > 1) {
129
+ setStatus(`[3/${totalCollections}] Generating italics collection`);
130
+ const ref = await createSanityCollection(italics, `${slug.current}-italics`, `${title} Italics`);
131
+ if (ref) typefacePatch.push(ref);
132
+ }
133
+ for (let i = 0; i < subfamilies.length; i++) {
134
+ setStatus(`[${i + 4}/${totalCollections}] Generating ${subfamilies[i].title} collection`);
135
+ const ref = await createSanityCollection(
136
+ subfamilies[i].fonts,
137
+ `${slug.current}-${slugify(subfamilies[i].title)}-family`,
138
+ `${title} ${subfamilies[i].title} Family`
139
+ );
140
+ if (ref) typefacePatch.push(ref);
141
+ }
142
+
143
+ await client.patch(doc_id).set({ styles: { ...stylesObject, collections: typefacePatch } }).commit();
144
+ setStatus('Collections generated');
145
+ } catch (err) {
146
+ console.error('Error generating collections:', err);
147
+ setStatus('Error generating collections');
148
+ }
149
+ setReady(true);
150
+ }, [doc_id, title, slug, stylesObject, collectionPrice, client, createSanityCollection]);
151
+
152
+ /** Generates Regular/Italic pairs matched by subfamily and weight. */
153
+ const handleGeneratePairs = useCallback(async () => {
154
+ setStatus('Generating pairs...');
155
+ setReady(false);
156
+ try {
157
+ const result = await client.fetch(
158
+ `*[_type == "typeface" && _id == $id]{ "fonts": styles.fonts[] -> }[0]`,
159
+ { id: doc_id }
160
+ );
161
+ const sanityFonts = result?.fonts ?? [];
162
+
163
+ const regular = [], italic = [];
164
+ for (const font of sanityFonts) {
165
+ if (font.style === 'Regular') regular.push(font);
166
+ else italic.push(font);
167
+ }
168
+
169
+ const pairs = [];
170
+ for (const reg of regular) {
171
+ for (const ita of italic) {
172
+ if (ita.subfamily === reg.subfamily && ita.weight === reg.weight && ita.weightName === reg.weightName) {
173
+ pairs.push([reg, ita]);
174
+ }
175
+ }
176
+ }
177
+
178
+ const typefacePatch = [];
179
+ for (let i = 0; i < pairs.length; i++) {
180
+ const [reg] = pairs[i];
181
+ let pairSlug, pairTitle;
182
+ if (reg.subfamily && reg.subfamily !== '') {
183
+ if (reg.subfamily === 'Regular') {
184
+ pairSlug = `${slug.current}-${slugify(reg.weightName)}s`;
185
+ pairTitle = `${title} ${reg.weightName}s`;
186
+ } else {
187
+ pairSlug = `${slug.current}-${slugify(reg.subfamily)}-${slugify(reg.weightName)}s`;
188
+ pairTitle = `${title} ${reg.subfamily} ${reg.weightName}s`;
189
+ }
190
+ } else {
191
+ pairSlug = `${slug.current}-${slugify(reg.weightName)}s`;
192
+ pairTitle = `${title} ${reg.weightName}s`;
193
+ }
194
+ setStatus(`[${i + 1}/${pairs.length}] Generating ${pairTitle}`);
195
+ const ref = await createSanityPair(pairs[i], pairSlug, pairTitle);
196
+ if (ref) typefacePatch.push(ref);
197
+ }
198
+
199
+ const preferredStyle = regular[0]?._id ?? '';
200
+ await client.patch(doc_id).set({
201
+ preferredStyle: { _ref: preferredStyle, _type: 'reference', _weak: true },
202
+ styles: { ...stylesObject, pairs: typefacePatch },
203
+ }).commit();
204
+
205
+ setStatus('Pairs generated');
206
+ } catch (err) {
207
+ console.error('Error generating pairs:', err);
208
+ setStatus('Error generating pairs');
209
+ }
210
+ setReady(true);
211
+ }, [doc_id, title, slug, stylesObject, pairPrice, client, createSanityPair]);
212
+
213
+ if (!title || !slug) return null;
214
+
215
+ return (
216
+ <Stack space={2}>
217
+ <StatusDisplay status={status} error={false} />
218
+ <Card border padding={2} shadow={1} radius={2}>
219
+ {ready ? (
220
+ <Stack space={3}>
221
+ <Grid columns={[2]} gap={4} marginTop={1} marginBottom={1}>
222
+ <Stack space={2}>
223
+ <Text size={1} muted>Collection price / font</Text>
224
+ <Flex align="center" gap={2}>
225
+ <Text size={1} muted>$</Text>
226
+ <input
227
+ value={collectionPrice}
228
+ onChange={(e) => setCollectionPrice(e.target.value)}
229
+ type="number"
230
+ style={{ textAlign: 'end', padding: '5px', maxWidth: '75px' }}
231
+ />
232
+ </Flex>
233
+ </Stack>
234
+ <Stack space={2}>
235
+ <Text size={1} muted>Pair price</Text>
236
+ <Flex align="center" gap={2}>
237
+ <Text size={1} muted>$</Text>
238
+ <input
239
+ value={pairPrice}
240
+ onChange={(e) => setPairPrice(e.target.value)}
241
+ type="number"
242
+ style={{ textAlign: 'end', padding: '5px', maxWidth: '75px' }}
243
+ />
244
+ </Flex>
245
+ </Stack>
246
+ </Grid>
247
+ <Button mode="ghost" tone="primary" text="Generate Collections" style={{ width: '100%' }} onClick={handleGenerateCollections} />
248
+ <Button mode="ghost" tone="primary" text="Generate Pairs" style={{ width: '100%' }} onClick={handleGeneratePairs} />
249
+ </Stack>
250
+ ) : (
251
+ <Flex align="center" justify="center" gap={3} padding={4}>
252
+ <Spinner />
253
+ <Text muted size={1}>{status}</Text>
254
+ </Flex>
255
+ )}
256
+ </Card>
257
+ </Stack>
258
+ );
259
+ };
@@ -0,0 +1,26 @@
1
+ // Reusable $ + number input for collection and pair price fields
2
+
3
+ import React from 'react';
4
+ import { Flex, Text } from '@sanity/ui';
5
+
6
+ /**
7
+ * Renders an inline price field: "Price $ [input] per style"
8
+ * @param {Object} props
9
+ * @param {string} props.inputPrice - Current price value
10
+ * @param {Function} props.handleInputChange - onChange handler
11
+ */
12
+ const PriceInput = ({ inputPrice, handleInputChange }) => (
13
+ <Flex align="center" gap={2}>
14
+ <Text size={1} muted>Price:</Text>
15
+ <Text size={1} muted>$</Text>
16
+ <input
17
+ value={inputPrice}
18
+ onChange={handleInputChange}
19
+ type="number"
20
+ style={{ textAlign: 'end', padding: '5px', maxWidth: '75px' }}
21
+ />
22
+ <Text size={1} muted>per style</Text>
23
+ </Flex>
24
+ );
25
+
26
+ export default PriceInput;
@@ -0,0 +1,185 @@
1
+ // Recalculates and patches the subfamily field on all fonts linked to a typeface
2
+
3
+ import React, { useState } from 'react';
4
+ import { nanoid } from 'nanoid';
5
+ import { Button, Text, Stack, Box } from '@sanity/ui';
6
+ import { useFormValue } from 'sanity';
7
+ import { useSanityClient } from '../hooks/useSanityClient';
8
+ import { expandAbbreviations } from '../utils/generateKeywords';
9
+
10
+ /**
11
+ * Button component that rebuilds the subfamilies array on the current typeface document
12
+ * by grouping all linked fonts by their `subfamily` field.
13
+ *
14
+ * NOTE: This component uses its own local updateTypefaceDocument that patches ONLY
15
+ * the `subfamilies` key while preserving all other styles fields (fonts, variableFont).
16
+ * Do NOT replace it with the shared updateTypefaceDocument from utils/, which patches
17
+ * the full styles object and would overwrite the fonts/variableFont arrays.
18
+ */
19
+ export const RegenerateSubfamiliesComponent = () => {
20
+ const [status, setStatus] = useState('');
21
+ const [ready, setReady] = useState(true);
22
+ const [error, setError] = useState(false);
23
+
24
+ const client = useSanityClient();
25
+
26
+ const doc_id = useFormValue(['_id']);
27
+ const title = useFormValue(['title']);
28
+ const slug = useFormValue(['slug']);
29
+ const stylesObject = useFormValue(['styles']) || { fonts: [], variableFont: [] };
30
+
31
+ const handleClick = () => {
32
+ regenerateSubfamilies({ title, stylesObject, slug, doc_id, client, setStatus, setReady, setError });
33
+ };
34
+
35
+ return (
36
+ <>
37
+ {status && (
38
+ <Box padding={3} style={{ borderRadius: '4px', marginBottom: '10px' }}>
39
+ <Text size={1} style={{ color: error ? 'red' : 'green' }}>{status}</Text>
40
+ </Box>
41
+ )}
42
+ <Button mode="ghost" tone="primary" width="fill" padding={3} onClick={handleClick} disabled={!ready}>
43
+ <Stack space={2}>
44
+ <Text align="center">Regenerate Subfamilies</Text>
45
+ </Stack>
46
+ </Button>
47
+ </>
48
+ );
49
+ };
50
+
51
+ /**
52
+ * Fetches all fonts for a typeface, groups them by subfamily, and patches the document.
53
+ */
54
+ const regenerateSubfamilies = async ({ title, stylesObject, slug, doc_id, client, setStatus, setReady, setError }) => {
55
+ try {
56
+ setStatus('Regenerating subfamilies...');
57
+ setReady(false);
58
+ setError(false);
59
+
60
+ const allFonts = await fetchFonts(title, slug, client);
61
+
62
+ if (!allFonts || allFonts.length === 0) {
63
+ setStatus('No fonts found for this typeface');
64
+ setReady(true);
65
+ setError(true);
66
+ return;
67
+ }
68
+
69
+ console.log('Found fonts:', allFonts.length);
70
+ setStatus(`Found ${allFonts.length} fonts. Processing...`);
71
+
72
+ const subfamilies = groupFontsBySubfamily(allFonts);
73
+ const newSubfamiliesArray = createSubfamiliesArray(subfamilies);
74
+
75
+ console.log('New subfamilies:', newSubfamiliesArray);
76
+ setStatus(`Created ${newSubfamiliesArray.length} subfamily groups`);
77
+
78
+ await updateTypefaceSubfamilies(doc_id, stylesObject, newSubfamiliesArray, client, setStatus, setError);
79
+
80
+ setStatus('Subfamilies regenerated successfully');
81
+ } catch (e) {
82
+ console.error(e.message);
83
+ setStatus('Error regenerating subfamilies');
84
+ setError(true);
85
+ }
86
+
87
+ setReady(true);
88
+ };
89
+
90
+ /**
91
+ * Fetches the font list for a typeface by slug.
92
+ * @param {string} title
93
+ * @param {Object} slug
94
+ * @param {Object} client
95
+ * @returns {Promise<Array>}
96
+ */
97
+ const fetchFonts = async (title, slug, client) => {
98
+ const slugCurrent = slug?.current || title;
99
+ const query = await client.fetch(
100
+ `*[_type == "typeface" && slug.current == $slugCurrent][0]{
101
+ "fonts": styles.fonts[]->{ _id, title, subfamily, style, weight, _key }
102
+ }`,
103
+ { slugCurrent }
104
+ );
105
+ return query?.fonts || [];
106
+ };
107
+
108
+ /**
109
+ * Groups fonts by their subfamily name, excluding VF fonts from shared subfamilies.
110
+ * @param {Object[]} fonts
111
+ * @returns {Object}
112
+ */
113
+ const groupFontsBySubfamily = (fonts) => {
114
+ const subfamilies = {};
115
+ fonts.forEach(font => {
116
+ if (font.title?.includes('VF')) {
117
+ // Variable fonts get isolated into their own unique group so they don't pollute subfamilies
118
+ subfamilies[`VF_${font.title}`] = [font];
119
+ } else {
120
+ const subfamilyName = font.subfamily ? expandAbbreviations(font.subfamily) : 'Regular';
121
+ if (!subfamilies[subfamilyName]) subfamilies[subfamilyName] = [];
122
+ subfamilies[subfamilyName].push(font);
123
+ }
124
+ });
125
+ return subfamilies;
126
+ };
127
+
128
+ /**
129
+ * Converts a grouped subfamilies map into the Sanity array format.
130
+ * @param {Object} subfamilies
131
+ * @returns {Object[]}
132
+ */
133
+ const createSubfamiliesArray = (subfamilies) => {
134
+ return Object.keys(subfamilies).map(subfamilyName => ({
135
+ title: subfamilyName,
136
+ _key: nanoid(),
137
+ _type: 'object',
138
+ fonts: subfamilies[subfamilyName].map(font => ({
139
+ _ref: font._id,
140
+ _key: nanoid(),
141
+ _type: 'reference',
142
+ _weak: true,
143
+ })),
144
+ }));
145
+ };
146
+
147
+ /**
148
+ * Patches ONLY the subfamilies key on the typeface document, preserving fonts and variableFont.
149
+ * This is intentionally scoped — do NOT replace with the shared updateTypefaceDocument util.
150
+ * @param {string} doc_id
151
+ * @param {Object} stylesObject
152
+ * @param {Object[]} newSubfamiliesArray
153
+ * @param {Object} client
154
+ * @param {Function} setStatus
155
+ * @param {Function} setError
156
+ */
157
+ const updateTypefaceSubfamilies = async (doc_id, stylesObject, newSubfamiliesArray, client, setStatus, setError) => {
158
+ const patch = {
159
+ styles: {
160
+ ...stylesObject,
161
+ subfamilies: newSubfamiliesArray,
162
+ },
163
+ };
164
+
165
+ try {
166
+ await client.patch(doc_id).set(patch).commit();
167
+ console.log(`Updated document: ${doc_id}`);
168
+
169
+ if (doc_id.startsWith('drafts.')) {
170
+ const publishedId = doc_id.replace('drafts.', '');
171
+ // Parameterized to guard against any edge-case injection via draft IDs
172
+ const publishedDoc = await client.fetch(`*[_id == $publishedId]`, { publishedId }).then(res => res[0]);
173
+ if (publishedDoc) {
174
+ await client.patch(publishedId).set(patch).commit();
175
+ console.log(`Updated published document: ${publishedId}`);
176
+ } else {
177
+ console.log(`No published document found for ${publishedId}, skipping`);
178
+ }
179
+ }
180
+ } catch (err) {
181
+ console.error('Error updating document:', err.message);
182
+ setStatus('Error regenerating subfamilies');
183
+ setError(true);
184
+ }
185
+ };