@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.
@@ -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
@@ -0,0 +1,4 @@
1
+ import { createValidator } from './helpers/validator';
2
+ import { htmlEntityDecode } from './helpers/sanitize';
3
+
4
+ export { createValidator, htmlEntityDecode };
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
+ }