@learncard/helpers 1.3.5 → 1.3.7

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,179 @@
1
+ import { getUrlsFromSrcSet } from './images.helpers';
2
+
3
+ /**
4
+ * Creates an array of Filestack URL Parameters
5
+ *
6
+ * @param url Filestack URL
7
+ *
8
+ * @return Filestack URL Parameters
9
+ */
10
+ export const getUrlParams = (url: string): string[] => url.split('.com/')[1].split('/');
11
+
12
+ /**
13
+ * Converts an array of Filestack URL Parameters to a valid Filestack URL
14
+ *
15
+ * @param urlParams Filestack URL Parameters
16
+ *
17
+ * @return Filestack URL
18
+ */
19
+ export const getUrlFromUrlParams = (urlParams: string[]): string =>
20
+ `https://cdn.filestackcontent.com/${urlParams.join('/')}`;
21
+
22
+ /**
23
+ * Gets the handle from a Filestack URL
24
+ *
25
+ * @param url Filestack URL
26
+ *
27
+ * @return Filestack handle
28
+ */
29
+ export const getFilestackHandle = (url: string): string => {
30
+ const urlParams = getUrlParams(url);
31
+
32
+ return urlParams[urlParams.length - 1];
33
+ };
34
+
35
+ export const getFileType = async (url: string): Promise<string> => {
36
+ const handle = getFilestackHandle(url);
37
+
38
+ try {
39
+ const data = (await (
40
+ await fetch(`https://www.filestackapi.com/api/file/${handle}/metadata`)
41
+ ).json()) as { mimetype?: string };
42
+
43
+ return data.mimetype ?? '';
44
+ } catch (error) {
45
+ console.error(error);
46
+
47
+ return '';
48
+ }
49
+ };
50
+
51
+ export const fileTypeSupportsPreview = (fileType: string): boolean => {
52
+ const unsupportedFileTypes = [
53
+ 'application/vnd.apple.pages', // .pages
54
+ 'application/vnd.apple.numbers', // .numbers
55
+ 'application/x-iwork-keynote-sffkey', // .key
56
+ ];
57
+
58
+ return !unsupportedFileTypes.includes(fileType);
59
+ };
60
+
61
+ /**
62
+ * Fixes image rotation based on EXIF data and uses auto_image on a given URL
63
+ *
64
+ * @param url URL to fix
65
+ * @param mimetype MIME type (e.g. image/gif)
66
+ * @param webp flag to indicate whether we should manually specify conversion to webp
67
+ *
68
+ * @return fixed URL
69
+ */
70
+ export const fixUrl = (url: string, mimetype?: string, webp = false): string => {
71
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
72
+ if (url.split(' ').length > 1) return fixSrcSetString(url, mimetype, webp);
73
+
74
+ const urlParams = getUrlParams(url).filter(param => param !== 'rotate=deg:exif');
75
+
76
+ urlParams.splice(0, 0, 'rotate=deg:exif');
77
+
78
+ if (mimetype !== 'image/gif') urlParams.splice(-1, 0, 'auto_image');
79
+ if (webp) urlParams.splice(-1, 0, 'output=format:webp');
80
+
81
+ return getUrlFromUrlParams(urlParams);
82
+ };
83
+
84
+ /**
85
+ * Fixes image rotation based on EXIF data and uses auto_image on a given srcset string
86
+ *
87
+ * @param srcSetString srcset string to fix
88
+ * @param mimetype MIME type (e.g. image/gif)
89
+ * @param webp flag to indicate whether we should manually specify conversion to webp
90
+ *
91
+ * @return fixed srcset string
92
+ */
93
+ export const fixSrcSetString = (srcSetString: string, mimetype?: string, webp = false): string => {
94
+ const urls = getUrlsFromSrcSet(srcSetString);
95
+
96
+ return urls.map(url => `${fixUrl(url[0], mimetype, webp)} ${url[1]}`).join(', ');
97
+ };
98
+
99
+ /**
100
+ * Resizes a filestack image to the given size
101
+ *
102
+ * @param url Filestack URL to resize
103
+ * @param size Target output width
104
+ *
105
+ * @return Filestack URL
106
+ */
107
+ export const resizeUrl = (url: string, size: number): string => {
108
+ const urlParams = getUrlParams(url).filter(param => !param.match(/resize.*/));
109
+
110
+ if (urlParams.includes('rotate=deg:exif')) urlParams.splice(1, 0, `resize=width:${size}`);
111
+ else urlParams.splice(0, 0, `resize=width:${size}`);
112
+
113
+ return getUrlFromUrlParams(urlParams);
114
+ };
115
+
116
+ /**
117
+ * Changes a filestack image's quality to the given value
118
+ *
119
+ * @param url Filestack URL to change
120
+ * @param quality quality value (1-100)
121
+ *
122
+ * @return Filestack URL
123
+ */
124
+ export const changeQuality = (url: string, quality: number): string => {
125
+ const urlParams = getUrlParams(url).filter(param => !param.match(/quality.*/));
126
+
127
+ if (urlParams.includes('rotate=deg:exif')) urlParams.splice(1, 0, `quality=value:${quality}`);
128
+ else urlParams.splice(0, 0, `quality=value:${quality}`);
129
+
130
+ return getUrlFromUrlParams(urlParams);
131
+ };
132
+
133
+ /**
134
+ * Resizes and changes a filestack image's quality
135
+ *
136
+ * @param url Filestack URL to change
137
+ * @param size Target output width
138
+ * @param quality quality value (1-100)
139
+ *
140
+ * @return Filestack URL
141
+ */
142
+ export const resizeAndChangeQuality = (
143
+ url: string,
144
+ size: number,
145
+ quality: number,
146
+ {
147
+ mimetype,
148
+ fix = false,
149
+ webp = false,
150
+ }: { mimetype?: string; fix?: boolean; webp?: boolean } = {}
151
+ ) => {
152
+ const updatedUrl = changeQuality(resizeUrl(url, size), quality);
153
+
154
+ return fix ? fixUrl(updatedUrl, mimetype, webp) : updatedUrl;
155
+ };
156
+
157
+ /**
158
+ * Generates a responsive srcset string from a Filestack URL and array of resolutions
159
+ *
160
+ * @param url Filestack URL
161
+ * @param resolutions list of resolutions
162
+ *
163
+ * @return srcset string
164
+ */
165
+ export const generateSrcSet = (
166
+ url: string,
167
+ resolutions: number[],
168
+ {
169
+ mimetype,
170
+ fix = false,
171
+ webp = false,
172
+ }: { mimetype?: string; fix?: boolean; webp?: boolean } = {}
173
+ ) => {
174
+ const srcSet = resolutions
175
+ .map(resolution => `${resizeUrl(url, resolution)} ${resolution}w`)
176
+ .join(', ');
177
+
178
+ return fix ? fixSrcSetString(srcSet, mimetype, webp) : srcSet;
179
+ };
@@ -0,0 +1,75 @@
1
+ import * as filestack from './filestack.helpers';
2
+ import * as unsplash from './unsplash.helpers';
3
+ import * as discord from './discord.helpers';
4
+
5
+ const Providers = { filestack, unsplash, discord };
6
+
7
+ export const getProvider = (url?: string): keyof typeof Providers | null => {
8
+ if (url?.includes('cdn.filestackcontent.com')) return 'filestack';
9
+
10
+ if (url?.includes('images.unsplash.com')) return 'unsplash';
11
+
12
+ if (url?.includes('cdn.discordapp.com')) return 'discord';
13
+
14
+ return null;
15
+ };
16
+
17
+ export const changeQuality = (url: string, quality: number): string => {
18
+ const provider = getProvider(url);
19
+
20
+ if (!provider) return url;
21
+
22
+ return Providers[provider].changeQuality(url, quality);
23
+ };
24
+
25
+ export const fixUrl = (url: string, mimetype?: string, webp = false): string => {
26
+ const provider = getProvider(url);
27
+
28
+ if (!provider) return url;
29
+
30
+ return Providers[provider].fixUrl(url, mimetype, webp);
31
+ };
32
+
33
+ export const generateSrcSet = (
34
+ url: string,
35
+ resolutions: number[],
36
+ options: { mimetype?: string; fix?: boolean; webp?: boolean } = {}
37
+ ): string => {
38
+ const provider = getProvider(url);
39
+
40
+ if (!provider) return url;
41
+
42
+ return Providers[provider].generateSrcSet(url, resolutions, options);
43
+ };
44
+
45
+ export const resizeAndChangeQuality = (
46
+ url: string,
47
+ size: number,
48
+ quality: number,
49
+ options: { mimetype?: string; fix?: boolean; webp?: boolean } = {}
50
+ ): string => {
51
+ const provider = getProvider(url);
52
+
53
+ if (!provider) return url;
54
+
55
+ return Providers[provider].resizeAndChangeQuality(url, size, quality, options);
56
+ };
57
+
58
+ export const DEFAULT_RESOLUTIONS = [200, 400, 600];
59
+
60
+ /**
61
+ * Gets an array of URLs from a srcSet string
62
+ *
63
+ * @param srcSet HTML srcSet string (e.g. 'test.com/img 100w, test.com/img2 200w')
64
+ *
65
+ * @return URLs
66
+ */
67
+ export const getUrlsFromSrcSet = (srcSet: string): [string, string][] => {
68
+ return srcSet.split(/,? /).reduce<[string, string][]>((accumulator, current, index, array) => {
69
+ if (index % 2 === 0) return accumulator;
70
+
71
+ accumulator.push([array[index - 1], current]);
72
+
73
+ return accumulator;
74
+ }, []);
75
+ };
@@ -0,0 +1 @@
1
+ export * from './images.helpers';
@@ -0,0 +1,110 @@
1
+ import { getUrlsFromSrcSet } from './images.helpers';
2
+ import { parseUrl, stringifyUrl } from './url.helpers';
3
+
4
+ /**
5
+ * Fixes image rotation based on EXIF data and uses auto_image on a given srcset string
6
+ *
7
+ * @param srcSetString srcset string to fix
8
+ * @param mimetype MIME type (e.g. image/gif)
9
+ * @param webp flag to indicate whether we should manually specify conversion to webp
10
+ *
11
+ * @return fixed srcset string
12
+ */
13
+ export const fixSrcSetString = (
14
+ srcSetString: string,
15
+ _mimetype?: string,
16
+ _webp = false
17
+ ): string => {
18
+ const urls = getUrlsFromSrcSet(srcSetString);
19
+
20
+ return urls.map(([url, width]) => `${fixUrl(url)} ${width}`).join(', ');
21
+ };
22
+
23
+ /**
24
+ * Returns the best format on a given URL
25
+ *
26
+ * @param url URL to fix
27
+ *
28
+ * @return fixed URL
29
+ */
30
+ export const fixUrl = (url: string, _mimetype?: string, _webp = false): string => {
31
+ if (url.split(' ').length > 1) return fixSrcSetString(url);
32
+
33
+ const parsedUrl = parseUrl(url);
34
+
35
+ parsedUrl.query.auto = 'format';
36
+
37
+ return stringifyUrl(parsedUrl);
38
+ };
39
+
40
+ /**
41
+ * Resizes an unsplash image to the given size
42
+ *
43
+ * @param url Unsplash URL to resize
44
+ * @param size Target output width
45
+ *
46
+ * @return Unsplash URL
47
+ */
48
+ export const resizeUrl = (url: string, size: number): string => {
49
+ const parsedUrl = parseUrl(url);
50
+
51
+ parsedUrl.query.w = size.toString();
52
+
53
+ return stringifyUrl(parsedUrl);
54
+ };
55
+
56
+ /**
57
+ * Changes an unsplash image's quality to the given value
58
+ *
59
+ * @param url Unsplash URL to change
60
+ * @param quality quality value (1-100)
61
+ *
62
+ * @return Unsplash URL
63
+ */
64
+ export const changeQuality = (url: string, quality: number): string => {
65
+ const parsedUrl = parseUrl(url);
66
+
67
+ parsedUrl.query.q = quality.toString();
68
+
69
+ return stringifyUrl(parsedUrl);
70
+ };
71
+
72
+ /**
73
+ * Resizes and changes an unsplash image's quality
74
+ *
75
+ * @param url Unsplash URL to change
76
+ * @param size Target output width
77
+ * @param quality quality value (1-100)
78
+ *
79
+ * @return Unsplash URL
80
+ */
81
+ export const resizeAndChangeQuality = (
82
+ url: string,
83
+ size: number,
84
+ quality: number,
85
+ { fix = false }: { mimetype?: string; fix?: boolean; webp?: boolean } = {}
86
+ ): string => {
87
+ const updatedUrl = changeQuality(resizeUrl(url, size), quality);
88
+
89
+ return fix ? fixUrl(updatedUrl) : updatedUrl;
90
+ };
91
+
92
+ /**
93
+ * Generates a responsive srcset string from an Unsplash URL and array of resolutions
94
+ *
95
+ * @param url Unsplash URL
96
+ * @param resolutions list of resolutions
97
+ *
98
+ * @return srcset string
99
+ */
100
+ export const generateSrcSet = (
101
+ url: string,
102
+ resolutions: number[],
103
+ { fix = false }: { mimetype?: string; fix?: boolean; webp?: boolean } = {}
104
+ ): string => {
105
+ const srcSet = resolutions
106
+ .map(resolution => `${resizeUrl(url, resolution)} ${resolution}w`)
107
+ .join(', ');
108
+
109
+ return fix ? fixSrcSetString(srcSet) : srcSet;
110
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Minimal URL query helpers backed by `URLSearchParams`, replacing the two
3
+ * `query-string` call sites in the image helpers. `query-string@9` is ESM-only
4
+ * and pulls in ESM-only transitive deps, which broke the CommonJS build of
5
+ * `@learncard/helpers` for external consumers on Node 18.
6
+ */
7
+
8
+ export type ParsedUrl = {
9
+ url: string;
10
+ query: Record<string, string>;
11
+ fragmentIdentifier?: string;
12
+ };
13
+
14
+ export const parseUrl = (input: string): ParsedUrl => {
15
+ const hashIndex = input.indexOf('#');
16
+ const fragmentIdentifier = hashIndex === -1 ? undefined : input.slice(hashIndex + 1);
17
+ const withoutFragment = hashIndex === -1 ? input : input.slice(0, hashIndex);
18
+
19
+ const queryIndex = withoutFragment.indexOf('?');
20
+ const url = queryIndex === -1 ? withoutFragment : withoutFragment.slice(0, queryIndex);
21
+ const search = queryIndex === -1 ? '' : withoutFragment.slice(queryIndex + 1);
22
+
23
+ const query: Record<string, string> = {};
24
+ for (const [key, value] of new URLSearchParams(search)) query[key] = value;
25
+
26
+ return {
27
+ url,
28
+ query,
29
+ ...(fragmentIdentifier !== undefined ? { fragmentIdentifier } : {}),
30
+ };
31
+ };
32
+
33
+ export const stringifyUrl = (parsed: ParsedUrl): string => {
34
+ const params = new URLSearchParams();
35
+ for (const [key, value] of Object.entries(parsed.query)) {
36
+ if (value === undefined || value === null) continue;
37
+ params.append(key, String(value));
38
+ }
39
+
40
+ // URLSearchParams encodes spaces as `+`; normalize to `%20` so output stays
41
+ // byte-stable for consumers that use the URL as a cache key.
42
+ const search = params.toString().replace(/\+/g, '%20');
43
+ const fragment = parsed.fragmentIdentifier ? `#${parsed.fragmentIdentifier}` : '';
44
+
45
+ return `${parsed.url}${search ? `?${search}` : ''}${fragment}`;
46
+ };
package/src/index.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { JWE, JWEValidator, UnsignedVC, VC } from '@learncard/types';
2
+ import type { DataTransformer } from '@trpc/server';
3
+
4
+ /**
5
+ * Determines whether or not a string is a valid hexadecimal string
6
+ *
7
+ * E.g. 'abc123' is valid hex, 'zzz' is not
8
+ */
9
+ export const isHex = (str: string) => /^[0-9a-f]+$/i.test(str);
10
+
11
+ /** Determines whether or not an object is an encrypted JWE */
12
+ export const isEncrypted = (item: Record<string, any>): item is JWE => {
13
+ return JWEValidator.safeParse(item).success;
14
+ };
15
+
16
+ /**
17
+ * tRPC data transformer that handles RegExp serialization/deserialization
18
+ */
19
+ export const RegExpTransformer: DataTransformer = {
20
+ serialize(object: any): any {
21
+ return JSON.stringify(object, (_key, value) => {
22
+ if (value instanceof RegExp) return value.toString(); // Converts to format /pattern/flags
23
+
24
+ return value;
25
+ });
26
+ },
27
+
28
+ deserialize(object: any): any {
29
+ // Old clients will for some reason already be deserialized, so this checks for that to retain
30
+ // backwards compat
31
+ if (typeof object !== 'string') return object;
32
+
33
+ return JSON.parse(object, (_key, value) => {
34
+ if (typeof value === 'string') {
35
+ const match = value.match(/^\/(.*)\/([gimsuy]*)$/);
36
+
37
+ if (match) {
38
+ try {
39
+ return new RegExp(match[1], match[2]);
40
+ } catch (error) {
41
+ console.warn(`Failed to parse RegExp: ${error}`);
42
+ return value;
43
+ }
44
+ }
45
+ }
46
+ return value;
47
+ });
48
+ },
49
+ };
50
+
51
+ /**
52
+ * Determines if a credential uses VC 2.0 format by checking the context array
53
+ */
54
+ export const isVC2Format = (credential: UnsignedVC | VC): boolean => {
55
+ if (!credential['@context'] || !Array.isArray(credential['@context'])) {
56
+ return false;
57
+ }
58
+
59
+ return credential['@context'].some(
60
+ context => context === 'https://www.w3.org/ns/credentials/v2'
61
+ );
62
+ };
63
+
64
+ /** Unwraps a boost credential from a CertifiedBoostCredential, if it is one */
65
+ export const unwrapBoostCredential = (vc?: VC | UnsignedVC) => {
66
+ if (vc?.type?.includes('CertifiedBoostCredential') && vc?.boostCredential) {
67
+ return vc.boostCredential;
68
+ } else {
69
+ return vc;
70
+ }
71
+ };
72
+
73
+ // Export helpers from shared-helpers migration
74
+ export * from './images';
75
+ export * from './arrays';
76
+ export * from './types';
77
+ export * from './strings';
78
+ export * from './numbers';
79
+ export * from './state';
80
+ export * from './bitstring-status-list';
81
+
82
+ // Export utilities from shared-types migration
83
+ export * from './Utilities';
84
+
85
+ // Export app install helpers
86
+ export * from './app-install';
87
+ export * from './credential-format';
88
+
89
+ // ADR-0001 Phase 1: format-tagged credential storage projector
90
+ export * from './credential-format';
91
+
92
+ /**
93
+ * Checks if a DID is an app-specific did:web
94
+ *
95
+ * App did:webs follow the pattern: `did:web:learncard.app:app:&lt;slug&gt;`
96
+ *
97
+ * @param did - The DID to check
98
+ * @returns true if the DID is an app did:web, false otherwise
99
+ */
100
+ export const isAppDidWeb = (did?: string): boolean => {
101
+ if (!did) return false;
102
+ // Matches did:web:<slug>:app:<slug> pattern
103
+ const LCN_APP_DID_WEB_REGEX = /^did:web:.*:app:([^:]+)$/;
104
+ return LCN_APP_DID_WEB_REGEX.test(did);
105
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Formats a number into a friendlier string i.e. 1,234 becomes 1.2K
3
+ */
4
+ export const formatNumber = (num: number): string => {
5
+ const abbreviations = [
6
+ { value: 1e9, symbol: 'B' },
7
+ { value: 1e6, symbol: 'M' },
8
+ { value: 1e3, symbol: 'K' }
9
+ ];
10
+
11
+ for (const { value, symbol } of abbreviations) {
12
+ if (num >= value) {
13
+ // Round to 1 decimal place and remove trailing zero
14
+ const result = (Math.floor(num / (value / 10)) / 10).toFixed(1);
15
+ return result.replace(/\.0$/, '') + symbol;
16
+ }
17
+ }
18
+
19
+ // For numbers less than 1000, return as is, but without decimal places
20
+ return Math.floor(num).toString();
21
+ }