@splendidlabz/utils 1.5.0-alpha.4 → 1.5.0-beta.5
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/CHANGELOG.md +6 -0
- package/dom/actions/prefer-horizontal-scroll.js +2 -2
- package/dom/index.js +1 -0
- package/dom/local-store.js +37 -0
- package/dom/sanitize.js +16 -0
- package/lib/arrays/index.js +5 -85
- package/lib/arrays/item-position.js +23 -0
- package/lib/arrays/join.js +17 -0
- package/lib/arrays/shuffle.js +21 -0
- package/lib/arrays/sort.js +58 -0
- package/lib/arrays/unique.js +3 -0
- package/lib/date/index.js +2 -1
- package/lib/date/time.js +50 -0
- package/lib/form/sanitize.js +47 -38
- package/lib/form/sanitize.spec.js +126 -0
- package/lib/objects/equal.js +9 -0
- package/lib/objects/index.js +1 -0
- package/node/dirname.js +1 -1
- package/node/index.js +1 -0
- package/node/sanitize.js +13 -0
- package/node/sanitize.spec.js +142 -0
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,7 +12,7 @@ const DEFAULT_OPTIONS = {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function preferHorizontalScroll(node, props = {}) {
|
|
15
|
-
if (!node.classList.contains('scrollable-prefer-horizontal
|
|
15
|
+
if (!node.classList.contains('scrollable-prefer-horizontal')) return
|
|
16
16
|
|
|
17
17
|
const state = {
|
|
18
18
|
origSnapType: null,
|
|
@@ -23,7 +23,7 @@ export function preferHorizontalScroll(node, props = {}) {
|
|
|
23
23
|
const options = {
|
|
24
24
|
...DEFAULT_OPTIONS,
|
|
25
25
|
...omitEmpty({
|
|
26
|
-
scrollSnapDelay: getCSSVar(node, '--
|
|
26
|
+
scrollSnapDelay: getCSSVar(node, '--scroll-snap-delay'),
|
|
27
27
|
}),
|
|
28
28
|
...omitEmpty(props),
|
|
29
29
|
}
|
package/dom/index.js
CHANGED
|
@@ -16,6 +16,7 @@ export * from './observers/index.js'
|
|
|
16
16
|
export * from './pkce.js'
|
|
17
17
|
export * from './query-params.js'
|
|
18
18
|
export * from './random-string.js'
|
|
19
|
+
export * from './sanitize.js'
|
|
19
20
|
export * from './session-store.js'
|
|
20
21
|
export * from './trap-focus.js'
|
|
21
22
|
export * from './ui/index.js'
|
package/dom/local-store.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
// ========================
|
|
2
2
|
// Local Storage
|
|
3
3
|
// ========================
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Utility object for managing localStorage operations with JSON support
|
|
7
|
+
* @namespace localStore
|
|
8
|
+
*/
|
|
4
9
|
export const localStore = {
|
|
10
|
+
/**
|
|
11
|
+
* Retrieves a value from localStorage
|
|
12
|
+
* @param {string} key - The key to retrieve from localStorage
|
|
13
|
+
* @returns {any} The stored value. Returns parsed JSON if the value was stored as JSON. Returns the original string if not JSON, or undefined if key doesn't exist
|
|
14
|
+
*/
|
|
5
15
|
get(key) {
|
|
6
16
|
const value = localStorage.getItem(key)
|
|
7
17
|
if (!value) return
|
|
@@ -15,6 +25,11 @@ export const localStore = {
|
|
|
15
25
|
}
|
|
16
26
|
},
|
|
17
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Stores a value in localStorage
|
|
30
|
+
* @param {string} key - The key to store the value under
|
|
31
|
+
* @param {any} value - The value to store. Objects will be stringified to JSON
|
|
32
|
+
*/
|
|
18
33
|
set(key, value) {
|
|
19
34
|
if (typeof value === 'string') {
|
|
20
35
|
localStorage.setItem(key, value)
|
|
@@ -23,7 +38,29 @@ export const localStore = {
|
|
|
23
38
|
}
|
|
24
39
|
},
|
|
25
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Stores multiple key-value pairs in localStorage
|
|
43
|
+
* @param {Object|Map} data - An object or Map containing key-value pairs to store
|
|
44
|
+
*/
|
|
45
|
+
setMultiple(data) {
|
|
46
|
+
for (const [key, value] of Object.entries(data)) {
|
|
47
|
+
this.set(key, value)
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Removes a value from localStorage
|
|
53
|
+
* @param {string} key - The key to remove from localStorage
|
|
54
|
+
*/
|
|
26
55
|
remove(key) {
|
|
27
56
|
localStorage.removeItem(key)
|
|
28
57
|
},
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Removes multiple keys from localStorage
|
|
61
|
+
* @param {string[]} keys - Array of keys to remove from localStorage
|
|
62
|
+
*/
|
|
63
|
+
removeMultiple(keys) {
|
|
64
|
+
keys.forEach(key => this.remove(key))
|
|
65
|
+
},
|
|
29
66
|
}
|
package/dom/sanitize.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import DOMPurify from 'dompurify'
|
|
2
|
+
import { sanitize as sanitizeLib } from '../lib/form/sanitize.js'
|
|
3
|
+
|
|
4
|
+
// Need to test if possible.
|
|
5
|
+
// We can't test browser on node. So, unless we do like playwright or some form of browser testing, we can't actually verify this works every time.
|
|
6
|
+
// Node version works already.
|
|
7
|
+
/**
|
|
8
|
+
* Sanitizes values using DOMPurify
|
|
9
|
+
* @param {*} value - Value to sanitize (string, array, or object)
|
|
10
|
+
* @param {Object} [options={}] - DOMPurify options
|
|
11
|
+
* @returns {*} Sanitized value
|
|
12
|
+
* @throws {Error} If input is a string but DOMPurify fails
|
|
13
|
+
*/
|
|
14
|
+
export function sanitize(value, options = {}) {
|
|
15
|
+
return sanitizeLib(value, { sanitizer: DOMPurify.sanitize })
|
|
16
|
+
}
|
package/lib/arrays/index.js
CHANGED
|
@@ -1,85 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
// Fisher Yates Shuffle without mutating the original array
|
|
8
|
-
export function shuffle(array) {
|
|
9
|
-
const clone = array.slice()
|
|
10
|
-
let currentIndex = array.length
|
|
11
|
-
let temporaryValue
|
|
12
|
-
let randomIndex
|
|
13
|
-
|
|
14
|
-
// While there remain elements to shuffle...
|
|
15
|
-
while (currentIndex !== 0) {
|
|
16
|
-
// Pick a remaining element...
|
|
17
|
-
randomIndex = Math.floor(Math.random() * currentIndex)
|
|
18
|
-
currentIndex -= 1
|
|
19
|
-
|
|
20
|
-
// And swap it with the current element.
|
|
21
|
-
temporaryValue = clone[currentIndex]
|
|
22
|
-
clone[currentIndex] = clone[randomIndex]
|
|
23
|
-
clone[randomIndex] = temporaryValue
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return clone
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Sorts an array of objects, numbers, or strings without mutating the original array
|
|
31
|
-
* @param {Array} array - Array to sort
|
|
32
|
-
* @param {Object} options - Sort options
|
|
33
|
-
* @param {string|string[]|null} options.props - Property or array of properties to sort by. If null, sorts simple arrays
|
|
34
|
-
* @param {boolean} options.reverse - Whether to sort in reverse order
|
|
35
|
-
*/
|
|
36
|
-
export function sort(array, { props = null, reverse = false } = {}) {
|
|
37
|
-
const clone = array.slice()
|
|
38
|
-
|
|
39
|
-
// Handle simple arrays (numbers or strings)
|
|
40
|
-
if (props === null) {
|
|
41
|
-
return clone.sort((a, b) => {
|
|
42
|
-
const comparison = compareValues(a, b)
|
|
43
|
-
return reverse ? -comparison : comparison
|
|
44
|
-
})
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Handle objects in arrays
|
|
48
|
-
const properties = Array.isArray(props) ? props : [props]
|
|
49
|
-
|
|
50
|
-
return clone.sort((a, b) => {
|
|
51
|
-
for (const property of properties) {
|
|
52
|
-
const aValue = getNestedValue(a, property)
|
|
53
|
-
const bValue = getNestedValue(b, property)
|
|
54
|
-
|
|
55
|
-
// Skip if values are equal
|
|
56
|
-
if (aValue === bValue) continue
|
|
57
|
-
|
|
58
|
-
// Push null/undefined values to the end
|
|
59
|
-
if (aValue == null) return 1
|
|
60
|
-
if (bValue == null) return -1
|
|
61
|
-
|
|
62
|
-
// Compare values and handle reverse sort
|
|
63
|
-
const comparison = compareValues(aValue, bValue)
|
|
64
|
-
return reverse ? -comparison : comparison
|
|
65
|
-
}
|
|
66
|
-
return 0
|
|
67
|
-
})
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Compare values for sorting
|
|
71
|
-
function compareValues(a, b) {
|
|
72
|
-
// Compare numbers
|
|
73
|
-
if (typeof a === 'number' && typeof b === 'number') return a - b
|
|
74
|
-
|
|
75
|
-
// Compare strings
|
|
76
|
-
if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b)
|
|
77
|
-
|
|
78
|
-
// Compare dates
|
|
79
|
-
const aDate = new Date(a)
|
|
80
|
-
const bDate = new Date(b)
|
|
81
|
-
if (!isNaN(aDate) && !isNaN(bDate)) return aDate.getTime() - bDate.getTime()
|
|
82
|
-
|
|
83
|
-
// Convert into strings and compare
|
|
84
|
-
return String(a).localeCompare(String(b))
|
|
85
|
-
}
|
|
1
|
+
export * from './item-position.js'
|
|
2
|
+
export * from './join.js'
|
|
3
|
+
export * from './shuffle.js'
|
|
4
|
+
export * from './sort.js'
|
|
5
|
+
export * from './unique.js'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function last(array, index) {
|
|
2
|
+
console.warn('last is deprecated. Use isLastItem instead.')
|
|
3
|
+
return index === array.length - 1
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Checks if the given index is the last item in the array
|
|
8
|
+
* @param {Array} array - The array to check
|
|
9
|
+
* @param {number} index - The index to check
|
|
10
|
+
* @returns {boolean} True if the index is the last item, false otherwise
|
|
11
|
+
*/
|
|
12
|
+
export function isLastItem(array, index) {
|
|
13
|
+
return index === array.length - 1
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns the last item in the array
|
|
18
|
+
* @param {Array} array - The array to get the last item from
|
|
19
|
+
* @returns {*} The last item in the array
|
|
20
|
+
*/
|
|
21
|
+
export function lastArrayItem(array) {
|
|
22
|
+
return array[array.length - 1]
|
|
23
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { lastArrayItem } from './item-position.js'
|
|
2
|
+
/**
|
|
3
|
+
* Joins an array of strings with commas and a conjunction for the last item
|
|
4
|
+
* @param {string[]} array - Array of strings to join
|
|
5
|
+
* @param {Object} options - Join options
|
|
6
|
+
* @param {string} options.conjunction - Conjunction to use ('and' or 'or')
|
|
7
|
+
* @returns {string} Joined string
|
|
8
|
+
*/
|
|
9
|
+
export function joinWithConjunction(array, { conjunction = 'and' } = {}) {
|
|
10
|
+
if (!array?.length) return ''
|
|
11
|
+
if (array.length === 1) return array[0]
|
|
12
|
+
if (array.length === 2) return `${array[0]} ${conjunction} ${array[1]}`
|
|
13
|
+
|
|
14
|
+
const lastItem = lastArrayItem(array)
|
|
15
|
+
const rest = array.slice(0, -1)
|
|
16
|
+
return `${rest.join(', ')} ${conjunction} ${lastItem}`
|
|
17
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Fisher Yates Shuffle without mutating the original array
|
|
2
|
+
export function shuffle(array) {
|
|
3
|
+
const clone = array.slice()
|
|
4
|
+
let currentIndex = array.length
|
|
5
|
+
let temporaryValue
|
|
6
|
+
let randomIndex
|
|
7
|
+
|
|
8
|
+
// While there remain elements to shuffle...
|
|
9
|
+
while (currentIndex !== 0) {
|
|
10
|
+
// Pick a remaining element...
|
|
11
|
+
randomIndex = Math.floor(Math.random() * currentIndex)
|
|
12
|
+
currentIndex -= 1
|
|
13
|
+
|
|
14
|
+
// And swap it with the current element.
|
|
15
|
+
temporaryValue = clone[currentIndex]
|
|
16
|
+
clone[currentIndex] = clone[randomIndex]
|
|
17
|
+
clone[randomIndex] = temporaryValue
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return clone
|
|
21
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getNestedValue } from '../objects/nested-property.js'
|
|
2
|
+
/**
|
|
3
|
+
* Sorts an array of objects, numbers, or strings without mutating the original array
|
|
4
|
+
* @param {Array} array - Array to sort
|
|
5
|
+
* @param {Object} options - Sort options
|
|
6
|
+
* @param {string|string[]|null} options.props - Property or array of properties to sort by. If null, sorts simple arrays
|
|
7
|
+
* @param {boolean} options.reverse - Whether to sort in reverse order
|
|
8
|
+
*/
|
|
9
|
+
export function sort(array, { props = null, reverse = false } = {}) {
|
|
10
|
+
const clone = array.slice()
|
|
11
|
+
|
|
12
|
+
// Handle simple arrays (numbers or strings)
|
|
13
|
+
if (props === null) {
|
|
14
|
+
return clone.sort((a, b) => {
|
|
15
|
+
const comparison = compareValues(a, b)
|
|
16
|
+
return reverse ? -comparison : comparison
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Handle objects in arrays
|
|
21
|
+
const properties = Array.isArray(props) ? props : [props]
|
|
22
|
+
|
|
23
|
+
return clone.sort((a, b) => {
|
|
24
|
+
for (const property of properties) {
|
|
25
|
+
const aValue = getNestedValue(a, property)
|
|
26
|
+
const bValue = getNestedValue(b, property)
|
|
27
|
+
|
|
28
|
+
// Skip if values are equal
|
|
29
|
+
if (aValue === bValue) continue
|
|
30
|
+
|
|
31
|
+
// Push null/undefined values to the end
|
|
32
|
+
if (aValue == null) return 1
|
|
33
|
+
if (bValue == null) return -1
|
|
34
|
+
|
|
35
|
+
// Compare values and handle reverse sort
|
|
36
|
+
const comparison = compareValues(aValue, bValue)
|
|
37
|
+
return reverse ? -comparison : comparison
|
|
38
|
+
}
|
|
39
|
+
return 0
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Compare values for sorting
|
|
44
|
+
function compareValues(a, b) {
|
|
45
|
+
// Compare numbers
|
|
46
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b
|
|
47
|
+
|
|
48
|
+
// Compare strings
|
|
49
|
+
if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b)
|
|
50
|
+
|
|
51
|
+
// Compare dates
|
|
52
|
+
const aDate = new Date(a)
|
|
53
|
+
const bDate = new Date(b)
|
|
54
|
+
if (!isNaN(aDate) && !isNaN(bDate)) return aDate.getTime() - bDate.getTime()
|
|
55
|
+
|
|
56
|
+
// Convert into strings and compare
|
|
57
|
+
return String(a).localeCompare(String(b))
|
|
58
|
+
}
|
package/lib/date/index.js
CHANGED
package/lib/date/time.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { splitUnit } from '../numbers/index.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convert a time string to milliseconds
|
|
5
|
+
* @param {string} arg - The time string to convert
|
|
6
|
+
* @returns {number}
|
|
7
|
+
*/
|
|
8
|
+
export function ms(arg) {
|
|
9
|
+
const [value, unit] = splitUnit(arg)
|
|
10
|
+
if (unit === 'd') return getTimeInMs(value, 'days')
|
|
11
|
+
if (unit === 'day') return getTimeInMs(value, 'days')
|
|
12
|
+
if (unit === 'days') return getTimeInMs(value, 'days')
|
|
13
|
+
if (unit === 'h') return getTimeInMs(value, 'hours')
|
|
14
|
+
if (unit === 'hr') return getTimeInMs(value, 'hours')
|
|
15
|
+
if (unit === 'hour') return getTimeInMs(value, 'hours')
|
|
16
|
+
if (unit === 'hours') return getTimeInMs(value, 'hours')
|
|
17
|
+
if (unit === 'm') return getTimeInMs(value, 'minutes')
|
|
18
|
+
if (unit === 'min') return getTimeInMs(value, 'minutes')
|
|
19
|
+
if (unit === 'minute') return getTimeInMs(value, 'minutes')
|
|
20
|
+
if (unit === 'minutes') return getTimeInMs(value, 'minutes')
|
|
21
|
+
if (unit === 's') return getTimeInMs(value, 'seconds')
|
|
22
|
+
if (unit === 'sec') return getTimeInMs(value, 'seconds')
|
|
23
|
+
if (unit === 'second') return getTimeInMs(value, 'seconds')
|
|
24
|
+
if (unit === 'seconds') return getTimeInMs(value, 'seconds')
|
|
25
|
+
|
|
26
|
+
// The rest are for milliseconds
|
|
27
|
+
return value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function seconds(arg) {
|
|
31
|
+
const value = ms(arg)
|
|
32
|
+
return value / 1000
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getTimeInMs(value, unit = 'ms') {
|
|
36
|
+
switch (unit) {
|
|
37
|
+
case 'ms':
|
|
38
|
+
return value
|
|
39
|
+
case 'seconds':
|
|
40
|
+
return value * 1000
|
|
41
|
+
case 'minutes':
|
|
42
|
+
return value * 1000 * 60
|
|
43
|
+
case 'hours':
|
|
44
|
+
return value * 1000 * 60 * 60
|
|
45
|
+
case 'days':
|
|
46
|
+
return value * 1000 * 60 * 60 * 24
|
|
47
|
+
case 'weeks':
|
|
48
|
+
return value * 1000 * 60 * 60 * 24 * 7
|
|
49
|
+
}
|
|
50
|
+
}
|
package/lib/form/sanitize.js
CHANGED
|
@@ -1,48 +1,57 @@
|
|
|
1
|
-
import DOMPurify from 'dompurify'
|
|
2
|
-
|
|
3
|
-
// MIGHT HAVE TO SHIFT THIS TO DOM, because DOMPurify doesn't work with Node
|
|
4
1
|
/**
|
|
5
|
-
*
|
|
6
|
-
* @param {
|
|
7
|
-
* @param {Object} [options={}] -
|
|
8
|
-
* @
|
|
2
|
+
* Core sanitization function that handles different value types
|
|
3
|
+
* @param {*} value - The value to sanitize
|
|
4
|
+
* @param {Object} [options={}] - Sanitization options
|
|
5
|
+
* @param {Function} options.sanitizer - Function to sanitize strings. Must be provided
|
|
6
|
+
* @returns {*} Sanitized value
|
|
9
7
|
*/
|
|
10
|
-
export function sanitize(
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
export function sanitize(value, options = {}) {
|
|
9
|
+
const { sanitizer, ...rest } = options
|
|
10
|
+
if (!sanitizer) throw new Error('sanitizer function is required')
|
|
13
11
|
|
|
12
|
+
if (typeof value === 'string') return sanitizer(value, rest)
|
|
13
|
+
if (Array.isArray(value)) return sanitizeArray(value, { sanitizer, ...rest })
|
|
14
|
+
if (value && typeof value === 'object') {
|
|
15
|
+
return Object.fromEntries(
|
|
16
|
+
Object.entries(value).map(([key, val]) => [
|
|
17
|
+
key,
|
|
18
|
+
sanitize(val, { sanitizer, ...rest }),
|
|
19
|
+
]),
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
14
24
|
/**
|
|
15
|
-
*
|
|
16
|
-
* @param {Array}
|
|
17
|
-
* @param {Object}
|
|
18
|
-
* @
|
|
25
|
+
* Sanitizes an array by recursively sanitizing each element
|
|
26
|
+
* @param {Array} arr - Array to sanitize
|
|
27
|
+
* @param {Object} options - Sanitization options
|
|
28
|
+
* @param {Function} options.sanitizer - Function to sanitize strings. Must be provided
|
|
29
|
+
* @returns {Array} New array with sanitized values
|
|
30
|
+
* @throws {Error} If sanitizer function is not provided
|
|
19
31
|
*/
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
return [
|
|
24
|
-
key,
|
|
25
|
-
value.map(item => sanitizeEntries(Object.entries(item), options)),
|
|
26
|
-
]
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
if (value && typeof value === 'object') {
|
|
30
|
-
return [
|
|
31
|
-
key,
|
|
32
|
-
Object.fromEntries(sanitizeEntries(Object.entries(value), options)),
|
|
33
|
-
]
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return [key, sanitize(value, options)]
|
|
37
|
-
})
|
|
32
|
+
function sanitizeArray(arr, { sanitizer, ...rest }) {
|
|
33
|
+
if (!sanitizer) throw new Error('sanitizer function is required')
|
|
34
|
+
return arr.map(item => sanitize(item, { sanitizer, ...rest }))
|
|
38
35
|
}
|
|
39
36
|
|
|
40
37
|
/**
|
|
41
|
-
* Sanitizes all string values in an object
|
|
42
|
-
* @param {Object} obj -
|
|
43
|
-
* @param {Object} [options={}] -
|
|
44
|
-
* @
|
|
38
|
+
* Sanitizes all string values in an object recursively
|
|
39
|
+
* @param {Object} obj - Object to sanitize
|
|
40
|
+
* @param {Object} [options={}] - Sanitization options
|
|
41
|
+
* @param {Function} options.sanitizer - Function to sanitize strings. Must be provided
|
|
42
|
+
* @returns {Object} New object with sanitized values
|
|
43
|
+
* @throws {Error} If sanitizer function is not provided
|
|
45
44
|
*/
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
function sanitizeObject(obj, { sanitizer, ...options } = {}) {
|
|
46
|
+
if (!sanitizer) throw new Error('sanitizer function is required')
|
|
47
|
+
if (Array.isArray(obj)) return sanitizeArray(obj, { sanitizer, ...options })
|
|
48
|
+
|
|
49
|
+
return Object.fromEntries(
|
|
50
|
+
Object.entries(obj).map(([key, value]) => [
|
|
51
|
+
key,
|
|
52
|
+
sanitize(value, { sanitizer, ...options }),
|
|
53
|
+
]),
|
|
54
|
+
)
|
|
48
55
|
}
|
|
56
|
+
|
|
57
|
+
export { sanitizeArray, sanitizeObject }
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import sanitizeHtml from 'sanitize-html'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { sanitize } from './sanitize.js'
|
|
4
|
+
|
|
5
|
+
// Real sanitizer using sanitize-html
|
|
6
|
+
function mockSanitizer(value, options = {}) {
|
|
7
|
+
if (typeof value !== 'string') return value
|
|
8
|
+
return sanitizeHtml(value, {
|
|
9
|
+
allowedTags: ['b', 'i', 'em', 'strong', 'a'],
|
|
10
|
+
allowedAttributes: {
|
|
11
|
+
a: ['href'],
|
|
12
|
+
},
|
|
13
|
+
...options,
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('sanitize', () => {
|
|
18
|
+
it('sanitizes HTML in string values', () => {
|
|
19
|
+
const input = {
|
|
20
|
+
name: '<script>alert("xss")</script>John',
|
|
21
|
+
bio: '<p>Hello <b>World</b> <script>alert("xss")</script></p>',
|
|
22
|
+
link: '<a href="javascript:alert(1)">Click me</a>',
|
|
23
|
+
safeLink: '<a href="https://example.com">Safe link</a>',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const expected = {
|
|
27
|
+
name: 'John',
|
|
28
|
+
bio: 'Hello <b>World</b> ',
|
|
29
|
+
link: '<a>Click me</a>',
|
|
30
|
+
safeLink: '<a href="https://example.com">Safe link</a>',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
expect(sanitize(input, { sanitizer: mockSanitizer })).toEqual(expected)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('sanitizes nested objects with HTML', () => {
|
|
37
|
+
const input = {
|
|
38
|
+
user: {
|
|
39
|
+
name: '<script>alert("xss")</script>John',
|
|
40
|
+
profile: {
|
|
41
|
+
bio: '<p>Hello <b>World</b></p>',
|
|
42
|
+
links: [
|
|
43
|
+
'<a href="javascript:alert(1)">Bad</a>',
|
|
44
|
+
'<a href="https://good.com">Good</a>',
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const expected = {
|
|
51
|
+
user: {
|
|
52
|
+
name: 'John',
|
|
53
|
+
profile: {
|
|
54
|
+
bio: 'Hello <b>World</b>',
|
|
55
|
+
links: ['<a>Bad</a>', '<a href="https://good.com">Good</a>'],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
expect(sanitize(input, { sanitizer: mockSanitizer })).toEqual(expected)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('preserves non-string values and sanitizes arrays correctly', () => {
|
|
64
|
+
const input = {
|
|
65
|
+
name: '<script>alert("xss")</script>John',
|
|
66
|
+
age: 25,
|
|
67
|
+
active: true,
|
|
68
|
+
email: null,
|
|
69
|
+
role: undefined,
|
|
70
|
+
tags: ['<script>alert(1)</script>admin', 'user'],
|
|
71
|
+
numbers: [1, 2, 3],
|
|
72
|
+
mixed: ['<b>bold</b>', 42, null, '<script>alert(1)</script>text'],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const expected = {
|
|
76
|
+
name: 'John',
|
|
77
|
+
age: 25,
|
|
78
|
+
active: true,
|
|
79
|
+
email: null,
|
|
80
|
+
role: undefined,
|
|
81
|
+
tags: ['admin', 'user'],
|
|
82
|
+
numbers: [1, 2, 3],
|
|
83
|
+
mixed: ['<b>bold</b>', 42, null, 'text'],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
expect(sanitize(input, { sanitizer: mockSanitizer })).toEqual(expected)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('throws error when sanitizer is not provided', () => {
|
|
90
|
+
const input = { name: '<script>alert(1)</script>John' }
|
|
91
|
+
expect(() => sanitize(input)).toThrow('sanitizer function is required')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('allows custom sanitizer options', () => {
|
|
95
|
+
const input = {
|
|
96
|
+
content: '<p>Hello <span>World</span> <script>alert(1)</script></p>',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const defaultOptions = {
|
|
100
|
+
allowedTags: ['b', 'i', 'em', 'strong', 'a'],
|
|
101
|
+
allowedAttributes: {
|
|
102
|
+
a: ['href'],
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Test with custom allowed tags
|
|
107
|
+
const withSpan = sanitize(input, {
|
|
108
|
+
sanitizer: v =>
|
|
109
|
+
mockSanitizer(v, {
|
|
110
|
+
...defaultOptions,
|
|
111
|
+
allowedTags: [...defaultOptions.allowedTags, 'span'],
|
|
112
|
+
}),
|
|
113
|
+
})
|
|
114
|
+
expect(withSpan.content).toBe('Hello <span>World</span> ')
|
|
115
|
+
|
|
116
|
+
// Test with no allowed tags
|
|
117
|
+
const noTags = sanitize(input, {
|
|
118
|
+
sanitizer: v =>
|
|
119
|
+
mockSanitizer(v, {
|
|
120
|
+
...defaultOptions,
|
|
121
|
+
allowedTags: [],
|
|
122
|
+
}),
|
|
123
|
+
})
|
|
124
|
+
expect(noTags.content).toBe('Hello World ')
|
|
125
|
+
})
|
|
126
|
+
})
|
package/lib/objects/index.js
CHANGED
package/node/dirname.js
CHANGED
package/node/index.js
CHANGED
package/node/sanitize.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import sanitizeHtml from 'sanitize-html'
|
|
2
|
+
import { sanitize as sanitizeLib } from '../lib/form/sanitize.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sanitizes values using sanitize-html
|
|
6
|
+
* @param {*} value - Value to sanitize (string, array, or object)
|
|
7
|
+
* @param {Object} [options={}] - sanitize-html options
|
|
8
|
+
* @returns {*} Sanitized value
|
|
9
|
+
* @throws {Error} If input is a string but sanitize-html fails
|
|
10
|
+
*/
|
|
11
|
+
export function sanitize(value, options = {}) {
|
|
12
|
+
return sanitizeLib(value, { sanitizer: sanitizeHtml })
|
|
13
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { sanitize } from './sanitize.js'
|
|
3
|
+
|
|
4
|
+
describe('node sanitize', () => {
|
|
5
|
+
describe('sanitize', () => {
|
|
6
|
+
it('removes script tags', () => {
|
|
7
|
+
const input = '<script>alert("xss")</script>Hello'
|
|
8
|
+
expect(sanitize(input)).toBe('Hello')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('allows safe HTML tags', () => {
|
|
12
|
+
const input = '<p>Hello <b>World</b></p>'
|
|
13
|
+
expect(sanitize(input, { allowedTags: ['b'] })).toBe('Hello <b>World</b>')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('sanitizes unsafe attributes', () => {
|
|
17
|
+
const input = '<a href="javascript:alert(1)">Click me</a>'
|
|
18
|
+
expect(
|
|
19
|
+
sanitize(input, {
|
|
20
|
+
allowedTags: ['a'],
|
|
21
|
+
allowedAttributes: { a: ['href'] },
|
|
22
|
+
}),
|
|
23
|
+
).toBe('<a>Click me</a>')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('allows safe attributes', () => {
|
|
27
|
+
const input = '<a href="https://example.com">Safe link</a>'
|
|
28
|
+
expect(
|
|
29
|
+
sanitize(input, {
|
|
30
|
+
allowedTags: ['a'],
|
|
31
|
+
allowedAttributes: { a: ['href'] },
|
|
32
|
+
}),
|
|
33
|
+
).toBe('<a href="https://example.com">Safe link</a>')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('sanitizes HTML in string values', () => {
|
|
37
|
+
const input = {
|
|
38
|
+
name: '<script>alert("xss")</script>John',
|
|
39
|
+
bio: '<p>Hello <b>World</b> <script>alert("xss")</script></p>',
|
|
40
|
+
link: '<a href="javascript:alert(1)">Click me</a>',
|
|
41
|
+
safeLink: '<a href="https://example.com">Safe link</a>',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const expected = {
|
|
45
|
+
name: 'John',
|
|
46
|
+
bio: 'Hello <b>World</b> ',
|
|
47
|
+
link: '<a>Click me</a>',
|
|
48
|
+
safeLink: '<a href="https://example.com">Safe link</a>',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
expect(
|
|
52
|
+
sanitize(input, {
|
|
53
|
+
allowedTags: ['b', 'i', 'em', 'strong', 'a'],
|
|
54
|
+
allowedAttributes: { a: ['href'] },
|
|
55
|
+
}),
|
|
56
|
+
).toEqual(expected)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('sanitizes nested objects with HTML', () => {
|
|
60
|
+
const input = {
|
|
61
|
+
user: {
|
|
62
|
+
name: '<script>alert("xss")</script>John',
|
|
63
|
+
profile: {
|
|
64
|
+
bio: '<p>Hello <b>World</b></p>',
|
|
65
|
+
links: [
|
|
66
|
+
'<a href="javascript:alert(1)">Bad</a>',
|
|
67
|
+
'<a href="https://good.com">Good</a>',
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const expected = {
|
|
74
|
+
user: {
|
|
75
|
+
name: 'John',
|
|
76
|
+
profile: {
|
|
77
|
+
bio: 'Hello <b>World</b>',
|
|
78
|
+
links: ['<a>Bad</a>', '<a href="https://good.com">Good</a>'],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
expect(
|
|
84
|
+
sanitize(input, {
|
|
85
|
+
allowedTags: ['b', 'i', 'em', 'strong', 'a'],
|
|
86
|
+
allowedAttributes: { a: ['href'] },
|
|
87
|
+
}),
|
|
88
|
+
).toEqual(expected)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('preserves non-string values and sanitizes arrays correctly', () => {
|
|
92
|
+
const input = {
|
|
93
|
+
name: '<script>alert("xss")</script>John',
|
|
94
|
+
age: 25,
|
|
95
|
+
active: true,
|
|
96
|
+
email: null,
|
|
97
|
+
role: undefined,
|
|
98
|
+
tags: ['<script>alert(1)</script>admin', 'user'],
|
|
99
|
+
numbers: [1, 2, 3],
|
|
100
|
+
mixed: ['<b>bold</b>', 42, null, '<script>alert(1)</script>text'],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const expected = {
|
|
104
|
+
name: 'John',
|
|
105
|
+
age: 25,
|
|
106
|
+
active: true,
|
|
107
|
+
email: null,
|
|
108
|
+
role: undefined,
|
|
109
|
+
tags: ['admin', 'user'],
|
|
110
|
+
numbers: [1, 2, 3],
|
|
111
|
+
mixed: ['<b>bold</b>', 42, null, 'text'],
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
sanitize(input, {
|
|
116
|
+
allowedTags: ['b', 'i', 'em', 'strong', 'a'],
|
|
117
|
+
allowedAttributes: { a: ['href'] },
|
|
118
|
+
}),
|
|
119
|
+
).toEqual(expected)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('allows custom sanitizer options', () => {
|
|
123
|
+
const input = {
|
|
124
|
+
content: '<p>Hello <span>World</span> <script>alert(1)</script></p>',
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Test with custom allowed tags
|
|
128
|
+
const withSpan = sanitize(input, {
|
|
129
|
+
allowedTags: ['b', 'i', 'em', 'strong', 'a', 'span'],
|
|
130
|
+
allowedAttributes: { a: ['href'] },
|
|
131
|
+
})
|
|
132
|
+
expect(withSpan.content).toBe('Hello <span>World</span> ')
|
|
133
|
+
|
|
134
|
+
// Test with no allowed tags
|
|
135
|
+
const noTags = sanitize(input, {
|
|
136
|
+
allowedTags: [],
|
|
137
|
+
allowedAttributes: { a: ['href'] },
|
|
138
|
+
})
|
|
139
|
+
expect(noTags.content).toBe('Hello World ')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@splendidlabz/utils",
|
|
3
|
-
"version": "1.5.0-
|
|
3
|
+
"version": "1.5.0-beta.5",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -19,12 +19,14 @@
|
|
|
19
19
|
},
|
|
20
20
|
"author": "Zell Liew <zellwk@gmail.com>",
|
|
21
21
|
"dependencies": {
|
|
22
|
+
"deep-eql": "^5.0.2",
|
|
22
23
|
"dompurify": "^3.2.4",
|
|
23
24
|
"glob-promise": "^6.0.7",
|
|
24
25
|
"marked": "^15.0.7",
|
|
25
26
|
"marked-gfm-heading-id": "^4.1.1",
|
|
26
27
|
"marked-mangle": "^1.1.10",
|
|
27
28
|
"pluralize": "^8.0.0",
|
|
29
|
+
"sanitize-html": "^2.17.0",
|
|
28
30
|
"statuses": "^2.0.1"
|
|
29
31
|
},
|
|
30
32
|
"devDependencies": {
|