@20syldev/api 4.0.0 → 4.2.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,300 @@
1
+ import { MAX_STRING_LENGTH } from '../../constants.js';
2
+
3
+ const LOREM_WORDS = [
4
+ 'lorem',
5
+ 'ipsum',
6
+ 'dolor',
7
+ 'sit',
8
+ 'amet',
9
+ 'consectetur',
10
+ 'adipiscing',
11
+ 'elit',
12
+ 'sed',
13
+ 'do',
14
+ 'eiusmod',
15
+ 'tempor',
16
+ 'incididunt',
17
+ 'ut',
18
+ 'labore',
19
+ 'et',
20
+ 'dolore',
21
+ 'magna',
22
+ 'aliqua',
23
+ 'enim',
24
+ 'ad',
25
+ 'minim',
26
+ 'veniam',
27
+ 'quis',
28
+ 'nostrud',
29
+ 'exercitation',
30
+ 'ullamco',
31
+ 'laboris',
32
+ 'nisi',
33
+ 'aliquip',
34
+ 'ex',
35
+ 'ea',
36
+ 'commodo',
37
+ 'consequat',
38
+ 'duis',
39
+ 'aute',
40
+ 'irure',
41
+ 'in',
42
+ 'reprehenderit',
43
+ 'voluptate',
44
+ 'velit',
45
+ 'esse',
46
+ 'cillum',
47
+ 'eu',
48
+ 'fugiat',
49
+ 'nulla',
50
+ 'pariatur',
51
+ 'excepteur',
52
+ 'sint',
53
+ 'occaecat',
54
+ 'cupidatat',
55
+ 'non',
56
+ 'proident',
57
+ 'sunt',
58
+ 'culpa',
59
+ 'qui',
60
+ 'officia',
61
+ 'deserunt',
62
+ 'mollit',
63
+ 'anim',
64
+ 'id',
65
+ 'est',
66
+ 'laborum',
67
+ ];
68
+
69
+ const FR_UNITS = [
70
+ 'zéro',
71
+ 'un',
72
+ 'deux',
73
+ 'trois',
74
+ 'quatre',
75
+ 'cinq',
76
+ 'six',
77
+ 'sept',
78
+ 'huit',
79
+ 'neuf',
80
+ 'dix',
81
+ 'onze',
82
+ 'douze',
83
+ 'treize',
84
+ 'quatorze',
85
+ 'quinze',
86
+ 'seize',
87
+ 'dix-sept',
88
+ 'dix-huit',
89
+ 'dix-neuf',
90
+ ];
91
+ const FR_TENS = [
92
+ '',
93
+ '',
94
+ 'vingt',
95
+ 'trente',
96
+ 'quarante',
97
+ 'cinquante',
98
+ 'soixante',
99
+ 'soixante',
100
+ 'quatre-vingt',
101
+ 'quatre-vingt',
102
+ ];
103
+
104
+ const EN_UNITS = [
105
+ 'zero',
106
+ 'one',
107
+ 'two',
108
+ 'three',
109
+ 'four',
110
+ 'five',
111
+ 'six',
112
+ 'seven',
113
+ 'eight',
114
+ 'nine',
115
+ 'ten',
116
+ 'eleven',
117
+ 'twelve',
118
+ 'thirteen',
119
+ 'fourteen',
120
+ 'fifteen',
121
+ 'sixteen',
122
+ 'seventeen',
123
+ 'eighteen',
124
+ 'nineteen',
125
+ ];
126
+ const EN_TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
127
+
128
+ function checkText(value: string): void {
129
+ if (!value) throw new Error('A value is required');
130
+ if (typeof value !== 'string') throw new Error('Value must be a string');
131
+ if (value.length > MAX_STRING_LENGTH)
132
+ throw new Error(`Value must be less than ${MAX_STRING_LENGTH} characters long`);
133
+ }
134
+
135
+ export interface TextStats {
136
+ characters: number;
137
+ charactersNoSpaces: number;
138
+ words: number;
139
+ sentences: number;
140
+ paragraphs: number;
141
+ readingTime: string;
142
+ mostFrequentChar: string;
143
+ }
144
+
145
+ export function stats(value: string): TextStats {
146
+ checkText(value);
147
+
148
+ const characters = value.length;
149
+ const charactersNoSpaces = value.replace(/\s/g, '').length;
150
+ const words = value.trim().split(/\s+/).filter(Boolean).length;
151
+ const sentences = value.split(/[.!?]+/).filter((s) => s.trim().length > 0).length;
152
+ const paragraphs = value.split(/\n\s*\n/).filter((p) => p.trim().length > 0).length || 1;
153
+
154
+ const minutes = words / 200;
155
+ const readingTime = minutes < 1 ? `${Math.ceil(minutes * 60)}s` : `${Math.round(minutes)}min`;
156
+
157
+ const counts = new Map<string, number>();
158
+ for (const c of value.replace(/\s/g, '').toLowerCase()) counts.set(c, (counts.get(c) ?? 0) + 1);
159
+ let mostFrequentChar = '';
160
+ let maxCount = 0;
161
+ for (const [char, count] of counts) {
162
+ if (count > maxCount) {
163
+ mostFrequentChar = char;
164
+ maxCount = count;
165
+ }
166
+ }
167
+
168
+ return { characters, charactersNoSpaces, words, sentences, paragraphs, readingTime, mostFrequentChar };
169
+ }
170
+
171
+ export function slug(value: string): string {
172
+ checkText(value);
173
+ return value
174
+ .normalize('NFD')
175
+ .replace(/[\u0300-\u036f]/g, '')
176
+ .toLowerCase()
177
+ .replace(/[^a-z0-9\s-]/g, '')
178
+ .trim()
179
+ .replace(/[\s-]+/g, '-');
180
+ }
181
+
182
+ export function lorem(type: string, count: string): string {
183
+ const n = Number(count) || 5;
184
+ if (n < 1 || n > 500) throw new Error('Count must be between 1 and 500');
185
+
186
+ const randomWord = (): string => LOREM_WORDS[Math.floor(Math.random() * LOREM_WORDS.length)]!;
187
+ const randomSentence = (): string => {
188
+ const length = 5 + Math.floor(Math.random() * 10);
189
+ const words = Array.from({ length }, randomWord);
190
+ words[0] = words[0]!.charAt(0).toUpperCase() + words[0]!.slice(1);
191
+ return words.join(' ') + '.';
192
+ };
193
+ const randomParagraph = (): string => {
194
+ const length = 3 + Math.floor(Math.random() * 5);
195
+ return Array.from({ length }, randomSentence).join(' ');
196
+ };
197
+
198
+ switch (type) {
199
+ case 'words':
200
+ return Array.from({ length: n }, randomWord).join(' ');
201
+ case 'sentences':
202
+ return Array.from({ length: n }, randomSentence).join(' ');
203
+ case 'paragraphs':
204
+ return Array.from({ length: n }, randomParagraph).join('\n\n');
205
+ default:
206
+ throw new Error('Type must be one of: words, sentences, paragraphs');
207
+ }
208
+ }
209
+
210
+ function frBelowHundred(n: number): string {
211
+ if (n < 20) return FR_UNITS[n]!;
212
+ const t = Math.floor(n / 10);
213
+ const u = n % 10;
214
+ if (t === 7 || t === 9) {
215
+ const base = FR_TENS[t]!;
216
+ const sub = 10 + u;
217
+ return base + (base.endsWith('-') ? '' : '-') + FR_UNITS[sub];
218
+ }
219
+ if (t === 8 && u === 0) return 'quatre-vingts';
220
+ if (u === 0) return FR_TENS[t]!;
221
+ if (u === 1 && t < 8) return FR_TENS[t] + ' et un';
222
+ return FR_TENS[t] + '-' + FR_UNITS[u];
223
+ }
224
+
225
+ function frBelowThousand(n: number): string {
226
+ if (n < 100) return frBelowHundred(n);
227
+ const h = Math.floor(n / 100);
228
+ const r = n % 100;
229
+ let result = '';
230
+ if (h === 1) result = 'cent';
231
+ else result = FR_UNITS[h] + ' cent' + (r === 0 ? 's' : '');
232
+ if (r > 0) result += ' ' + frBelowHundred(r);
233
+ return result;
234
+ }
235
+
236
+ function numberToFrench(n: number): string {
237
+ if (n === 0) return 'zéro';
238
+ if (n < 0) return 'moins ' + numberToFrench(-n);
239
+ if (n < 1000) return frBelowThousand(n);
240
+ if (n < 1_000_000) {
241
+ const t = Math.floor(n / 1000);
242
+ const r = n % 1000;
243
+ const thousands = t === 1 ? 'mille' : frBelowThousand(t) + ' mille';
244
+ return r === 0 ? thousands : thousands + ' ' + frBelowThousand(r);
245
+ }
246
+ if (n < 1_000_000_000) {
247
+ const m = Math.floor(n / 1_000_000);
248
+ const r = n % 1_000_000;
249
+ const millions = m === 1 ? 'un million' : frBelowThousand(m) + ' millions';
250
+ return r === 0 ? millions : millions + ' ' + numberToFrench(r);
251
+ }
252
+ throw new Error('Number must be less than 1 billion');
253
+ }
254
+
255
+ function enBelowHundred(n: number): string {
256
+ if (n < 20) return EN_UNITS[n]!;
257
+ const t = Math.floor(n / 10);
258
+ const u = n % 10;
259
+ return u === 0 ? EN_TENS[t]! : EN_TENS[t] + '-' + EN_UNITS[u];
260
+ }
261
+
262
+ function enBelowThousand(n: number): string {
263
+ if (n < 100) return enBelowHundred(n);
264
+ const h = Math.floor(n / 100);
265
+ const r = n % 100;
266
+ return r === 0 ? EN_UNITS[h] + ' hundred' : EN_UNITS[h] + ' hundred ' + enBelowHundred(r);
267
+ }
268
+
269
+ function numberToEnglish(n: number): string {
270
+ if (n === 0) return 'zero';
271
+ if (n < 0) return 'minus ' + numberToEnglish(-n);
272
+ if (n < 1000) return enBelowThousand(n);
273
+ if (n < 1_000_000) {
274
+ const t = Math.floor(n / 1000);
275
+ const r = n % 1000;
276
+ return r === 0 ? enBelowThousand(t) + ' thousand' : enBelowThousand(t) + ' thousand ' + enBelowThousand(r);
277
+ }
278
+ if (n < 1_000_000_000) {
279
+ const m = Math.floor(n / 1_000_000);
280
+ const r = n % 1_000_000;
281
+ return r === 0 ? enBelowThousand(m) + ' million' : enBelowThousand(m) + ' million ' + numberToEnglish(r);
282
+ }
283
+ throw new Error('Number must be less than 1 billion');
284
+ }
285
+
286
+ export function number(value: string, lang: string): string {
287
+ const n = Number(value);
288
+ if (isNaN(n)) throw new Error('Value must be a number');
289
+ if (!Number.isInteger(n)) throw new Error('Value must be an integer');
290
+ if (Math.abs(n) >= 1_000_000_000) throw new Error('Number must be less than 1 billion');
291
+
292
+ switch (lang) {
293
+ case 'fr':
294
+ return numberToFrench(n);
295
+ case 'en':
296
+ return numberToEnglish(n);
297
+ default:
298
+ throw new Error('Lang must be one of: fr, en');
299
+ }
300
+ }
@@ -0,0 +1,55 @@
1
+ import { MAX_STRING_LENGTH } from '../../constants.js';
2
+
3
+ function checkValue(value: string): void {
4
+ if (!value) throw new Error('A value is required');
5
+ if (typeof value !== 'string') throw new Error('Value must be a string');
6
+ if (value.length > MAX_STRING_LENGTH)
7
+ throw new Error(`Value must be less than ${MAX_STRING_LENGTH} characters long`);
8
+ }
9
+
10
+ export function luhn(value: string): { valid: boolean; value: string } {
11
+ checkValue(value);
12
+ const digits = value.replace(/\s|-/g, '');
13
+ if (!/^\d+$/.test(digits)) throw new Error('Value must contain only digits, spaces or dashes');
14
+ if (digits.length < 12 || digits.length > 19) throw new Error('Card number must have between 12 and 19 digits');
15
+
16
+ let sum = 0;
17
+ let alt = false;
18
+ for (let i = digits.length - 1; i >= 0; i--) {
19
+ let n = parseInt(digits[i]!, 10);
20
+ if (alt) {
21
+ n *= 2;
22
+ if (n > 9) n -= 9;
23
+ }
24
+ sum += n;
25
+ alt = !alt;
26
+ }
27
+
28
+ return { valid: sum % 10 === 0, value: digits };
29
+ }
30
+
31
+ export function iban(value: string): { valid: boolean; value: string; country?: string } {
32
+ checkValue(value);
33
+ const cleaned = value.replace(/\s/g, '').toUpperCase();
34
+ if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(cleaned)) throw new Error('Invalid IBAN format');
35
+ if (cleaned.length < 15 || cleaned.length > 34) throw new Error('IBAN length must be between 15 and 34 characters');
36
+
37
+ const rearranged = cleaned.slice(4) + cleaned.slice(0, 4);
38
+ const numeric = rearranged
39
+ .split('')
40
+ .map((c) => (c >= 'A' && c <= 'Z' ? (c.charCodeAt(0) - 55).toString() : c))
41
+ .join('');
42
+
43
+ let remainder = 0;
44
+ for (const char of numeric) {
45
+ remainder = (remainder * 10 + parseInt(char, 10)) % 97;
46
+ }
47
+
48
+ return { valid: remainder === 1, value: cleaned, country: cleaned.slice(0, 2) };
49
+ }
50
+
51
+ export function email(value: string): { valid: boolean; value: string } {
52
+ checkValue(value);
53
+ const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
54
+ return { valid: regex.test(value), value };
55
+ }
package/src/modules/v4.ts CHANGED
@@ -3,13 +3,18 @@ export { default as chat } from './v4/chat.js';
3
3
  export { default as captcha } from './v4/captcha.js';
