@splendidlabz/utils 1.5.0-alpha.4 → 1.5.0-beta.6

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @splendidlabz/utils
2
2
 
3
+ ## 1.5.0-beta.6
4
+
5
+ ### Minor Changes
6
+
7
+ - Add utilities for SSE
8
+
9
+ ## 1.5.0-beta.5
10
+
11
+ ### Minor Changes
12
+
13
+ - Ready for next release
14
+
3
15
  ## 1.5.0-alpha.4
4
16
 
5
17
  ### Patch Changes
@@ -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-scroll')) return
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, '--scrollSnapDelay'),
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'
@@ -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
  }
@@ -2,10 +2,40 @@
2
2
  import { dispatchEvent } from '../events.js'
3
3
  import { useObserverMethodOnTarget } from './observer.js'
4
4
 
5
+ /**
6
+ * Creates and manages a ResizeObserver instance to monitor size changes of a target element.
7
+ *
8
+ * @param {Element|Window|NodeList|Element[]} target - The element(s) to observe.
9
+ * - If window is provided, document.body will be observed instead.
10
+ * - If NodeList or Array of elements is provided, all elements will be observed.
11
+ * @param {Object} options - Configuration options for the resize observer
12
+ * @param {boolean} [options.observe=true] - Whether to start observing immediately. If false, the observer won't be created.
13
+ * @param {Function} [options.callback] - Optional callback function that will be called when resize changes are detected.
14
+ * If not provided, a 'resize-obs' event will be dispatched on the target.
15
+ * @param {Object} [options...] - Additional options to pass to ResizeObserver.observe()
16
+ *
17
+ * @returns {Object} An object with methods to control the observer:
18
+ * - observe(target, options): Start observing a new target element
19
+ * - unobserve(target): Stop observing a target element
20
+ * - disconnect(): Disconnect the observer and stop all observations
21
+ * - destroy(): Alias for disconnect()
22
+ *
23
+ * @example
24
+ * // Basic usage with callback
25
+ * resizeObserver(element, {
26
+ * callback: ({ entry, entries, observer }) => {
27
+ * console.log('Element resized:', entry.contentRect);
28
+ * }
29
+ * });
30
+ *
31
+ * @example
32
+ * // Usage with event listener
33
+ * resizeObserver(element);
34
+ * element.addEventListener('resize-obs', ({ detail }) => {
35
+ * console.log('Element resized:', detail.entry.contentRect);
36
+ * });
37
+ */
5
38
  export function resizeObserver(target, options) {
6
- // We set the option here to prevent observer from being created unless necessary.
7
- options = { observe: true, ...options }
8
- if (!options.observe) return
9
39
  const { callback, ...opts } = options
10
40
  const observer = new ResizeObserver(observerFn)
11
41
 
@@ -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
+ }
@@ -1,85 +1,5 @@
1
- import { getNestedValue } from '../objects/nested-property.js'
2
-
3
- export function last(array, index) {
4
- return index === array.length - 1
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
+ }
@@ -0,0 +1,3 @@
1
+ export function uniqueArray(array) {
2
+ return Array.from(new Set(array))
3
+ }
package/lib/date/index.js CHANGED
@@ -1,2 +1,3 @@
1
- export * from './months.js'
2
1
  export * from './days.js'
