@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.
- package/README.md +5 -5
- package/dist/helpers.cjs.development.cjs +9837 -7556
- package/dist/helpers.cjs.development.cjs.map +3 -3
- package/dist/helpers.cjs.production.min.cjs +111 -27
- package/dist/helpers.cjs.production.min.cjs.map +3 -3
- package/dist/helpers.d.cts +2 -2
- package/dist/helpers.d.ts +2 -2
- package/dist/helpers.esm.js +4374 -2485
- package/dist/helpers.esm.js.map +3 -3
- package/package.json +68 -66
- package/src/Utilities.ts +71 -0
- package/src/app-install/index.ts +168 -0
- package/src/arrays/index.ts +20 -0
- package/src/bitstring-status-list/index.ts +69 -0
- package/src/credential-format.ts +508 -0
- package/src/images/discord.helpers.ts +103 -0
- package/src/images/filestack.helpers.ts +179 -0
- package/src/images/images.helpers.ts +75 -0
- package/src/images/index.ts +1 -0
- package/src/images/unsplash.helpers.ts +110 -0
- package/src/images/url.helpers.ts +46 -0
- package/src/index.ts +105 -0
- package/src/numbers/index.ts +21 -0
- package/src/state.ts +576 -0
- package/src/strings/index.ts +4 -0
- package/src/types/index.ts +5 -0
package/package.json
CHANGED
|
@@ -1,67 +1,69 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
2
|
+
"name": "@learncard/helpers",
|
|
3
|
+
"version": "1.3.7",
|
|
4
|
+
"description": "Shared helpers for LearnCard packages",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/helpers.esm.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"development": "./src/index.ts",
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/helpers.d.ts",
|
|
13
|
+
"default": "./dist/helpers.esm.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/helpers.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "node ./scripts/build.mjs && shx cp ./scripts/mixedEntypoint.js ./dist/index.cjs && dts-bundle-generator src/index.ts -o dist/helpers.d.ts --no-check && shx cp ./dist/helpers.d.ts ./dist/helpers.d.cts",
|
|
27
|
+
"start": "aqu watch",
|
|
28
|
+
"lint": "aqu lint",
|
|
29
|
+
"lint:fix": "aqu lint --fix",
|
|
30
|
+
"test": "jest",
|
|
31
|
+
"test:watch": "jest --watch",
|
|
32
|
+
"test:coverage": "jest --silent --ci --coverage --coverageReporters=\"text\" --coverageReporters=\"text-summary\""
|
|
33
|
+
},
|
|
34
|
+
"author": "Learning Economy Foundation (www.learningeconomy.io)",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"homepage": "https://github.com/learningeconomy/LearnCard/tree/main/packages/learn-card-helpers/README.md",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/learningeconomy/LearnCard"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/learningeconomy/LearnCard/issues"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@esbuild-plugins/node-resolve": "^0.1.4",
|
|
46
|
+
"@types/jest": "^27.5.0",
|
|
47
|
+
"@types/node": "^18.7.19",
|
|
48
|
+
"@types/react": "^18.0.0",
|
|
49
|
+
"aqu": "0.4.3",
|
|
50
|
+
"dts-bundle-generator": "^6.10.0",
|
|
51
|
+
"esbuild": "^0.27.1",
|
|
52
|
+
"esbuild-jest": "^0.5.0",
|
|
53
|
+
"jest": "^28.1.3",
|
|
54
|
+
"jest-environment-jsdom": "^28.1.3",
|
|
55
|
+
"shx": "^0.3.4",
|
|
56
|
+
"ts-jest": "^28.0.5"
|
|
57
|
+
},
|
|
58
|
+
"types": "./dist/helpers.d.ts",
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@noble/hashes": "^1.8.0",
|
|
61
|
+
"@sd-jwt/decode": "^0.19.0",
|
|
62
|
+
"@learncard/types": "workspace:*",
|
|
63
|
+
"@trpc/server": "11.7.1",
|
|
64
|
+
"immer": "^10.0.3",
|
|
65
|
+
"use-immer": "^0.9.0",
|
|
66
|
+
"zod": "4.1.13"
|
|
67
|
+
},
|
|
68
|
+
"sideEffects": false
|
|
69
|
+
}
|
package/src/Utilities.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Note: SetState is exported from ./state.ts to avoid duplicate exports
|
|
4
|
+
|
|
5
|
+
export const ImageResizingValidator = z.object({ type: z.literal('resizing') });
|
|
6
|
+
export type ImageResizing = z.infer<typeof ImageResizingValidator>;
|
|
7
|
+
|
|
8
|
+
export const ImageUploadingValidator = z.object({
|
|
9
|
+
type: z.literal('uploading'),
|
|
10
|
+
progress: z.number().optional(),
|
|
11
|
+
});
|
|
12
|
+
export type ImageUploading = z.infer<typeof ImageUploadingValidator>;
|
|
13
|
+
|
|
14
|
+
export const ImageWithLoadingStateValdator = z.object({
|
|
15
|
+
image: z.string(),
|
|
16
|
+
loading: z.union([ImageResizingValidator, ImageUploadingValidator]).optional(),
|
|
17
|
+
});
|
|
18
|
+
export type ImageWithLoadingState = z.infer<typeof ImageWithLoadingStateValdator>;
|
|
19
|
+
|
|
20
|
+
export type FilestackImage = {
|
|
21
|
+
filename: string;
|
|
22
|
+
handle: string;
|
|
23
|
+
mimetype: string;
|
|
24
|
+
originalFile: {
|
|
25
|
+
name: string;
|
|
26
|
+
size: number;
|
|
27
|
+
type: string;
|
|
28
|
+
};
|
|
29
|
+
originalPath: string;
|
|
30
|
+
size: number;
|
|
31
|
+
source: string;
|
|
32
|
+
status: string;
|
|
33
|
+
uploadId: string;
|
|
34
|
+
url: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type Simplify<T> = {
|
|
38
|
+
[KeyType in keyof T]: T[KeyType];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type Maybify<T> = Simplify<{
|
|
42
|
+
[P in keyof T]?: Maybify<T[P]> | null;
|
|
43
|
+
}>;
|
|
44
|
+
|
|
45
|
+
/** If Maybify gives you an exceeds maximum length error, try this one! */
|
|
46
|
+
export type PerformantMaybify<T> = {
|
|
47
|
+
[P in keyof T]?: PerformantMaybify<T[P]> | null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type DeepValues<ObjectType extends object> = {
|
|
51
|
+
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
|
|
52
|
+
? DeepValues<ObjectType[Key]>
|
|
53
|
+
: ObjectType[Key];
|
|
54
|
+
}[keyof ObjectType & (string | number)];
|
|
55
|
+
|
|
56
|
+
export interface Nothing {}
|
|
57
|
+
export type Other<T> = T & Nothing;
|
|
58
|
+
|
|
59
|
+
export type SuggestString<Literals extends string> = Literals | Other<string>;
|
|
60
|
+
|
|
61
|
+
export type DeepPartial<T> = T extends Date
|
|
62
|
+
? T
|
|
63
|
+
: T extends object
|
|
64
|
+
? {
|
|
65
|
+
[P in keyof T]?: DeepPartial<T[P]>;
|
|
66
|
+
}
|
|
67
|
+
: T;
|
|
68
|
+
|
|
69
|
+
export const isNotUndefined = <T>(value: T | null | undefined): value is T => Boolean(value);
|
|
70
|
+
|
|
71
|
+
export const filterUndefined = <T>(arr: (T | null | undefined)[]) => arr.filter(isNotUndefined);
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App installation age restriction logic shared between frontend and backend.
|
|
3
|
+
*
|
|
4
|
+
* This helper evaluates whether a user can install an app based on:
|
|
5
|
+
* - Hard age blocks (min_age) - always blocks, even with guardian approval
|
|
6
|
+
* - Soft age restrictions (age_rating) - can be approved by guardian for child profiles
|
|
7
|
+
* - Contract requirements - require guardian approval for child profiles
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type AppInstallCheckResult =
|
|
11
|
+
| { action: 'proceed' }
|
|
12
|
+
| { action: 'hard_blocked'; reason: string }
|
|
13
|
+
| { action: 'require_dob'; reason: string }
|
|
14
|
+
| { action: 'require_guardian_approval'; reason: string };
|
|
15
|
+
|
|
16
|
+
export type AppInstallCheckInput = {
|
|
17
|
+
/** Whether the user is a child profile (managed account) */
|
|
18
|
+
isChildProfile: boolean;
|
|
19
|
+
/** User's age calculated from DOB, null if DOB is unknown */
|
|
20
|
+
userAge: number | null;
|
|
21
|
+
/** Hard minimum age requirement (blocks completely, no guardian override) */
|
|
22
|
+
minAge?: number;
|
|
23
|
+
/** Soft age rating string (e.g., '4+', '9+', '12+', '17+') */
|
|
24
|
+
ageRating?: string;
|
|
25
|
+
/** Whether the app has an associated consent contract */
|
|
26
|
+
hasContract: boolean;
|
|
27
|
+
/** Whether guardian approval has already been obtained (for backend validation) */
|
|
28
|
+
hasGuardianApproval?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Evaluates whether a user can install an app based on age restrictions and profile type.
|
|
33
|
+
*
|
|
34
|
+
* Cases:
|
|
35
|
+
* 1. Hard block: min_age violation (blocks completely, cannot be overridden)
|
|
36
|
+
* 2. Child profile scenarios:
|
|
37
|
+
* 2a. Child age unknown → require DOB entry (with guardian approval)
|
|
38
|
+
* 2b. No age rating specified → require guardian approval
|
|
39
|
+
* 2c. Child too young for age rating → require guardian approval
|
|
40
|
+
* 2d. App has a contract → require guardian approval
|
|
41
|
+
* 2e. Child old enough, no contract → proceed without guardian
|
|
42
|
+
* 3. Adult user old enough → proceed
|
|
43
|
+
*
|
|
44
|
+
* @param input - The check parameters
|
|
45
|
+
* @returns The action to take and reason if blocked/requires approval
|
|
46
|
+
*/
|
|
47
|
+
export const checkAppInstallEligibility = (input: AppInstallCheckInput): AppInstallCheckResult => {
|
|
48
|
+
const {
|
|
49
|
+
isChildProfile,
|
|
50
|
+
userAge,
|
|
51
|
+
minAge,
|
|
52
|
+
ageRating,
|
|
53
|
+
hasContract,
|
|
54
|
+
hasGuardianApproval = false,
|
|
55
|
+
} = input;
|
|
56
|
+
|
|
57
|
+
const ageRatingMinAge = getAgeRatingMinAge(ageRating);
|
|
58
|
+
|
|
59
|
+
// Case 1: Hard block - min_age violation (block completely)
|
|
60
|
+
// Only applies when userAge is known
|
|
61
|
+
const isHardBlocked =
|
|
62
|
+
userAge !== null && minAge !== undefined && minAge > 0 && userAge < minAge;
|
|
63
|
+
|
|
64
|
+
if (isHardBlocked) {
|
|
65
|
+
return {
|
|
66
|
+
action: 'hard_blocked',
|
|
67
|
+
reason: `User does not meet the minimum age requirement of ${minAge}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Case 2: Child profile - check age restrictions
|
|
72
|
+
if (isChildProfile) {
|
|
73
|
+
const noAgeRating = ageRatingMinAge === 0;
|
|
74
|
+
const childAgeUnknown = userAge === null;
|
|
75
|
+
const childTooYoung = userAge !== null && userAge < ageRatingMinAge;
|
|
76
|
+
|
|
77
|
+
// Case 2a: Child age unknown - require guardian to verify age via DOB entry
|
|
78
|
+
// If guardian has already approved, allow proceed (DOB may not be saved yet)
|
|
79
|
+
if (childAgeUnknown) {
|
|
80
|
+
if (hasGuardianApproval) {
|
|
81
|
+
return { action: 'proceed' };
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
action: 'require_dob',
|
|
85
|
+
reason: 'Child profile age is unknown and must be verified by guardian',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Case 2b: No age rating specified - require guardian approval
|
|
90
|
+
if (noAgeRating) {
|
|
91
|
+
if (hasGuardianApproval) {
|
|
92
|
+
return { action: 'proceed' };
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
action: 'require_guardian_approval',
|
|
96
|
+
reason: 'App has no age rating; guardian approval required for child profiles',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Case 2c: Child too young for age rating - require guardian approval
|
|
101
|
+
if (childTooYoung) {
|
|
102
|
+
if (hasGuardianApproval) {
|
|
103
|
+
return { action: 'proceed' };
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
action: 'require_guardian_approval',
|
|
107
|
+
reason: `Child is under the age rating of ${ageRatingMinAge}+; guardian approval required`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Case 2d: App with a contract - require guardian approval
|
|
112
|
+
if (hasContract) {
|
|
113
|
+
if (hasGuardianApproval) {
|
|
114
|
+
return { action: 'proceed' };
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
action: 'require_guardian_approval',
|
|
118
|
+
reason: 'App requires consent to a contract; guardian approval required for child profiles',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Case 2e: Child old enough, no contract - proceed directly without guardian approval
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Case 3: User is old enough (or adult) - proceed directly
|
|
126
|
+
return { action: 'proceed' };
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Map age_rating strings to numeric minimum ages.
|
|
131
|
+
*/
|
|
132
|
+
export const AGE_RATING_TO_MIN_AGE: Record<string, number> = {
|
|
133
|
+
'4+': 4,
|
|
134
|
+
'9+': 9,
|
|
135
|
+
'12+': 12,
|
|
136
|
+
'17+': 17,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Convert an age_rating string to a numeric minimum age.
|
|
141
|
+
* Returns 0 if no rating is specified or the rating is unknown.
|
|
142
|
+
*/
|
|
143
|
+
const getAgeRatingMinAge = (ageRating?: string): number => {
|
|
144
|
+
if (!ageRating) return 0;
|
|
145
|
+
return AGE_RATING_TO_MIN_AGE[ageRating] ?? 0;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Calculate age in years from a date of birth string.
|
|
150
|
+
* @param dob - Date of birth as ISO string or parseable date string
|
|
151
|
+
* @returns Age in years, or null if the date is invalid
|
|
152
|
+
*/
|
|
153
|
+
export const calculateAgeFromDob = (dob?: string | null): number | null => {
|
|
154
|
+
if (!dob) return null;
|
|
155
|
+
|
|
156
|
+
const birthDate = new Date(dob);
|
|
157
|
+
if (isNaN(birthDate.getTime())) return null;
|
|
158
|
+
|
|
159
|
+
const today = new Date();
|
|
160
|
+
let age = today.getFullYear() - birthDate.getFullYear();
|
|
161
|
+
const monthDiff = today.getMonth() - birthDate.getMonth();
|
|
162
|
+
|
|
163
|
+
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
|
164
|
+
age--;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return age;
|
|
168
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Quantizes a number to the closest number in an array of numbers */
|
|
2
|
+
export const quantizeValue = (value: number, array: number[]) => {
|
|
3
|
+
const foundIndex = array.findIndex((number) => number === value);
|
|
4
|
+
|
|
5
|
+
if (foundIndex > -1) return array[foundIndex];
|
|
6
|
+
|
|
7
|
+
const closestIndex = array.reduce(
|
|
8
|
+
(closest, current, index) => {
|
|
9
|
+
const currentDistance = Math.abs(current - value);
|
|
10
|
+
|
|
11
|
+
if (currentDistance <= closest.distance)
|
|
12
|
+
return { index, distance: currentDistance };
|
|
13
|
+
|
|
14
|
+
return closest;
|
|
15
|
+
},
|
|
16
|
+
{ index: 0, distance: Infinity },
|
|
17
|
+
).index;
|
|
18
|
+
|
|
19
|
+
return array[closestIndex];
|
|
20
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { BitstringStatusListEntry, BitstringStatusPurpose } from '@learncard/types';
|
|
2
|
+
|
|
3
|
+
export const getCredentialStatusArray = (credential: unknown): Record<string, unknown>[] => {
|
|
4
|
+
if (!credential || typeof credential !== 'object' || Array.isArray(credential)) return [];
|
|
5
|
+
|
|
6
|
+
const status = (credential as { credentialStatus?: unknown }).credentialStatus;
|
|
7
|
+
if (!status) return [];
|
|
8
|
+
|
|
9
|
+
return Array.isArray(status)
|
|
10
|
+
? (status as Record<string, unknown>[])
|
|
11
|
+
: [status as Record<string, unknown>];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const isBitstringStatusListEntry = (status: unknown): status is BitstringStatusListEntry => {
|
|
15
|
+
if (!status || typeof status !== 'object') return false;
|
|
16
|
+
|
|
17
|
+
const record = status as Record<string, unknown>;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
record.type === 'BitstringStatusListEntry' &&
|
|
21
|
+
(record.statusPurpose === 'revocation' || record.statusPurpose === 'suspension') &&
|
|
22
|
+
typeof record.statusListIndex === 'string' &&
|
|
23
|
+
typeof record.statusListCredential === 'string'
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const getBitstringStatusListEntries = (credential: unknown): BitstringStatusListEntry[] => {
|
|
28
|
+
if (!credential || typeof credential !== 'object' || Array.isArray(credential)) return [];
|
|
29
|
+
|
|
30
|
+
const record = credential as Record<string, unknown>;
|
|
31
|
+
const statuses = getCredentialStatusArray(record);
|
|
32
|
+
|
|
33
|
+
return [
|
|
34
|
+
...statuses.filter(isBitstringStatusListEntry),
|
|
35
|
+
...getBitstringStatusListEntries(record.boostCredential),
|
|
36
|
+
];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const getBitstringStatusListEntryForPurpose = (
|
|
40
|
+
credential: unknown,
|
|
41
|
+
statusPurpose: BitstringStatusPurpose
|
|
42
|
+
): BitstringStatusListEntry | undefined =>
|
|
43
|
+
getBitstringStatusListEntries(credential).find(entry => entry.statusPurpose === statusPurpose);
|
|
44
|
+
|
|
45
|
+
export const getBitstringStatusListBit = (bitstring: Uint8Array, index: number): boolean => {
|
|
46
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
47
|
+
throw new Error('Bitstring status list index is out of range');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const byte = bitstring[Math.floor(index / 8)] ?? 0;
|
|
51
|
+
|
|
52
|
+
return (byte & (1 << index % 8)) !== 0;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const setBitstringStatusListBit = (
|
|
56
|
+
bitstring: Uint8Array,
|
|
57
|
+
index: number,
|
|
58
|
+
value: boolean
|
|
59
|
+
): void => {
|
|
60
|
+
if (!Number.isInteger(index) || index < 0 || index >= bitstring.length * 8) {
|
|
61
|
+
throw new Error('Bitstring status list index is out of range');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const byteIndex = Math.floor(index / 8);
|
|
65
|
+
const mask = 1 << index % 8;
|
|
66
|
+
const current = bitstring[byteIndex] ?? 0;
|
|
67
|
+
|
|
68
|
+
bitstring[byteIndex] = value ? current | mask : current & ~mask;
|
|
69
|
+
};
|