4
4
  export { default as color } from './v4/color.js';
5
5
  export { default as convert } from './v4/convert.js';
6
+ export { default as dice } from './v4/dice.js';
6
7
  export { default as domain } from './v4/domain.js';
8
+ export * as encode from './v4/encode.js';
7
9
  export { default as hash } from './v4/hash.js';
8
10
  export { default as hyperplanning } from './v4/hyperplanning.js';
9
11
  export { default as levenshtein } from './v4/levenshtein.js';
10
12
  export { default as personal } from './v4/personal.js';
11
13
  export { default as qrcode } from './v4/qrcode.js';
14
+ export { default as statistics } from './v4/statistics.js';
15
+ export * as text from './v4/text.js';
12
16
  export { default as tic_tac_toe } from './v4/tic_tac_toe.js';
13
17
  export { default as time } from './v4/time.js';
14
18
  export { default as token } from './v4/token.js';
15
19
  export { default as username } from './v4/username.js';
20
+ export * as validate from './v4/validate.js';
package/src/routes/get.ts CHANGED
@@ -18,6 +18,7 @@ router.get('/:version', (req: Request, res: Response) => {
18
18
 
19
19
  const endpoints = Object.keys(versionConfig.endpoints).reduce<Record<string, unknown>>((acc, method) => {
20
20
  const endpointList = versionConfig.endpoints[method as keyof typeof versionConfig.endpoints];
21
+ if (!endpointList) return acc;
21
22
  acc[method] = endpointList
22
23
  .filter(({ name }: { name: string }) => name !== 'website')
23
24
  .sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name))
@@ -149,6 +150,52 @@ router.get('/:version/domain', (req: Request, res: Response) => {
149
150
  }
150
151
  });
151
152
 
153
+ // RPG Dice roller
154
+ router.get('/:version/dice', (req: Request, res: Response) => {
155
+ const { roll } = req.query;
156
+ const { version } = req.params;
157
+
158
+ const dice = (req.module as { dice?: (r: string) => unknown }).dice;
159
+ if (!dice) {
160
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/dice`);
161
+ return;
162
+ }
163
+ if (!roll) {
164
+ error(res, 400, 'Please provide a roll notation (?roll=2d6+3)', `${version}/dice`);
165
+ return;
166
+ }
167
+
168
+ try {
169
+ const result = dice(roll as string);
170
+ res.jsonResponse(result);
171
+ } catch (err) {
172
+ error(res, 400, (err as Error).message, `${req.version}/dice`);
173
+ }
174
+ });
175
+
176
+ // Encode / decode text
177
+ router.get('/:version/encode', (req: Request, res: Response) => {
178
+ const { method, text, shift } = req.query;
179
+ const { version } = req.params;
180
+
181
+ const encode = (req.module as { encode?: Record<string, (v: string, v2?: string) => string> }).encode;
182
+ if (!encode) {
183
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/encode`);
184
+ return;
185
+ }
186
+ if (!method || !encode[method as string]) {
187
+ error(res, 400, 'Please provide a valid method (?method={method})', `${version}/encode`);
188
+ return;
189
+ }
190
+
191
+ try {
192
+ const result = encode[method as string]!(text as string, shift as string);
193
+ res.jsonResponse({ method, result });
194
+ } catch (err) {
195
+ error(res, 400, (err as Error).message, `${req.version}/encode`);
196
+ }
197
+ });
198
+
152
199
  // GET planning error