2
+ export * from './months.js'
3
+ export * from './time.js'
@@ -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
+ }
@@ -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
- * Sanitizes HTML string using DOMPurify.
6
- * @param {string} html - The HTML string to sanitize.
7
- * @param {Object} [options={}] - DOMPurify configuration options.
8
- * @returns {string} The sanitized HTML string.
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(html, options = {}) {
11
- return DOMPurify.sanitize(html, options)
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
- * Recursively sanitizes entries in an object or array.
16
- * @param {Array} entries - The entries to sanitize.
17
- * @param {Object} [options={}] - DOMPurify configuration options.
18
- * @returns {Array} The sanitized entries.
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
- export function sanitizeEntries(entries, options = {}) {
21
- return entries.map(([key, value]) => {
22
- if (Array.isArray(value)) {
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 - The object to sanitize.
43
- * @param {Object} [options={}] - DOMPurify configuration options.
44
- * @returns {Object} A new object with all string values sanitized.
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
- export function sanitizeObject(obj, options = {}) {
47
- return Object.fromEntries(sanitizeEntries(Object.entries(obj), options))
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/index.js CHANGED
@@ -7,6 +7,7 @@ export * from './functions/index.js'
7
7
  export * from './numbers/index.js'
8
8
  export * from './objects/index.js'
9
9
  export * from './promises/index.js'
10
+ export * from './sse.js'
10
11
  export * from './strings/index.js'
11
12
  export * from './style/index.js'
12
13
  export * from './symbols/index.js'
@@ -0,0 +1,9 @@
1
+ import deepEqual from 'deep-eql'
2
+
3
+ export function isEqual(a, b) {
4
+ return deepEqual(a, b)
5
+ }
6
+
7
+ export function notEqual(a, b) {
8
+ return !isEqual(a, b)
9
+ }
@@ -1,5 +1,6 @@
1
1
  export * from './camelcase-keys.js'
2
2
  export * from './empty.js'
3
+ export * from './equal.js'
3
4
  export * from './extend.js'
4
5
  export * from './flatten.js'
5
6
  export * from './json.js'
package/lib/sse.js ADDED
@@ -0,0 +1,64 @@
1
+ // Server Sent Events (SSE)
2
+ import { omitEmpty } from './objects/omit-empty.js'
3
+
4
+ export function createSSE({ event, data, id, retry }) {
5
+ const eventString = event ? `event: ${event}\n` : ''
6
+ const idString = id ? `id: ${id}\n` : ''
7
+ const retryString = retry ? `retry: ${retry}\n` : ''
8
+
9
+ if (typeof data === 'object') data = JSON.stringify(data)
10
+ const dataString = data ? `data: ${data}\n` : ''
11
+
12
+ return `${eventString}${dataString}${idString}${retryString}\n`
13
+ }
14
+
15
+ /**
16
+ * Parses a single SSE message into a structured object
17
+ * @param {string} message - The raw SSE message
18
+ * @returns {Object|null} The parsed message or null if invalid
19
+ * @property {string} [event] - The event type if specified
20
+ * @property {string|Object} data - The message data (parsed as JSON if possible)
21
+ * @property {string} [id] - The message ID if specified
22
+ * @property {number} [retry] - The retry interval if specified
23
+ */
24
+ export function parseSSE(message) {
25
+ // console.log('parsing SSE', message);
26
+ const lines = message.split('\n')
27
+ const result = {
28
+ event: 'message', // Default event type
29
+ data: '',
30
+ id: null,
31
+ retry: null
32
+ }
33
+
34
+ for (const line of lines) {
35
+ if (line.startsWith('data:')) {
36
+ const data = line.slice(5).trim()
37
+ // Try to parse as JSON
38
+ try { result.data = JSON.parse(data) }
39
+ // If not JSON, use as string
40
+ catch { result.data = data }
41
+ continue
42
+ }
43
+
44
+ if (line.startsWith('event:')) {
45
+ result.event = line.slice(6).trim()
46
+ continue
47
+ }
48
+
49
+ if (line.startsWith('id:')) {
50
+ result.id = line.slice(3).trim()
51
+ continue
52
+ }
53
+
54
+ if (line.startsWith('retry:')) {
55
+ result.retry = parseInt(line.slice(6).trim(), 10)
56
+ continue
57
+ }
58
+ }
59
+
60
+
61
+ const ret = omitEmpty(result)
62
+ // Only return if we have data
63
+ return ret.data ? ret : null
64
+ }
package/node/dirname.js CHANGED
@@ -1,5 +1,5 @@
1
+ import path from 'node:path'
1
2
  import { fileURLToPath } from 'node:url'
2
- import path from 'path'
3
3
 
4
4
  /**
5
5
  * Returns __dirname
package/node/index.js CHANGED
@@ -4,3 +4,4 @@ export * from './file-cache.js'
4
4
  export * from './hash.js'
5
5
  export * from './pkce.js'
6
6
  export * from './random-string.js'
7
+ export * from './sanitize.js'
@@ -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-alpha.4",
3
+ "version": "1.5.0-beta.6",
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": {