-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Expand file tree
/
Copy pathstring.ts
More file actions
143 lines (133 loc) · 5.12 KB
/
Copy pathstring.ts
File metadata and controls
143 lines (133 loc) · 5.12 KB
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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
/**
* Truncates `str` if it exceeds `sliceLength` characters, appending `suffix`.
* The total output length when truncated is `sliceLength + suffix.length`.
* Defaults suffix to `'...'`.
*
* @example
* truncate('hello world', 8) // 'hello wo...' (11 chars)
* truncate('hello world', 8, ' …') // 'hello wo …'
* truncate('hi', 10) // 'hi'
*/
export function truncate(str: string, sliceLength: number, suffix = '...'): string {
return str.length > sliceLength ? str.slice(0, sliceLength) + suffix : str
}
/**
* Strips a trailing `_vN` version suffix from `value`, yielding the base type.
* Only the single trailing suffix is removed; leading occurrences are left intact.
*
* @example
* stripVersionSuffix('notion_search_v2') // 'notion_search'
* stripVersionSuffix('x') // 'x'
* stripVersionSuffix('a_v2_v3') // 'a_v2'
*/
export function stripVersionSuffix(value: string): string {
return value.replace(/_v\d+$/, '')
}
/**
* Tests whether `value` ends with a `_vN` version suffix.
* Only a trailing suffix counts; a leading or embedded `_vN` does not match.
*
* @example
* isVersionedType('notion_search_v2') // true
* isVersionedType('plain') // false
* isVersionedType('a_version') // false
*/
export function isVersionedType(value: string): boolean {
return /_v\d+$/.test(value)
}
/**
* Normalizes an email address for comparison and storage by trimming
* surrounding whitespace and lowercasing.
*/
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase()
}
/**
* RFC 5322-shaped syntax gate for a full address. Format only — domain
* reputation, MX/DNS, and membership policy are the caller's concern.
*/
const EMAIL_SYNTAX_REGEX =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
/**
* Bare `@domain` pattern, for allowlists that grant access to a whole domain.
* Single-label domains (`@intranet`) are allowed — self-hosted deployments use
* them — but a lone `@` and malformed labels are not.
*/
const EMAIL_DOMAIN_SYNTAX_REGEX =
/^@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
/**
* Format-only email syntax check, capped at the RFC 5321 length limit.
*
* @param allowDomains - also accept a bare `@domain` entry, for allowlists that
* grant access to an entire domain rather than a single address.
*/
export function isValidEmailSyntax(email: string, allowDomains = false): boolean {
if (email.length > 254) return false
return EMAIL_SYNTAX_REGEX.test(email) || (allowDomains && EMAIL_DOMAIN_SYNTAX_REGEX.test(email))
}
/**
* Matches UTF-16 code units that Postgres JSONB rejects: unpaired surrogate
* halves (e.g. produced by `slice()` cutting an astral character like 𝐀 in
* half) and the NUL character, which jsonb cannot store at all.
*/
const JSONB_UNSAFE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]|\u0000/g
/**
* Replaces unpaired UTF-16 surrogates and NUL characters with U+FFFD (�) so
* the string can be stored in a Postgres `jsonb` column. Well-formed
* surrogate pairs (emoji, mathematical alphanumerics, etc.) pass through
* untouched.
*/
export function sanitizeForJsonb(str: string): string {
return str.replace(JSONB_UNSAFE, '\uFFFD')
}
/**
* Recursively applies {@link sanitizeForJsonb} to every string (values AND
* keys) reachable from `value`. Use on untrusted payloads immediately before
* writing them to a `jsonb` column; returns the input unchanged (same
* reference) when nothing needs rewriting.
*/
export function sanitizeValueForJsonb<T>(value: T): T {
if (typeof value === 'string') {
const clean = sanitizeForJsonb(value)
return (clean === value ? value : clean) as T
}
if (Array.isArray(value)) {
let changed = false
const result = value.map((item) => {
const clean = sanitizeValueForJsonb(item)
if (clean !== item) changed = true
return clean
})
return (changed ? result : value) as T
}
if (typeof value === 'object' && value !== null) {
let changed = false
const result: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
const cleanKey = sanitizeForJsonb(key)
const cleanItem = sanitizeValueForJsonb(item)
if (cleanKey !== key || cleanItem !== item) changed = true
result[cleanKey] = cleanItem
}
return (changed ? result : value) as T
}
return value
}
/**
* Formats a list of names as quoted values with an overflow tail, listing at
* most `maxListed` names.
*
* @example
* formatQuotedNameList(['A', 'B'], 3) // '"A", "B"'
* formatQuotedNameList(['A', 'B', 'C', 'D'], 3) // '"A", "B", "C" and 1 more'
* formatQuotedNameList([], 3) // ''
*/
export function formatQuotedNameList(names: string[], maxListed: number): string {
const listed = names
.slice(0, maxListed)
.map((name) => `"${name}"`)
.join(', ')
const overflow = names.length - maxListed
return overflow > 0 ? `${listed} and ${overflow} more` : listed
}