@excofy/utils 1.0.0
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/.eslintrc.json +9 -0
- package/.github/workflows/publish.yml +32 -0
- package/.prettierrc +4 -0
- package/dist/index.cjs +412 -0
- package/dist/index.d.cts +88 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.js +384 -0
- package/package.json +43 -0
- package/src/helpers/sanitize.ts +115 -0
- package/src/helpers/validator.ts +427 -0
- package/src/helpers/video.ts +28 -0
- package/src/index.ts +4 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { sanitizeValue, type AllowedTag } from './sanitize';
|
|
2
|
+
import { video } from './video';
|
|
3
|
+
|
|
4
|
+
type TMessage = { message: string };
|
|
5
|
+
type TValue = string | number | boolean | File | object | null | undefined;
|
|
6
|
+
type TInputValue = TValue;
|
|
7
|
+
type TTypes =
|
|
8
|
+
| 'string'
|
|
9
|
+
| 'number'
|
|
10
|
+
| 'boolean'
|
|
11
|
+
| 'object'
|
|
12
|
+
| 'array'
|
|
13
|
+
| 'file'
|
|
14
|
+
| 'date';
|
|
15
|
+
|
|
16
|
+
interface IInputErrors {
|
|
17
|
+
[key: string]: TMessage[] | IInputErrors[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Cria um validador de inputs com suporte a validações encadeadas, transformação e sanitização.
|
|
22
|
+
*
|
|
23
|
+
* @template TRaw Tipo dos dados recebidos originalmente (geralmente string, vindo do query, body ou params).
|
|
24
|
+
* @template TParsed Tipo final retornado por `getValidatedInputs()` após transformações.
|
|
25
|
+
*
|
|
26
|
+
* @returns Um objeto com métodos para validação, transformação, sanitização e extração dos dados validados.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* type InputRaw = { limit?: string }
|
|
30
|
+
* type InputParsed = { limit?: number }
|
|
31
|
+
*
|
|
32
|
+
* const v = createValidator<InputRaw, InputParsed>()
|
|
33
|
+
* v.setInputs({ limit: '10' })
|
|
34
|
+
* v.validate('limit').type('string', 'Invalid').transform(Number)
|
|
35
|
+
* const inputs = v.getValidatedInputs() // { limit: 10 }
|
|
36
|
+
*/
|
|
37
|
+
export function createValidator<
|
|
38
|
+
TRaw extends Record<string, TInputValue>,
|
|
39
|
+
TParsed = TRaw
|
|
40
|
+
>() {
|
|
41
|
+
const current = {
|
|
42
|
+
field: '' as keyof TRaw,
|
|
43
|
+
value: undefined as TInputValue,
|
|
44
|
+
required: false,
|
|
45
|
+
inputs: {} as TRaw,
|
|
46
|
+
errors: {} as IInputErrors,
|
|
47
|
+
typeValid: false as boolean,
|
|
48
|
+
setInput(field: keyof TRaw, value?: TInputValue) {
|
|
49
|
+
this.field = field;
|
|
50
|
+
this.value = value;
|
|
51
|
+
},
|
|
52
|
+
pushError(message: string) {
|
|
53
|
+
const key = String(this.field);
|
|
54
|
+
if (!this.errors[key]) this.errors[key] = [];
|
|
55
|
+
(this.errors[key] as TMessage[]).push({ message });
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const validatorRequired = {
|
|
60
|
+
isRequired(message: string) {
|
|
61
|
+
current.required = true;
|
|
62
|
+
if (current.value === undefined || current.value === null) {
|
|
63
|
+
current.pushError(message);
|
|
64
|
+
}
|
|
65
|
+
return validatorType;
|
|
66
|
+
},
|
|
67
|
+
isNotRequired() {
|
|
68
|
+
current.required = false;
|
|
69
|
+
return validatorType;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const validatorType = {
|
|
74
|
+
type(type: TTypes, message: string) {
|
|
75
|
+
if (shouldSkipValidation()) {
|
|
76
|
+
return validator;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const value = current.value;
|
|
80
|
+
let isValidType = false;
|
|
81
|
+
|
|
82
|
+
if (type === 'array') {
|
|
83
|
+
isValidType = Array.isArray(value);
|
|
84
|
+
} else if (type === 'object') {
|
|
85
|
+
isValidType =
|
|
86
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
87
|
+
} else if (type === 'file') {
|
|
88
|
+
isValidType = value instanceof File;
|
|
89
|
+
} else if (type === 'date') {
|
|
90
|
+
isValidType =
|
|
91
|
+
typeof value === 'string' && !Number.isNaN(new Date(value).getTime());
|
|
92
|
+
} else {
|
|
93
|
+
isValidType =
|
|
94
|
+
(type === 'string' && typeof value === 'string') ||
|
|
95
|
+
(type === 'number' && typeof value === 'number') ||
|
|
96
|
+
(type === 'boolean' && typeof value === 'boolean');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
current.typeValid = isValidType;
|
|
100
|
+
|
|
101
|
+
if (!isValidType) {
|
|
102
|
+
current.pushError(message);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return validator;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const shouldSkipValidation = () => {
|
|
110
|
+
return (
|
|
111
|
+
!current.required &&
|
|
112
|
+
(current.value === undefined ||
|
|
113
|
+
current.value === null ||
|
|
114
|
+
current.value === '')
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const validator = {
|
|
119
|
+
each(
|
|
120
|
+
callback: <U extends Record<string, TInputValue>>(
|
|
121
|
+
item: U,
|
|
122
|
+
index: number,
|
|
123
|
+
subValidator: ReturnType<typeof createValidator<U>>
|
|
124
|
+
) => void
|
|
125
|
+
) {
|
|
126
|
+
if (shouldSkipValidation()) {
|
|
127
|
+
return validator;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!Array.isArray(current.value)) return validator;
|
|
131
|
+
|
|
132
|
+
const fieldName = String(current.field);
|
|
133
|
+
|
|
134
|
+
for (let index = 0; index < current.value.length; index++) {
|
|
135
|
+
const item = current.value[index];
|
|
136
|
+
const subValidator = createValidator<typeof item>();
|
|
137
|
+
subValidator.setInputs(item);
|
|
138
|
+
callback(item, index, subValidator);
|
|
139
|
+
|
|
140
|
+
const subErrors = subValidator.getFlatErrors();
|
|
141
|
+
if (subErrors) {
|
|
142
|
+
for (const [subField, messages] of Object.entries(subErrors)) {
|
|
143
|
+
const path = `${fieldName}[${index}].${subField}`;
|
|
144
|
+
(current.errors as Record<string, TMessage[]>)[path] = messages;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (Array.isArray(current.value)) {
|
|
149
|
+
current.value[index] = subValidator.getSanitizedInputs();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
current.inputs[current.field] = current.value as TRaw[keyof TRaw];
|
|
154
|
+
return validator;
|
|
155
|
+
},
|
|
156
|
+
email: (message: string) => {
|
|
157
|
+
if (shouldSkipValidation()) {
|
|
158
|
+
return validator;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (typeof current.value === 'string') {
|
|
162
|
+
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
163
|
+
if (!regex.test(current.value)) {
|
|
164
|
+
current.pushError(message);
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
current.pushError(message);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return validator;
|
|
171
|
+
},
|
|
172
|
+
fileMaxSize: (maxSize: number, message: string) => {
|
|
173
|
+
if (shouldSkipValidation()) {
|
|
174
|
+
return validator;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const value = current.value;
|
|
178
|
+
|
|
179
|
+
if (!(value instanceof File)) {
|
|
180
|
+
current.pushError('Arquivo inválido');
|
|
181
|
+
return validator;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (value.size > maxSize) {
|
|
185
|
+
current.pushError(message);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return validator;
|
|
189
|
+
},
|
|
190
|
+
fileType: (validTypes: string[], message: string) => {
|
|
191
|
+
if (shouldSkipValidation()) {
|
|
192
|
+
return validator;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const value = current.value;
|
|
196
|
+
|
|
197
|
+
if (!(value instanceof File)) {
|
|
198
|
+
current.pushError('Arquivo inválido');
|
|
199
|
+
return validator;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!validTypes.includes(value.type)) {
|
|
203
|
+
current.pushError(message);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return validator;
|
|
207
|
+
},
|
|
208
|
+
length(length: number, message: string) {
|
|
209
|
+
if (shouldSkipValidation()) {
|
|
210
|
+
return validator;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const value = current.value;
|
|
214
|
+
const isValid =
|
|
215
|
+
(typeof value === 'string' || Array.isArray(value)) &&
|
|
216
|
+
value.length === length;
|
|
217
|
+
|
|
218
|
+
if (!isValid) {
|
|
219
|
+
current.pushError(message);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return validator;
|
|
223
|
+
},
|
|
224
|
+
min(min: number, message: string) {
|
|
225
|
+
if (shouldSkipValidation()) {
|
|
226
|
+
return validator;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (typeof current.value === 'string' && current.value.length < min)
|
|
230
|
+
current.pushError(message);
|
|
231
|
+
else if (typeof current.value === 'number' && current.value < min)
|
|
232
|
+
current.pushError(message);
|
|
233
|
+
else if (Array.isArray(current.value) && current.value.length < min)
|
|
234
|
+
current.pushError(message);
|
|
235
|
+
return validator;
|
|
236
|
+
},
|
|
237
|
+
max(max: number, message: string) {
|
|
238
|
+
if (shouldSkipValidation()) {
|
|
239
|
+
return validator;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (typeof current.value === 'string' && current.value.length > max)
|
|
243
|
+
current.pushError(message);
|
|
244
|
+
else if (typeof current.value === 'number' && current.value > max)
|
|
245
|
+
current.pushError(message);
|
|
246
|
+
return validator;
|
|
247
|
+
},
|
|
248
|
+
transform<U>(fn: (value: TInputValue) => U) {
|
|
249
|
+
if (shouldSkipValidation()) return validator;
|
|
250
|
+
|
|
251
|
+
const transformed = fn(current.value);
|
|
252
|
+
current.value = transformed as TInputValue;
|
|
253
|
+
current.inputs[current.field] = transformed as TRaw[keyof TRaw];
|
|
254
|
+
return validator;
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
slug(message: string) {
|
|
258
|
+
if (shouldSkipValidation()) {
|
|
259
|
+
return validator;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (typeof current.value === 'string') {
|
|
263
|
+
const regex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
264
|
+
if (!regex.test(current.value)) current.pushError(message);
|
|
265
|
+
}
|
|
266
|
+
return validator;
|
|
267
|
+
},
|
|
268
|
+
sanitize() {
|
|
269
|
+
if (shouldSkipValidation()) {
|
|
270
|
+
return validator;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (typeof current.value === 'string') {
|
|
274
|
+
const newValue = sanitizeValue(current.value, [])
|
|
275
|
+
.replace(/\s+/g, ' ')
|
|
276
|
+
.trim();
|
|
277
|
+
current.value = newValue;
|
|
278
|
+
current.inputs[current.field] = newValue as TRaw[keyof TRaw];
|
|
279
|
+
}
|
|
280
|
+
return validator;
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
sanitizeHTML(tags?: AllowedTag[]) {
|
|
284
|
+
if (shouldSkipValidation()) {
|
|
285
|
+
return validator;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (typeof current.value === 'string') {
|
|
289
|
+
const newValue = sanitizeValue(current.value, tags);
|
|
290
|
+
current.value = newValue;
|
|
291
|
+
current.inputs[current.field] = newValue as TRaw[keyof TRaw];
|
|
292
|
+
}
|
|
293
|
+
return validator;
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
uuid(message: string) {
|
|
297
|
+
if (shouldSkipValidation()) {
|
|
298
|
+
return validator;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (typeof current.value === 'string') {
|
|
302
|
+
const regex =
|
|
303
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
304
|
+
if (!regex.test(current.value)) current.pushError(message);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return validator;
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
oneOf: (types: string[], message: string) => {
|
|
311
|
+
if (shouldSkipValidation()) {
|
|
312
|
+
return validator;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (types.includes(String(current.value))) {
|
|
316
|
+
return validator;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
current.pushError(message);
|
|
320
|
+
return validator;
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
videoUrl: {
|
|
324
|
+
youtube(message: string) {
|
|
325
|
+
if (shouldSkipValidation()) {
|
|
326
|
+
return validator;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (
|
|
330
|
+
typeof current.value === 'string' &&
|
|
331
|
+
!video.youtube.isValid(current.value)
|
|
332
|
+
) {
|
|
333
|
+
current.pushError(message);
|
|
334
|
+
}
|
|
335
|
+
return validator;
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
getErrors(): IInputErrors | undefined {
|
|
342
|
+
return Object.keys(current.errors).length > 0
|
|
343
|
+
? current.errors
|
|
344
|
+
: undefined;
|
|
345
|
+
},
|
|
346
|
+
getFlatErrors(): Record<string, TMessage[]> {
|
|
347
|
+
const flatErrors: Record<string, TMessage[]> = {};
|
|
348
|
+
|
|
349
|
+
function flatten(
|
|
350
|
+
prefix: string,
|
|
351
|
+
obj: IInputErrors | TMessage[] | IInputErrors[] | undefined
|
|
352
|
+
) {
|
|
353
|
+
if (!obj) return;
|
|
354
|
+
|
|
355
|
+
if (Array.isArray(obj)) {
|
|
356
|
+
if (
|
|
357
|
+
obj.length > 0 &&
|
|
358
|
+
typeof obj[0] === 'object' &&
|
|
359
|
+
'message' in obj[0]
|
|
360
|
+
) {
|
|
361
|
+
// It's an array of TMessage
|
|
362
|
+
flatErrors[prefix] = obj as TMessage[];
|
|
363
|
+
} else if (obj.length > 0 && typeof obj[0] === 'object') {
|
|
364
|
+
// It's an array of IInputErrors
|
|
365
|
+
for (let i = 0; i < obj.length; i++) {
|
|
366
|
+
flatten(`${prefix}[${i}]`, obj[i] as IInputErrors);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
for (const key in obj) {
|
|
371
|
+
flatten(prefix ? `${prefix}.${key}` : key, obj[key]);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
flatten('', current.errors);
|
|
377
|
+
|
|
378
|
+
return Object.keys(flatErrors).length ? flatErrors : {};
|
|
379
|
+
},
|
|
380
|
+
getSanitizedInputs(): TParsed {
|
|
381
|
+
return current.inputs as unknown as TParsed;
|
|
382
|
+
},
|
|
383
|
+
hasErrors(): boolean {
|
|
384
|
+
return Object.keys(current.errors).length > 0;
|
|
385
|
+
},
|
|
386
|
+
setInputs(inputs: TRaw) {
|
|
387
|
+
current.inputs = inputs;
|
|
388
|
+
current.errors = {};
|
|
389
|
+
},
|
|
390
|
+
unflattenErrors(flat: Record<string, TMessage[]>): Record<string, unknown> {
|
|
391
|
+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
|
392
|
+
const result: any = {};
|
|
393
|
+
|
|
394
|
+
for (const flatKey in flat) {
|
|
395
|
+
const messages = flat[flatKey].map((m) => m.message).join('\n');
|
|
396
|
+
|
|
397
|
+
const pathParts = flatKey
|
|
398
|
+
.replace(/\[(\d+)\]/g, '.$1') // converte products[0].description → products.0.description
|
|
399
|
+
.split('.'); // divide em ["products", "0", "description"]
|
|
400
|
+
|
|
401
|
+
let current = result;
|
|
402
|
+
|
|
403
|
+
for (let i = 0; i < pathParts.length; i++) {
|
|
404
|
+
const part = pathParts[i];
|
|
405
|
+
|
|
406
|
+
const nextIsIndex = /^\d+$/.test(pathParts[i + 1] || '');
|
|
407
|
+
|
|
408
|
+
if (i === pathParts.length - 1) {
|
|
409
|
+
current[part] = messages;
|
|
410
|
+
} else {
|
|
411
|
+
if (!(part in current)) {
|
|
412
|
+
current[part] = nextIsIndex ? [] : {};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
current = current[part];
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return result;
|
|
421
|
+
},
|
|
422
|
+
validate<K extends keyof TRaw>(field: K) {
|
|
423
|
+
current.setInput(field, current.inputs[field]);
|
|
424
|
+
return validatorRequired;
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const video = {
|
|
2
|
+
youtube: {
|
|
3
|
+
isValid: (url: string) => {
|
|
4
|
+
const regex =
|
|
5
|
+
/^(?:(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:[^\/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)([a-zA-Z0-9_-]{11}))/g;
|
|
6
|
+
return regex.test(url);
|
|
7
|
+
},
|
|
8
|
+
getId: (url: string) => {
|
|
9
|
+
// Regex para identificar os diferentes formatos de URLs do YouTube
|
|
10
|
+
const regex =
|
|
11
|
+
/^(?:(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:[^\/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)([a-zA-Z0-9_-]{11}))/;
|
|
12
|
+
|
|
13
|
+
// Tenta fazer correspondência com a regex
|
|
14
|
+
const match = url?.match(regex);
|
|
15
|
+
|
|
16
|
+
// Se houver uma correspondência, retorna o ID do vídeo
|
|
17
|
+
if (match && match.length >= 2 && match[1]) {
|
|
18
|
+
return match[1];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Se não houver correspondência, retorna null
|
|
22
|
+
return null;
|
|
23
|
+
},
|
|
24
|
+
getEmbedUrl: (id: string) => {
|
|
25
|
+
return `https://www.youtube.com/embed/${id}`;
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
package/src/index.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"declarationMap": true,
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"moduleResolution": "Node",
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"isolatedModules": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src"],
|
|
17
|
+
"exclude": ["dist", "node_modules", "**/*.test.ts"]
|
|
18
|
+
}
|