@heliosgraphics/utils 6.0.0-alpha.10

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/sanitize.ts ADDED
@@ -0,0 +1,219 @@
1
+ const ALLOWED_TAGS = new Set([
2
+ "b",
3
+ "i",
4
+ "u",
5
+ "em",
6
+ "strong",
7
+ "span",
8
+ "p",
9
+ "br",
10
+ "div",
11
+ "h1",
12
+ "h2",
13
+ "h3",
14
+ "h4",
15
+ "h5",
16
+ "h6",
17
+ "ul",
18
+ "ol",
19
+ "li",
20
+ "blockquote",
21
+ "code",
22
+ "pre",
23
+ ])
24
+
25
+ const ALLOWED_ATTRIBUTES = new Set(["class", "id", "title"])
26
+
27
+ const SAFE_PROTOCOLS = /^(https?|mailto|tel|ftp):/i
28
+
29
+ const DANGEROUS_FUNCTIONS: Array<RegExp> = [
30
+ /alert(?=\s*\()/gi,
31
+ /eval(?=\s*\()/gi,
32
+ /document\./gi,
33
+ /window\./gi,
34
+ /location\./gi,
35
+ /navigator\./gi,
36
+ /history\./gi,
37
+ /frames?\./gi,
38
+ /parent\./gi,
39
+ /top\./gi,
40
+ /self\./gi,
41
+ /globalThis\./gi,
42
+ /this\./gi,
43
+ /global\./gi,
44
+ /Function(?=\s*\()/gi,
45
+ /setTimeout(?=\s*\()/gi,
46
+ /setInterval(?=\s*\()/gi,
47
+ /requestAnimationFrame(?=\s*\()/gi,
48
+ /XMLHttpRequest(?=\s*\()/gi,
49
+ /fetch(?=\s*\()/gi,
50
+ /import(?=\s*\()/gi,
51
+ /require(?=\s*\()/gi,
52
+ /process\./gi,
53
+ /Buffer\./gi,
54
+ /\w+\[\s*["']constructor["']\s*\]/gi,
55
+ /\w+\[\s*["']prototype["']\s*\]/gi,
56
+ /`[\s\S]*?\$\{[\s\S]*?\}[\s\S]*?`/gi,
57
+ /fromCharCode(?=\s*\()/gi,
58
+ /String\s*\./gi,
59
+ /unescape(?=\s*\()/gi,
60
+ /decodeURI(?=\s*\()/gi,
61
+ /decodeURIComponent(?=\s*\()/gi,
62
+ /atob(?=\s*\()/gi,
63
+ /btoa(?=\s*\()/gi,
64
+ /WebAssembly\./gi,
65
+ /SharedArrayBuffer(?=\s*\()/gi,
66
+ /Worker(?=\s*\()/gi,
67
+ /ServiceWorker(?=\s*\()/gi,
68
+ /innerHTML\s*=/gi,
69
+ /outerHTML\s*=/gi,
70
+ /insertAdjacentHTML(?=\s*\()/gi,
71
+ /document\.write(?=\s*\()/gi,
72
+ /document\.writeln(?=\s*\()/gi,
73
+ /CustomEvent(?=\s*\()/gi,
74
+ /dispatchEvent(?=\s*\()/gi,
75
+ /createEvent(?=\s*\()/gi,
76
+ /constructor\s*\./gi,
77
+ /__proto__\s*\./gi,
78
+ /prototype\s*\./gi,
79
+ /jsonp(?=\s*\()/gi,
80
+ /loadScript(?=\s*\()/gi,
81
+ /createElement\s*\(\s*["']script["']\s*\)/gi,
82
+ ]
83
+
84
+ export const sanitizeText = (input: string = ""): string => {
85
+ if (!input) return ""
86
+
87
+ // First remove dangerous patterns before decoding
88
+ let sanitized = input
89
+ .replace(/<!--[\s\S]*?-->/g, "")
90
+ .replace(/\\[0-9a-f]{1,6}\s*/gi, "")
91
+ .replace(/<img[^>]*src\s*=\s*["']data:[^"']*["'][^>]*>/gi, "")
92
+ .replace(/data\s*:[^"'\s>)]*/gi, "")
93
+
94
+ // Then decode entities
95
+ sanitized = sanitized
96
+ .replace(/&#x0*([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
97
+ .replace(/&#0*(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10)))
98
+ .replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
99
+ .replace(/\\x([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
100
+ .replace(/\\([0-7]{1,3})/g, (_, octal) => String.fromCharCode(parseInt(octal, 8)))
101
+ .replace(/%3c/gi, "<")
102
+ .replace(/%3e/gi, ">")
103
+ .replace(/%22/gi, '"')
104
+ .replace(/%27/gi, "'")
105
+ .replace(/%2f/gi, "/")
106
+ .replace(/%20/gi, " ")
107
+ .replace(/&lt;/g, "<")
108
+ .replace(/&gt;/g, ">")
109
+ .replace(/&quot;/g, '"')
110
+ .replace(/&#39;/g, "'")
111
+ .replace(/&apos;/g, "'")
112
+ .replace(/&sol;/g, "/")
113
+ .replace(/&amp;/g, "&")
114
+ .replace(/&NewLine;/g, "\n")
115
+ .replace(/&Tab;/g, "\t")
116
+ .replace(/&lpar;/g, "(")
117
+ .replace(/&rpar;/g, ")")
118
+ .replace(/&comma;/g, ",")
119
+ .replace(/&period;/g, ".")
120
+ .replace(/&colon;/g, ":")
121
+ .replace(/&semi;/g, ";")
122
+ .replace(/&equals;/g, "=")
123
+ .replace(/&plus;/g, "+")
124
+ .replace(/&ast;/g, "*")
125
+ .replace(/&dollar;/g, "$")
126
+ .replace(/&num;/g, "#")
127
+ .replace(/&percnt;/g, "%")
128
+ .replace(/\\\\[\w\s]+\\/gi, "")
129
+ .replace(/\{\{[^}]*\}\}/g, "")
130
+ .replace(/\$\{[^}]*\}/g, "")
131
+
132
+ if (typeof sanitized.normalize === "function") {
133
+ sanitized = sanitized.normalize("NFKC")
134
+ }
135
+
136
+ sanitized = sanitized
137
+ .replace(/[\u200B-\u200D\uFEFF]/g, "")
138
+ // eslint-disable-next-line no-control-regex
139
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, "")
140
+ .replace(/[\u202A-\u202E]/g, "")
141
+ .replace(/[\u2066-\u2069]/g, "")
142
+
143
+ sanitized = sanitized.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
144
+ sanitized = sanitized.replace(/<script\b[^>]*>[\s\S]*$/gi, "")
145
+ sanitized = sanitized.replace(/<script\b[^>]*$/gi, "")
146
+
147
+ sanitized = sanitized.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "")
148
+ sanitized = sanitized.replace(/<style\b[^>]*>[\s\S]*$/gi, "")
149
+
150
+ sanitized = sanitized.replace(
151
+ /(\w+(?:[\s\u00A0]*\w)*)\s*(?:&#58;|&#x3a;|&colon;|%3a|%3A|\\:|:)/gi,
152
+ (_: string, protocol: string) => {
153
+ const cleanProtocol = protocol.replace(/[\s\u00A0]/g, "").toLowerCase()
154
+ const testString = cleanProtocol + ":"
155
+ return SAFE_PROTOCOLS.test(testString) ? testString : ""
156
+ },
157
+ )
158
+
159
+ // Handle both closed and unclosed tags
160
+ sanitized = sanitized.replace(
161
+ /<(\/?)([\w-]+)([^>]*?)(?:>|$)/gi,
162
+ (_fullMatch: string, closing: string, tagName: string, attributes: string) => {
163
+ const tag = tagName.toLowerCase()
164
+
165
+ if (!ALLOWED_TAGS.has(tag)) {
166
+ return ""
167
+ }
168
+
169
+ if (attributes && attributes.trim()) {
170
+ let cleanAttributes = ""
171
+
172
+ const attrMatches = attributes.match(/\s*([a-z-]+)\s*=\s*(?:["']([^"']*)["']|`([^`]*)`|([^\s>=]+))/gi)
173
+
174
+ if (attrMatches) {
175
+ attrMatches.forEach((attrMatch) => {
176
+ const attrParsed = attrMatch.match(/\s*([a-z-]+)\s*=\s*(?:["']([^"']*)["']|`([^`]*)`|([^\s>=]+))/i)
177
+ if (attrParsed) {
178
+ const attrName = attrParsed[1]?.toLowerCase().trim()
179
+ const attrValue = (attrParsed[2] || attrParsed[3] || attrParsed[4] || "").trim()
180
+
181
+ if (!attrName) {
182
+ return
183
+ }
184
+
185
+ if (ALLOWED_ATTRIBUTES.has(attrName)) {
186
+ let cleanValue = attrValue
187
+
188
+ cleanValue = cleanValue.replace(/expression\s*\(/gi, "")
189
+ cleanValue = cleanValue.replace(/-moz-binding/gi, "")
190
+ cleanValue = cleanValue.replace(/behavior\s*:/gi, "")
191
+ cleanValue = cleanValue.replace(/script/gi, "")
192
+ cleanValue = cleanValue.replace(/alert/gi, "")
193
+
194
+ cleanAttributes += ` ${attrName}="${cleanValue}"`
195
+ }
196
+ }
197
+ })
198
+ }
199
+
200
+ return `<${closing}${tag}${cleanAttributes}>`
201
+ }
202
+
203
+ return `<${closing}${tag}>`
204
+ },
205
+ )
206
+
207
+ DANGEROUS_FUNCTIONS.forEach((pattern) => {
208
+ sanitized = sanitized.replace(pattern, "")
209
+ })
210
+
211
+ // Additional targeted cleanup for specific dangerous contexts
212
+ sanitized = sanitized.replace(/\balert\s*(?=[,\)])/gi, "")
213
+ sanitized = sanitized.replace(/\bjavascript\s*(?=:)/gi, "")
214
+
215
+ // Remove any remaining backticks
216
+ sanitized = sanitized.replace(/`/g, "")
217
+
218
+ return sanitized.trim()
219
+ }
package/sleep.spec.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { sleep } from "./sleep"
3
+
4
+ describe("sleep", () => {
5
+ it("returns a Promise", () => {
6
+ const result = sleep(1)
7
+ expect(result).toBeInstanceOf(Promise)
8
+ })
9
+
10
+ it("resolves with undefined", async () => {
11
+ const result = await sleep(1)
12
+ expect(result).toBeUndefined()
13
+ })
14
+
15
+ it("accepts number parameter", () => {
16
+ expect(() => sleep(100)).not.toThrow()
17
+ expect(() => sleep(0)).not.toThrow()
18
+ expect(() => sleep(1.5)).not.toThrow()
19
+ })
20
+
21
+ it("resolves after minimal delay", async () => {
22
+ const start = Date.now()
23
+ await sleep(1)
24
+ const elapsed = Date.now() - start
25
+ expect(elapsed).toBeGreaterThanOrEqual(0)
26
+ })
27
+
28
+ it("handles zero milliseconds", async () => {
29
+ const start = Date.now()
30
+ await sleep(0)
31
+ const elapsed = Date.now() - start
32
+ expect(elapsed).toBeGreaterThanOrEqual(0)
33
+ })
34
+
35
+ it("handles fractional milliseconds", async () => {
36
+ const start = Date.now()
37
+ await sleep(0.5)
38
+ const elapsed = Date.now() - start
39
+ expect(elapsed).toBeGreaterThanOrEqual(0)
40
+ })
41
+
42
+ it("handles negative values", async () => {
43
+ const start = Date.now()
44
+ await sleep(-1)
45
+ const elapsed = Date.now() - start
46
+ expect(elapsed).toBeGreaterThanOrEqual(0)
47
+ })
48
+
49
+ it("can be used with Promise.all", async () => {
50
+ const promises = [sleep(1), sleep(2), sleep(3)]
51
+ const results = await Promise.all(promises)
52
+ expect(results).toEqual([undefined, undefined, undefined])
53
+ })
54
+
55
+ it("maintains proper typing", () => {
56
+ const promise: Promise<void> = sleep(1)
57
+ expect(promise).toBeInstanceOf(Promise)
58
+ })
59
+ })
package/sleep.ts ADDED
@@ -0,0 +1 @@
1
+ export const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
package/slug.spec.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { it, describe, expect } from "vitest"
2
+ import { getSlug } from "./slug"
3
+
4
+ describe("slug", () => {
5
+ describe("getSlug", () => {
6
+ it("returns valid from string with dashes", () => expect(getSlug("--B—uRn--")).toEqual("-burn-"))
7
+ it("returns valid from special string", () => expect(getSlug("#$%^B#uR#n-")).toEqual("burn-"))
8
+ it("returns valid from parens string", () =>
9
+ expect(getSlug("Gaussian Blur [1](2){3}")).toEqual("gaussian-blur-123"))
10
+ it("replaces àáäâèéëêìíïîòóöôùúüûñç", () =>
11
+ expect(getSlug("àáäâèéëêìíïîòóöôùúüûñç")).toEqual("aaaaeeeeiiiioooouuuunc"))
12
+ it("fails silently from undefined", () => expect(getSlug(undefined)).toEqual(""))
13
+ it("fails silently from null", () => expect(getSlug(null as unknown as string)).toEqual(""))
14
+ })
15
+ })
package/slug.ts ADDED
@@ -0,0 +1,14 @@
1
+ export const getSlug = (text?: string): string => {
2
+ const isValid = Boolean(text && typeof text == "string")
3
+
4
+ if (!isValid || !text) return ""
5
+
6
+ return text
7
+ .normalize("NFD")
8
+ .replace(/[\u0300-\u036f]/g, "")
9
+ .trim()
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9 -]/g, "")
12
+ .replace(/\s+/g, "-")
13
+ .replace(/-+/g, "-")
14
+ }
@@ -0,0 +1,77 @@
1
+ import { it, describe, expect } from "vitest"
2
+ import { removeMarkdown, removeNewlines, ellipsis } from "./strings"
3
+
4
+ describe("strings", () => {
5
+ describe("removeMarkdown", () => {
6
+ it("removes a bold format", () => expect(removeMarkdown("a **bold** text")).toEqual("a bold text"))
7
+ it("removes a link but keeps the name", () =>
8
+ expect(removeMarkdown("a [link](https://x.com) hello")).toEqual("a link hello"))
9
+ it("removes a bold link", () =>
10
+ expect(removeMarkdown("a [bold **link**](https://x.com) hello")).toEqual("a bold link hello"))
11
+ })
12
+
13
+ describe("removeNewlines", () => {
14
+ it("removes no newline", () => expect(removeNewlines("first line same line")).toEqual("first line same line"))
15
+ it("removes newline", () =>
16
+ expect(removeNewlines("first line\n\n\nsecond line\n")).toEqual("first line second line"))
17
+ it("removes r newline", () =>
18
+ expect(removeNewlines("first line\r\n\nsecond line\n")).toEqual("first line second line"))
19
+ it("removes t newline", () => expect(removeNewlines("first line\tsecond line\n")).toEqual("first line second line"))
20
+ it("removes only a newline", () => expect(removeNewlines("\r")).toEqual(""))
21
+ it("removes nothing without newline", () => expect(removeNewlines("hey", undefined)).toEqual("hey"))
22
+ it("cuts string at the right position", () => expect(removeNewlines("ellipsis", 5)).toEqual("ellip"))
23
+ it("skips cutting if unnecessary", () => expect(removeNewlines("ellipsis", 20)).toEqual("ellipsis"))
24
+ it("returns empty string for null", () => expect(removeNewlines(null, 10)).toEqual(""))
25
+ it("returns empty string for undefined", () => expect(removeNewlines(undefined)).toEqual(""))
26
+ it("returns empty string for empty string", () => expect(removeNewlines("", 10)).toEqual(""))
27
+ })
28
+
29
+ describe("ellipsis", () => {
30
+ describe("end position (default)", () => {
31
+ it("truncates text at end with ellipsis", () => expect(ellipsis("Hello World", 8)).toEqual("Hello..."))
32
+ it("truncates longer text at end", () =>
33
+ expect(ellipsis("Lorem ipsum dolor sit amet", 15)).toEqual("Lorem ipsum ..."))
34
+ it("returns original text if shorter than max length", () => expect(ellipsis("Short", 10)).toEqual("Short"))
35
+ it("returns original text if equal to max length", () => expect(ellipsis("Exactly10!", 10)).toEqual("Exactly10!"))
36
+ it("handles single character with end position", () => expect(ellipsis("A", 5)).toEqual("A"))
37
+ })
38
+
39
+ describe("middle position", () => {
40
+ it("truncates text in middle with ellipsis", () =>
41
+ expect(ellipsis("Hello World", 8, "middle")).toEqual("He...rld"))
42
+ it("truncates longer text in middle", () =>
43
+ expect(ellipsis("Lorem ipsum dolor sit amet", 15, "middle")).toEqual("Lorem ...t amet"))
44
+ it("returns original text if shorter than max length", () =>
45
+ expect(ellipsis("Short", 10, "middle")).toEqual("Short"))
46
+ it("returns original text if equal to max length", () =>
47
+ expect(ellipsis("Exactly10!", 10, "middle")).toEqual("Exactly10!"))
48
+ it("handles odd length correctly", () => expect(ellipsis("Hello World", 7, "middle")).toEqual("He...ld"))
49
+ it("handles even length correctly", () => expect(ellipsis("Hello World", 8, "middle")).toEqual("He...rld"))
50
+ })
51
+
52
+ describe("edge cases", () => {
53
+ it("returns empty string for empty input", () => expect(ellipsis("", 5)).toEqual(""))
54
+ it("returns empty string for null input", () => expect(ellipsis(null as unknown as string, 5)).toEqual(""))
55
+ it("returns empty string for undefined input", () =>
56
+ expect(ellipsis(undefined as unknown as string, 5)).toEqual(""))
57
+ it("handles very small max length with end position", () => expect(ellipsis("Hello", 3)).toEqual(""))
58
+ it("handles very small max length with middle position", () => expect(ellipsis("Hello", 3, "middle")).toEqual(""))
59
+ it("handles max length of 4 with end position", () => expect(ellipsis("Hello", 4)).toEqual("H..."))
60
+ it("handles max length of 4 with middle position", () => expect(ellipsis("Hello", 4, "middle")).toEqual("...o"))
61
+ })
62
+
63
+ describe("emoji handling", () => {
64
+ it("truncates text with emojis at end", () => expect(ellipsis("Hello 👋 World 🌍", 12)).toEqual("Hello 👋 ..."))
65
+ it("truncates text with emojis in middle", () =>
66
+ expect(ellipsis("Hello 👋 World 🌍", 12, "middle")).toEqual("Hell...ld 🌍"))
67
+ it("preserves emojis when text is shorter than max length", () => expect(ellipsis("Hi 👋", 10)).toEqual("Hi 👋"))
68
+ it("handles text with basic emojis", () => expect(ellipsis("Hello world 😊", 10)).toEqual("Hello w..."))
69
+ it("demonstrates emoji splitting limitation", () => {
70
+ // Note: This function works with UTF-16 code units, not visual characters
71
+ // Complex emojis may be split incorrectly at boundaries
72
+ const result = ellipsis("👨‍💻 Work", 8)
73
+ expect(result.length).toBeLessThanOrEqual(8)
74
+ })
75
+ })
76
+ })
77
+ })
package/strings.ts ADDED
@@ -0,0 +1,46 @@
1
+ export const removeMarkdown = (markdownText: string): string => {
2
+ const patternsToRemove: Array<{ pattern: RegExp; replacement: string }> = [
3
+ { pattern: /!\[.*?\]\(.*?\)|\[(.*?)\]\(.*?\)/g, replacement: "$1" },
4
+ {
5
+ pattern: /#{1,6}\s|(\*\*|__|\*|_)(.*?)\1|~~(.*?)~~|>/g,
6
+ replacement: "$2$3",
7
+ },
8
+ { pattern: /-{3,}|`{3}[\s\S]*?`{3}/g, replacement: "" },
9
+ { pattern: /`{1,2}(.*?)`{1,2}/g, replacement: "$1" },
10
+ ]
11
+
12
+ let cleanText = markdownText
13
+
14
+ for (const { pattern, replacement } of patternsToRemove) {
15
+ cleanText = cleanText.replace(pattern, replacement)
16
+ }
17
+
18
+ return cleanText
19
+ }
20
+
21
+ export const removeNewlines = (text?: string | null, limit?: number): string => {
22
+ if (!text || typeof text !== "string") return ""
23
+
24
+ const limitEnd: number = limit ?? text.length ?? 0
25
+ const cleanString: string = text
26
+ .replace(/(?:\r\n|\r|\n|\t)+/g, " ")
27
+ .substring(0, limitEnd)
28
+ .trim()
29
+
30
+ return cleanString
31
+ }
32
+
33
+ export const ellipsis = (text: string, maxLength: number, position: "end" | "middle" = "end"): string => {
34
+ if (!text || typeof text !== "string") return ""
35
+ if (text.length <= maxLength) return text
36
+ if (maxLength < 4) return ""
37
+
38
+ if (position === "middle") {
39
+ const halfLength = Math.floor((maxLength - 3) / 2)
40
+ const remainingLength = maxLength - 3 - halfLength
41
+
42
+ return text.substring(0, halfLength) + "..." + text.substring(text.length - remainingLength)
43
+ }
44
+
45
+ return text.substring(0, maxLength - 3) + "..."
46
+ }
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import { throttle } from "./throttle"
3
+ import { sleep } from "./sleep"
4
+
5
+ describe("throttle", () => {
6
+ it("executes callback once within throttle window", async () => {
7
+ const fn = vi.fn()
8
+ const throttled = throttle(fn, 50)
9
+
10
+ throttled()
11
+ expect(fn).toHaveBeenCalledTimes(1)
12
+
13
+ throttled()
14
+ throttled()
15
+ expect(fn).toHaveBeenCalledTimes(1)
16
+
17
+ await sleep(60)
18
+ throttled()
19
+ expect(fn).toHaveBeenCalledTimes(2)
20
+ })
21
+
22
+ it("executes immediately on first call", () => {
23
+ const fn = vi.fn()
24
+ const throttled = throttle(fn, 50)
25
+
26
+ throttled()
27
+ expect(fn).toHaveBeenCalledTimes(1)
28
+ })
29
+
30
+ it("preserves function arguments on first call", () => {
31
+ const fn = vi.fn()
32
+ const throttled = throttle(fn, 50)
33
+
34
+ throttled("arg1", "arg2", 123)
35
+ expect(fn).toHaveBeenCalledWith("arg1", "arg2", 123)
36
+ })
37
+
38
+ it("ignores subsequent calls within throttle window", () => {
39
+ const fn = vi.fn()
40
+ const throttled = throttle(fn, 50)
41
+
42
+ throttled("first")
43
+ expect(fn).toHaveBeenCalledWith("first")
44
+
45
+ throttled("second")
46
+ throttled("third")
47
+ expect(fn).toHaveBeenCalledTimes(1)
48
+ expect(fn).toHaveBeenCalledWith("first")
49
+ })
50
+
51
+ it("allows execution after throttle window expires", async () => {
52
+ const fn = vi.fn()
53
+ const throttled = throttle(fn, 50)
54
+
55
+ throttled("first")
56
+ expect(fn).toHaveBeenCalledTimes(1)
57
+
58
+ await sleep(60)
59
+
60
+ throttled("second")
61
+ expect(fn).toHaveBeenCalledTimes(2)
62
+ expect(fn).toHaveBeenLastCalledWith("second")
63
+ })
64
+
65
+ it("works with functions that have specific parameter types", () => {
66
+ const fn = vi.fn((str: string, num: number) => str + num)
67
+ const throttled = throttle(fn, 50)
68
+
69
+ throttled("test", 42)
70
+ expect(fn).toHaveBeenCalledWith("test", 42)
71
+ })
72
+
73
+ it("works with functions that return values", () => {
74
+ const fn = vi.fn((x: number) => x * 2)
75
+ const throttled = throttle(fn, 50)
76
+
77
+ throttled(5)
78
+ expect(fn).toHaveBeenCalledWith(5)
79
+ })
80
+
81
+ it("handles rapid successive calls correctly", () => {
82
+ const fn = vi.fn()
83
+ const throttled = throttle(fn, 50)
84
+
85
+ for (let i = 0; i < 10; i++) {
86
+ throttled(i)
87
+ }
88
+
89
+ expect(fn).toHaveBeenCalledTimes(1)
90
+ expect(fn).toHaveBeenCalledWith(0)
91
+ })
92
+
93
+ it("works with zero delay", async () => {
94
+ const fn = vi.fn()
95
+ const throttled = throttle(fn, 0)
96
+
97
+ throttled()
98
+ expect(fn).toHaveBeenCalledTimes(1)
99
+
100
+ await sleep(1)
101
+ throttled()
102
+ expect(fn).toHaveBeenCalledTimes(2)
103
+ })
104
+
105
+ it("maintains separate throttle windows for different throttled functions", () => {
106
+ const fn1 = vi.fn()
107
+ const fn2 = vi.fn()
108
+ const throttled1 = throttle(fn1, 50)
109
+ const throttled2 = throttle(fn2, 50)
110
+
111
+ throttled1()
112
+ throttled2()
113
+
114
+ expect(fn1).toHaveBeenCalledTimes(1)
115
+ expect(fn2).toHaveBeenCalledTimes(1)
116
+
117
+ throttled1()
118
+ throttled2()
119
+
120
+ expect(fn1).toHaveBeenCalledTimes(1)
121
+ expect(fn2).toHaveBeenCalledTimes(1)
122
+ })
123
+
124
+ it("resets throttle window correctly", async () => {
125
+ const fn = vi.fn()
126
+ const throttled = throttle(fn, 50)
127
+
128
+ throttled()
129
+ expect(fn).toHaveBeenCalledTimes(1)
130
+
131
+ await sleep(25)
132
+ throttled()
133
+ expect(fn).toHaveBeenCalledTimes(1)
134
+
135
+ await sleep(30)
136
+ throttled()
137
+ expect(fn).toHaveBeenCalledTimes(2)
138
+
139
+ throttled()
140
+ expect(fn).toHaveBeenCalledTimes(2)
141
+ })
142
+ })
package/throttle.ts ADDED
@@ -0,0 +1,17 @@
1
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2
+ export const throttle = <T extends (...args: Array<any>) => any>(
3
+ fn: T,
4
+ delay: number,
5
+ ): ((...args: Parameters<T>) => void) => {
6
+ let timeoutId: ReturnType<typeof setTimeout> | null = null
7
+
8
+ return (...args: Parameters<T>): void => {
9
+ if (!timeoutId) {
10
+ fn(...args)
11
+
12
+ timeoutId = setTimeout(() => {
13
+ timeoutId = null
14
+ }, delay)
15
+ }
16
+ }
17
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "compilerOptions": {
3
+ "allowJs": false,
4
+ "baseUrl": ".",
5
+ "esModuleInterop": true,
6
+ "forceConsistentCasingInFileNames": true,
7
+ "incremental": true,
8
+ "isolatedModules": true,
9
+ "jsx": "preserve",
10
+ "lib": ["dom", "dom.iterable", "esnext"],
11
+ "module": "esnext",
12
+ "moduleResolution": "bundler",
13
+ "noEmit": true,
14
+ "noFallthroughCasesInSwitch": true,
15
+ "noImplicitAny": true,
16
+ "noImplicitOverride": true,
17
+ "noImplicitReturns": true,
18
+ "noImplicitThis": true,
19
+ "noPropertyAccessFromIndexSignature": true,
20
+ "noUncheckedIndexedAccess": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "exactOptionalPropertyTypes": true,
24
+ "resolveJsonModule": true,
25
+ "skipLibCheck": true,
26
+ "sourceMap": true,
27
+ "strict": true,
28
+ "strictBindCallApply": true,
29
+ "strictFunctionTypes": true,
30
+ "strictNullChecks": true,
31
+ "strictPropertyInitialization": true,
32
+ "target": "ES2022",
33
+ "useDefineForClassFields": true,
34
+ "verbatimModuleSyntax": false,
35
+ "types": ["node"]
36
+ },
37
+ "typeRoots": ["./node_modules/@types", "./types"],
38
+ "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
39
+ "exclude": ["node_modules"]
40
+ }
package/uuid.spec.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { it, describe, expect } from "vitest"
2
+ import { getUUID, isUUID } from "./uuid"
3
+
4
+ describe("validations", () => {
5
+ describe("getUUID", () => {
6
+ const UUID_1 = getUUID()
7
+ const UUID_1_IS_VALID = isUUID(UUID_1)
8
+ const UUID_2_STRING = "any-string"
9
+ const UUID_2 = getUUID(UUID_2_STRING)
10
+ const UUID_3 = getUUID()
11
+ const UUID_4 = getUUID()
12
+
13
+ it("returns a valid uuid", () => expect(UUID_1_IS_VALID).toEqual(true))
14
+ it("returns passed through", () => expect(UUID_2).toEqual(UUID_2_STRING))
15
+ it("returns different uuids on separate calls", () => expect(UUID_3).not.toEqual(UUID_4))
16
+ })
17
+
18
+ describe("isUUID", () => {
19
+ const UUID_3 = getUUID()
20
+ const UUID_3_IS_VALID = isUUID(UUID_3)
21
+ const UUID_4_IS_VALID = isUUID("101bfe56-8c16-4f94-9b45-759ea5e67cea")
22
+
23
+ it("validates a random uuid", () => expect(UUID_3_IS_VALID).toEqual(true))
24
+ it("validates a static uuid", () => expect(UUID_4_IS_VALID).toEqual(true))
25
+
26
+ const UUID_5_IS_VALID = isUUID("")
27
+ const UUID_6_IS_VALID = isUUID(undefined)
28
+
29
+ it("catches empty string", () => expect(UUID_5_IS_VALID).toEqual(false))
30
+ it("catches undefined", () => expect(UUID_6_IS_VALID).toEqual(false))
31
+ })
32
+ })
package/uuid.ts ADDED
@@ -0,0 +1,14 @@
1
+ export const getUUID = (id?: unknown): string => {
2
+ if (id) return id as string
3
+
4
+ return crypto.randomUUID()
5
+ }
6
+
7
+ export const isUUID = (uuid?: unknown): boolean => {
8
+ const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
9
+ const isValid = typeof uuid === "string"
10
+
11
+ if (!isValid) return false
12
+
13
+ return uuidRegex.test(uuid)
14
+ }