@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,508 @@
1
+ import { sha256 } from '@noble/hashes/sha2';
2
+ import { decodeSdJwtSync, getClaimsSync } from '@sd-jwt/decode';
3
+ import type {
4
+ CredentialRecord,
5
+ StoredCredential,
6
+ StoredCredentialEnvelope,
7
+ VC,
8
+ } from '@learncard/types';
9
+ import { isStoredCredentialEnvelope } from '@learncard/types';
10
+
11
+ const SD_JWT_RESERVED_CLAIMS = new Set([
12
+ 'iss',
13
+ 'iat',
14
+ 'exp',
15
+ 'nbf',
16
+ 'sub',
17
+ 'vct',
18
+ 'cnf',
19
+ '_sd_alg',
20
+ '_sd',
21
+ '...',
22
+ 'status',
23
+ ]);
24
+
25
+ const SD_JWT_FALLBACK_ISSUED_AT_ISO = '1970-01-01T00:00:00.000Z';
26
+
27
+ const SUPPORTED_SD_JWT_HASH_ALGS = new Set(['sha-256', 'SHA-256', 'sha256']);
28
+
29
+ const sha256HasherSync = (data: string | ArrayBuffer, alg: string): Uint8Array => {
30
+ if (!SUPPORTED_SD_JWT_HASH_ALGS.has(alg)) {
31
+ throw new Error(`Unsupported hash algorithm: "${alg}". Only sha-256 is supported.`);
32
+ }
33
+
34
+ const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data);
35
+ return sha256(bytes);
36
+ };
37
+
38
+ /**
39
+ * Project a `CredentialRecord` into a format-discriminated read view.
40
+ *
41
+ * Two paths into the same output:
42
+ *
43
+ * 1. **Explicit format**: if the record carries an explicit `format`
44
+ * discriminator (and `rawWireForm` where required), the projector
45
+ * trusts it. This is the path new format-aware writers take.
46
+ *
47
+ * 2. **Inferred from shape** (legacy fallback): for records that
48
+ * pre-date format-tagging, the projector inspects `record.vc` and
49
+ * infers the format. W3C VCs → `w3c-vc-2.0` / `w3c-vc-1.1` based
50
+ * on `@context`; transitional SD-JWT wrappers (which never shipped
51
+ * but exist on branch `lc-1796-3`) → `dc+sd-jwt` with the compact
52
+ * extracted from `proof.jwt`; legacy JWT-VC envelopes → `jwt-vc-json`.
53
+ *
54
+ * Returns a `StoredCredential` for every conceivable record. Never
55
+ * throws — credentials with unrecognizable shape fall back to
56
+ * `w3c-vc-1.1` with `data = record.vc` so legacy consumers keep
57
+ * something they can read.
58
+ *
59
+ * This projector is the SAFETY NET that makes the format-tagging
60
+ * migration non-breaking: writers can adopt format-tagging at their
61
+ * own pace; readers using this projector see the right discriminator
62
+ * either way.
63
+ */
64
+ export const toStoredCredential = (record: CredentialRecord): StoredCredential => {
65
+ if (record.format) {
66
+ if (
67
+ record.format === 'dc+sd-jwt' ||
68
+ record.format === 'vc+sd-jwt' ||
69
+ record.format === 'jwt-vc-json' ||
70
+ record.format === 'mso_mdoc'
71
+ ) {
72
+ const wireForm = record.rawWireForm ?? extractWireFormFromVc(record.vc);
73
+ if (typeof wireForm === 'string' && wireForm.length > 0) {
74
+ return { format: record.format, data: wireForm };
75
+ }
76
+ }
77
+
78
+ if (record.format === 'w3c-vc-2.0' || record.format === 'w3c-vc-1.1') {
79
+ return { format: record.format, data: record.vc as VC };
80
+ }
81
+ }
82
+
83
+ const vc = record.vc as unknown;
84
+
85
+ if (typeof vc === 'string') {
86
+ if (looksLikeSdJwtCompact(vc)) {
87
+ return { format: 'dc+sd-jwt', data: vc };
88
+ }
89
+ if (looksLikeJwsCompact(vc)) {
90
+ return { format: 'jwt-vc-json', data: vc };
91
+ }
92
+ }
93
+
94
+ if (vc && typeof vc === 'object') {
95
+ const proof = getWireFormProof(vc, true);
96
+ const wireFromProof = getWireFormFromProof(proof);
97
+
98
+ if (wireFromProof) {
99
+ const proofType = proof?.type;
100
+
101
+ if (proofType === 'SdJwtCompactProof') {
102
+ return { format: 'dc+sd-jwt', data: wireFromProof };
103
+ }
104
+
105
+ if (proofType === 'JwtProof2020') {
106
+ return { format: 'jwt-vc-json', data: wireFromProof };
107
+ }
108
+ }
109
+
110
+ const inferred = inferW3cVersionFromContext(vc);
111
+ return { format: inferred, data: vc as VC };
112
+ }
113
+
114
+ return { format: 'w3c-vc-1.1', data: vc as VC };
115
+ };
116
+
117
+ const SD_JWT_COMPACT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+~/;
118
+ const JWS_COMPACT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
119
+
120
+ const looksLikeSdJwtCompact = (value: string): boolean => SD_JWT_COMPACT_RE.test(value);
121
+ const looksLikeJwsCompact = (value: string): boolean => JWS_COMPACT_RE.test(value);
122
+
123
+ type WireFormProof = { type?: unknown; jwt?: unknown };
124
+
125
+ const getWireFormProof = (vc: unknown, requireSupportedType = false): WireFormProof | undefined => {
126
+ if (!vc || typeof vc !== 'object') return undefined;
127
+
128
+ const proof = (vc as { proof?: unknown }).proof;
129
+
130
+ if (Array.isArray(proof)) {
131
+ for (const entry of proof) {
132
+ const proofObject = asWireFormProof(entry);
133
+
134
+ if (proofObject && isUsableWireFormProof(proofObject, requireSupportedType)) {
135
+ return proofObject;
136
+ }
137
+ }
138
+
139
+ return undefined;
140
+ }
141
+
142
+ const proofObject = asWireFormProof(proof);
143
+
144
+ return proofObject && isUsableWireFormProof(proofObject, requireSupportedType)
145
+ ? proofObject
146
+ : undefined;
147
+ };
148
+
149
+ const asWireFormProof = (proof: unknown): WireFormProof | undefined =>
150
+ proof && typeof proof === 'object' ? (proof as WireFormProof) : undefined;
151
+
152
+ const isUsableWireFormProof = (proof: WireFormProof, requireSupportedType: boolean): boolean =>
153
+ typeof proof.jwt === 'string' &&
154
+ proof.jwt.length > 0 &&
155
+ (!requireSupportedType || proof.type === 'SdJwtCompactProof' || proof.type === 'JwtProof2020');
156
+
157
+ const getWireFormFromProof = (proof: WireFormProof | undefined): string | undefined =>
158
+ typeof proof?.jwt === 'string' && proof.jwt.length > 0 ? proof.jwt : undefined;
159
+
160
+ const extractWireFormFromVc = (vc: unknown): string | undefined =>
161
+ getWireFormFromProof(getWireFormProof(vc));
162
+
163
+ /**
164
+ * Extract the meaningful identifying segment from an SD-JWT-VC `vct` claim
165
+ * so it can be projected onto the W3C-VC `type` array and `name` field.
166
+ *
167
+ * Returns `undefined` for vct values where no meaningful name can be
168
+ * derived (numeric-only URN tails, empty string). Shared between the
169
+ * receipt-time wrapper synthesizer and the read-time display projector
170
+ * so SD-JWT credentials produce the same wrapper shape on both paths.
171
+ */
172
+ export const extractVctSegment = (vct: string): string | undefined => {
173
+ let segment: string | undefined;
174
+
175
+ try {
176
+ const url = new URL(vct);
177
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
178
+ const parts = url.pathname.split('/').filter(Boolean);
179
+ if (parts.length > 0) segment = parts[parts.length - 1]!;
180
+ }
181
+ } catch {
182
+ // Not URL-shaped — fall through to URN / plain-string handling.
183
+ }
184
+
185
+ if (segment === undefined) {
186
+ if (vct.includes(':')) {
187
+ const parts = vct.split(':').filter(Boolean);
188
+ const last = parts[parts.length - 1];
189
+ // Skip purely numeric trailing segments (e.g., URN version tags like
190
+ // "urn:eudi:pid:1") — the semantically meaningful name is one up.
191
+ if (last && !/^\d+$/.test(last)) {
192
+ segment = last;
193
+ } else if (parts.length >= 2) {
194
+ segment = parts[parts.length - 2];
195
+ }
196
+ } else {
197
+ segment = vct;
198
+ }
199
+ }
200
+
201
+ return segment && segment.length > 0 ? segment : undefined;
202
+ };
203
+
204
+ /**
205
+ * PascalCase identifier suitable for the W3C-VC `type` array. Picks acronyms
206
+ * for short single-word lowercase segments ("pid" → "PID") and otherwise
207
+ * concatenates capitalized words ("playground-credential" → "PlaygroundCredential").
208
+ */
209
+ export const deriveTypeFromVct = (vct: string | undefined): string | undefined => {
210
+ if (!vct || typeof vct !== 'string') return undefined;
211
+ const segment = extractVctSegment(vct);
212
+ if (!segment) return undefined;
213
+
214
+ const normalized = segment
215
+ .replace(/[-_.]+/g, ' ')
216
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
217
+ .trim();
218
+ const words = normalized.split(/\s+/).filter(Boolean);
219
+ if (words.length === 0) return undefined;
220
+
221
+ if (words.length === 1 && words[0]!.length <= 4 && words[0] === words[0]!.toLowerCase()) {
222
+ return words[0]!.toUpperCase();
223
+ }
224
+ return words.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
225
+ };
226
+
227
+ /**
228
+ * Human-readable display name for the `name` field. Same input handling
229
+ * as {@link deriveTypeFromVct} but keeps spaces between words so it reads
230
+ * naturally in UIs ("Playground Credential" not "PlaygroundCredential").
231
+ */
232
+ export const deriveNameFromVct = (vct: string | undefined): string | undefined => {
233
+ if (!vct || typeof vct !== 'string') return undefined;
234
+ const segment = extractVctSegment(vct);
235
+ if (!segment) return undefined;
236
+
237
+ const normalized = segment
238
+ .replace(/[-_.]+/g, ' ')
239
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
240
+ .trim();
241
+ const words = normalized.split(/\s+/).filter(Boolean);
242
+ if (words.length === 0) return undefined;
243
+
244
+ if (words.length === 1 && words[0]!.length <= 4 && words[0] === words[0]!.toLowerCase()) {
245
+ return words[0]!.toUpperCase();
246
+ }
247
+ return words.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
248
+ };
249
+
250
+ export const stripSdJwtReservedClaims = (
251
+ claims: Record<string, unknown>
252
+ ): Record<string, unknown> => {
253
+ const out: Record<string, unknown> = {};
254
+ for (const [key, value] of Object.entries(claims)) {
255
+ if (!SD_JWT_RESERVED_CLAIMS.has(key)) out[key] = value;
256
+ }
257
+ return out;
258
+ };
259
+
260
+ const PRIVATE_JWK_FIELDS = new Set(['d', 'dp', 'dq', 'p', 'q', 'qi', 'oth', 'k']);
261
+
262
+ const bytesToBase64Url = (bytes: Uint8Array): string => {
263
+ let binary = '';
264
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
265
+ const base64 =
266
+ typeof btoa === 'function'
267
+ ? btoa(binary)
268
+ : Buffer.from(binary, 'binary').toString('base64');
269
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
270
+ };
271
+
272
+ export const synthesizeDidJwk = (jwk: Record<string, unknown>): string | undefined => {
273
+ if (typeof jwk.kty !== 'string') return undefined;
274
+
275
+ const publicOnly: Record<string, unknown> = {};
276
+ for (const [key, value] of Object.entries(jwk)) {
277
+ if (!PRIVATE_JWK_FIELDS.has(key)) publicOnly[key] = value;
278
+ }
279
+
280
+ try {
281
+ const sortedKeys = Object.keys(publicOnly).sort();
282
+ const canonical: Record<string, unknown> = {};
283
+ for (const key of sortedKeys) canonical[key] = publicOnly[key];
284
+ return `did:jwk:${bytesToBase64Url(new TextEncoder().encode(JSON.stringify(canonical)))}`;
285
+ } catch {
286
+ return undefined;
287
+ }
288
+ };
289
+
290
+ export const deriveSdJwtIssuedAtIso = (options: {
291
+ issuedAt?: Date;
292
+ notBefore?: Date;
293
+ iat?: number;
294
+ nbf?: number;
295
+ }): string => {
296
+ if (options.issuedAt) return options.issuedAt.toISOString();
297
+ if (options.notBefore) return options.notBefore.toISOString();
298
+ if (typeof options.iat === 'number') return new Date(options.iat * 1000).toISOString();
299
+ if (typeof options.nbf === 'number') return new Date(options.nbf * 1000).toISOString();
300
+ return SD_JWT_FALLBACK_ISSUED_AT_ISO;
301
+ };
302
+
303
+ const deriveVerificationMethodFromIssuer = (options: {
304
+ issuer: string;
305
+ headerKid?: unknown;
306
+ }): string | undefined => {
307
+ if (!options.issuer.startsWith('did:') || typeof options.headerKid !== 'string') {
308
+ return undefined;
309
+ }
310
+
311
+ return options.headerKid.startsWith('#')
312
+ ? `${options.issuer}${options.headerKid}`
313
+ : options.headerKid;
314
+ };
315
+
316
+ /**
317
+ * Read-path shim: when a storage plugin returns a `StoredCredentialEnvelope` (because we
318
+ * wrote a native non-W3C format like SD-JWT-VC), legacy UI components
319
+ * that read `.credentialSubject` / `.issuer` / `.proof` need a VC-shaped
320
+ * object. This projector synthesizes a minimal display-only VC from the
321
+ * envelope so existing consumers keep working without per-component
322
+ * format awareness.
323
+ *
324
+ * For SD-JWT-VC: decodes the unsigned JWS payload (signature is NOT
325
+ * verified here — verification belongs to the dedicated verify chain),
326
+ * surfaces the disclosed claims under `credentialSubject`, preserves
327
+ * the compact form under `proof.jwt` and `proof.type = 'SdJwtCompactProof'`
328
+ * so anything that already understands the SD-JWT-Compact-Proof shape
329
+ * keeps working.
330
+ *
331
+ * For JWT-VC: decodes the JWS payload, returns its `vc` claim.
332
+ *
333
+ * For mDoc / unknown formats: returns `undefined` — the caller MUST
334
+ * branch on format (`record.format === 'mso_mdoc'`) and use a
335
+ * format-specific display path. We don't fabricate a fake VC for
336
+ * binary credentials.
337
+ *
338
+ * This projector is intentionally LOSSY for SD-JWT — it produces a
339
+ * display-only view, NOT a re-issuable credential. Egress paths
340
+ * (sharing, presenting, re-uploading) MUST go through
341
+ * `serializeForWire(stored)` (Phase 3) to recover the canonical
342
+ * compact form from `envelope.data`. NEVER re-serialize from the
343
+ * projected VC.
344
+ */
345
+ export const projectEnvelopeToDisplayVc = (envelope: StoredCredentialEnvelope): VC | undefined => {
346
+ if (envelope.format === 'mso_mdoc') return undefined;
347
+
348
+ const data = envelope.data;
349
+ if (typeof data !== 'string' || data.length === 0) return undefined;
350
+
351
+ if (envelope.format === 'dc+sd-jwt' || envelope.format === 'vc+sd-jwt') {
352
+ return projectSdJwtCompactToVc(data);
353
+ }
354
+
355
+ if (envelope.format === 'jwt-vc-json') {
356
+ return projectJwtVcCompactToVc(data);
357
+ }
358
+
359
+ return undefined;
360
+ };
361
+
362
+ const decodeJwsPayload = (compact: string): Record<string, unknown> | undefined => {
363
+ const firstTilde = compact.indexOf('~');
364
+ const jws = firstTilde >= 0 ? compact.slice(0, firstTilde) : compact;
365
+ const parts = jws.split('.');
366
+ if (parts.length !== 3) return undefined;
367
+ try {
368
+ const payloadJson = base64UrlDecodeToString(parts[1]!);
369
+ const parsed = JSON.parse(payloadJson) as Record<string, unknown>;
370
+ return parsed && typeof parsed === 'object' ? parsed : undefined;
371
+ } catch {
372
+ return undefined;
373
+ }
374
+ };
375
+
376
+ const base64UrlDecodeToString = (b64url: string): string => {
377
+ const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
378
+ const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
379
+ if (typeof atob === 'function') return decodeURIComponent(escape(atob(padded)));
380
+ return Buffer.from(padded, 'base64').toString('utf-8');
381
+ };
382
+
383
+ const projectSdJwtCompactToVc = (compact: string): VC | undefined => {
384
+ let decoded;
385
+ try {
386
+ decoded = decodeSdJwtSync(compact, sha256HasherSync);
387
+ } catch {
388
+ return undefined;
389
+ }
390
+
391
+ const payload = decoded.jwt?.payload;
392
+ if (!payload || typeof payload !== 'object') return undefined;
393
+
394
+ const issuer = typeof payload.iss === 'string' ? payload.iss : 'unknown';
395
+ const issuedAtIso = deriveSdJwtIssuedAtIso({
396
+ iat: typeof payload.iat === 'number' ? payload.iat : undefined,
397
+ nbf: typeof payload.nbf === 'number' ? payload.nbf : undefined,
398
+ });
399
+ const expiresAtSec = typeof payload.exp === 'number' ? payload.exp : undefined;
400
+
401
+ let reconstructedClaims = payload as Record<string, unknown>;
402
+ try {
403
+ reconstructedClaims = getClaimsSync<Record<string, unknown>>(
404
+ payload as Record<string, unknown>,
405
+ decoded.disclosures ?? [],
406
+ sha256HasherSync
407
+ );
408
+ } catch {
409
+ // Fail closed on disclosures: keep only issuer-payload claims. A disclosure
410
+ // whose digest doesn't match MUST NOT appear in the projected subject.
411
+ }
412
+
413
+ const credentialSubject: Record<string, unknown> =
414
+ stripSdJwtReservedClaims(reconstructedClaims);
415
+ const cnf = payload.cnf;
416
+ const holderPublicKey =
417
+ cnf && typeof cnf === 'object' && 'jwk' in cnf && cnf.jwk && typeof cnf.jwk === 'object'
418
+ ? (cnf.jwk as Record<string, unknown>)
419
+ : undefined;
420
+ const holderDid = holderPublicKey ? synthesizeDidJwk(holderPublicKey) : undefined;
421
+ if (holderDid) credentialSubject.id = holderDid;
422
+
423
+ const vct = typeof payload.vct === 'string' ? payload.vct : undefined;
424
+
425
+ const derivedType = deriveTypeFromVct(vct);
426
+ const derivedName = deriveNameFromVct(vct);
427
+ const typeArray = ['VerifiableCredential', 'SdJwtVcCredential'];
428
+ if (derivedType && derivedType !== 'SdJwtVcCredential') typeArray.push(derivedType);
429
+
430
+ const verificationMethod = deriveVerificationMethodFromIssuer({
431
+ issuer,
432
+ headerKid: decoded.jwt?.header?.kid,
433
+ });
434
+
435
+ const proof: Record<string, unknown> = {
436
+ type: 'SdJwtCompactProof',
437
+ created: issuedAtIso,
438
+ proofPurpose: 'assertionMethod',
439
+ jwt: compact,
440
+ };
441
+ if (verificationMethod) proof.verificationMethod = verificationMethod;
442
+
443
+ const vc: VC = {
444
+ '@context': ['https://www.w3.org/ns/credentials/v2' as string],
445
+ type: typeArray,
446
+ issuer,
447
+ validFrom: issuedAtIso,
448
+ credentialSubject,
449
+ proof: proof as VC['proof'],
450
+ };
451
+
452
+ if (vct) (vc as Record<string, unknown>).sdJwtVct = vct;
453
+ if (derivedName) (vc as Record<string, unknown>).name = derivedName;
454
+ if (expiresAtSec) {
455
+ (vc as Record<string, unknown>).validUntil = new Date(expiresAtSec * 1000).toISOString();
456
+ }
457
+
458
+ return vc;
459
+ };
460
+
461
+ const projectJwtVcCompactToVc = (compact: string): VC | undefined => {
462
+ const payload = decodeJwsPayload(compact);
463
+ if (!payload) return undefined;
464
+ const vcClaim = payload.vc;
465
+ if (!vcClaim || typeof vcClaim !== 'object') return undefined;
466
+
467
+ // Preserve the compact JWS under a JwtProof2020 proof.jwt so the
468
+ // projected VC stays verifiable and VC-validator compatible, mirroring
469
+ // normalizeIssuedCredential. Returning only payload.vc drops the issuer
470
+ // signature and yields a proofless object downstream consumers reject.
471
+ const vc = { ...(vcClaim as Record<string, unknown>) } as Record<string, unknown>;
472
+ if (!vc.proof) {
473
+ vc.proof = {
474
+ type: 'JwtProof2020',
475
+ jwt: compact,
476
+ };
477
+ }
478
+ return vc as VC;
479
+ };
480
+
481
+ /**
482
+ * Convenience: detect an envelope on the wire and return a legacy VC
483
+ * shape suitable for downstream W3C-only consumers. Returns the input
484
+ * unchanged if it's not an envelope (so callers can wrap any
485
+ * `read.get` result without branching themselves).
486
+ */
487
+ export const resolveStorageReadResult = (
488
+ value: VC | StoredCredentialEnvelope | undefined
489
+ ): VC | StoredCredentialEnvelope | undefined => {
490
+ if (!value) return value;
491
+ if (!isStoredCredentialEnvelope(value)) return value;
492
+ const projected = projectEnvelopeToDisplayVc(value);
493
+ return projected ?? value;
494
+ };
495
+
496
+ const inferW3cVersionFromContext = (vc: unknown): 'w3c-vc-2.0' | 'w3c-vc-1.1' => {
497
+ if (!vc || typeof vc !== 'object') return 'w3c-vc-1.1';
498
+ const contextRaw = (vc as { '@context'?: unknown })['@context'];
499
+ const contexts = Array.isArray(contextRaw)
500
+ ? contextRaw
501
+ : contextRaw !== undefined
502
+ ? [contextRaw]
503
+ : [];
504
+ const isV2 = contexts.some(
505
+ c => typeof c === 'string' && c.includes('w3.org/ns/credentials/v2')
506
+ );
507
+ return isV2 ? 'w3c-vc-2.0' : 'w3c-vc-1.1';
508
+ };
@@ -0,0 +1,103 @@
1
+ import { quantizeValue } from '../arrays';
2
+ import { getUrlsFromSrcSet } from './images.helpers';
3
+ import { parseUrl, stringifyUrl } from './url.helpers';
4
+
5
+ /**
6
+ * Fixes image rotation based on EXIF data and uses auto_image on a given srcset string
7
+ *
8
+ * @param srcSetString srcset string to fix
9
+ * @param mimetype MIME type (e.g. image/gif)
10
+ * @param webp flag to indicate whether we should manually specify conversion to webp
11
+ *
12
+ * @return fixed srcset string
13
+ */
14
+ export const fixSrcSetString = (
15
+ srcSetString: string,
16
+ _mimetype?: string,
17
+ _webp = false
18
+ ): string => {
19
+ const urls = getUrlsFromSrcSet(srcSetString);
20
+
21
+ return urls.map(([url, width]) => `${fixUrl(url)} ${width}`).join(', ');
22
+ };
23
+
24
+ /**
25
+ * Returns the best format on a given URL
26
+ *
27
+ * @param url URL to fix
28
+ *
29
+ * @return fixed URL
30
+ */
31
+ export const fixUrl = (url: string, _mimetype?: string, _webp = false): string => {
32
+ return url;
33
+ };
34
+
35
+ export const VALID_DISCORD_SIZES = [20, 32, 40, 60, 64, 80, 100, 128, 256, 512, 1024, 2048, 4096];
36
+
37
+ /**
38
+ * Resizes an Discord image to the given size
39
+ *
40
+ * @param url Discord URL to resize
41
+ * @param size Target output width
42
+ *
43
+ * @return Discord URL
44
+ */
45
+ export const resizeUrl = (url: string, size: number): string => {
46
+ const parsedUrl = parseUrl(url);
47
+
48
+ parsedUrl.query.size = quantizeValue(size, VALID_DISCORD_SIZES).toString();
49
+
50
+ return stringifyUrl(parsedUrl);
51
+ };
52
+
53
+ /**
54
+ * Changes an Discord image's quality to the given value
55
+ *
56
+ * @param url Discord URL to change
57
+ * @param quality quality value (1-100)
58
+ *
59
+ * @return Discord URL
60
+ */
61
+ export const changeQuality = (url: string, _quality: number): string => {
62
+ return url;
63
+ };
64
+
65
+ /**
66
+ * Resizes and changes an Discord image's quality
67
+ *
68
+ * @param url Discord URL to change
69
+ * @param size Target output width
70
+ * @param quality quality value (1-100)
71
+ *
72
+ * @return Discord URL
73
+ */
74
+ export const resizeAndChangeQuality = (
75
+ url: string,
76
+ size: number,
77
+ quality: number,
78
+ { fix = false }: { mimetype?: string; fix?: boolean; webp?: boolean } = {}
79
+ ): string => {
80
+ const updatedUrl = changeQuality(resizeUrl(url, size), quality);
81
+
82
+ return fix ? fixUrl(updatedUrl) : updatedUrl;
83
+ };
84
+
85
+ /**
86
+ * Generates a responsive srcset string from an Discord URL and array of resolutions
87
+ *
88
+ * @param url Discord URL
89
+ * @param resolutions list of resolutions
90
+ *
91
+ * @return srcset string
92
+ */
93
+ export const generateSrcSet = (
94
+ url: string,
95
+ resolutions: number[],
96
+ { fix = false }: { mimetype?: string; fix?: boolean; webp?: boolean } = {}
97
+ ): string => {
98
+ const srcSet = resolutions
99
+ .map(resolution => `${resizeUrl(url, resolution)} ${resolution}w`)
100
+ .join(', ');
101
+
102
+ return fix ? fixSrcSetString(srcSet) : srcSet;
103
+ };