153
200
  router.get('/:version/hyperplanning', (_req: Request, res: Response) => {
154
201
  error(res, 405, 'This endpoint only supports POST requests.');
@@ -222,6 +269,93 @@ router.get('/:version/qrcode', async (req: Request, res: Response) => {
222
269
  }
223
270
  });
224
271
 
272
+ // Statistics on a list of numbers
273
+ router.get('/:version/statistics', (req: Request, res: Response) => {
274
+ const { values } = req.query;
275
+ const { version } = req.params;
276
+
277
+ const statistics = (req.module as { statistics?: (v: string) => unknown }).statistics;
278
+ if (!statistics) {
279
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/statistics`);
280
+ return;
281
+ }
282
+ if (!values) {
283
+ error(res, 400, 'Please provide a list of values (?values=1,2,3)', `${version}/statistics`);
284
+ return;
285
+ }
286
+
287
+ try {
288
+ const result = statistics(values as string);
289
+ res.jsonResponse(result);
290
+ } catch (err) {
291
+ error(res, 400, (err as Error).message, `${req.version}/statistics`);
292
+ }
293
+ });
294
+
295
+ // Text utilities (slug, stats, lorem, number)
296
+ router.get('/:version/text', (req: Request, res: Response) => {
297
+ const { method, value, type, count, lang, text } = req.query;
298
+ const { version } = req.params;
299
+
300
+ const textMod = (req.module as { text?: Record<string, (...args: string[]) => unknown> }).text;
301
+ if (!textMod) {
302
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/text`);
303
+ return;
304
+ }
305
+ if (!method || !textMod[method as string]) {
306
+ error(res, 400, 'Please provide a valid method (?method={slug|stats|lorem|number})', `${version}/text`);
307
+ return;
308
+ }
309
+
310
+ try {
311
+ let result: unknown;
312
+ switch (method) {
313
+ case 'slug':
314
+ case 'stats':
315
+ result = textMod[method as string]!((value ?? text) as string);
316
+ break;
317
+ case 'lorem':
318
+ result = textMod.lorem!((type as string) || 'words', (count as string) || '5');
319
+ break;
320
+ case 'number':
321
+ result = textMod.number!(value as string, (lang as string) || 'en');
322
+ break;
323
+ default:
324
+ throw new Error('Unknown method');
325
+ }
326
+ res.jsonResponse({ method, result });
327
+ } catch (err) {
328
+ error(res, 400, (err as Error).message, `${req.version}/text`);
329
+ }
330
+ });
331
+
332
+ // Validate data (luhn, iban, email)
333
+ router.get('/:version/validate', (req: Request, res: Response) => {
334
+ const { type, value } = req.query;
335
+ const { version } = req.params;
336
+
337
+ const validate = (req.module as { validate?: Record<string, (v: string) => unknown> }).validate;
338
+ if (!validate) {
339
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/validate`);
340
+ return;
341
+ }
342
+ if (!type || !validate[type as string]) {
343
+ error(res, 400, 'Please provide a valid type (?type={luhn|iban|email})', `${version}/validate`);
344
+ return;
345
+ }
346
+ if (!value) {
347
+ error(res, 400, 'Please provide a value (&value={value})', `${version}/validate`);
348
+ return;
349
+ }
350
+
351
+ try {
352
+ const result = validate[type as string]!(value as string);
353
+ res.jsonResponse(result);
354
+ } catch (err) {
355
+ error(res, 400, (err as Error).message, `${req.version}/validate`);
356
+ }
357
+ });
358
+
225
359
  // GET tic-tac-toe errors
226
360
  router.get('/:version/tic-tac-toe', (_req: Request, res: Response) => {
227
361
  error(res, 405, 'This endpoint only supports POST requests.');
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express';
2
2
  import { versions } from '../config/versions.js';
3
3
  import { ipLimits } from '../storage/index.js';
4
4
  import { logger } from '../middleware/logger.js';
5
- import { DOCS_URL, START_TIME } from '../constants.js';
5
+ import { APP_VERSION, DOCS_URL, START_TIME } from '../constants.js';
6
6
 
7
7
  const router = Router();
8
8
 
@@ -28,7 +28,7 @@ router.get('/health', (_req: Request, res: Response) => {
28
28
  res.jsonResponse({
29
29
  status: 'ok',
30
30
  uptime: Math.floor((Date.now() - START_TIME) / 1000),
31
- version: '4.0.0',
31
+ version: APP_VERSION,
32
32
  node: process.version,
33
33
  memory: {
34
34
  rss: `${(mem.rss / 1024 / 1024).toFixed(1)} MB`,