@20syldev/api 4.0.0 → 4.1.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.
Files changed (45) hide show
  1. package/README.md +3 -3
  2. package/docs/changelog.md +333 -0
  3. package/package.json +5 -4
  4. package/src/app.ts +7 -3
  5. package/src/config/versions.ts +22 -10
  6. package/src/constants.ts +19 -0
  7. package/src/middleware/cors.ts +1 -1
  8. package/src/modules/v4/algorithms.ts +43 -1
  9. package/src/modules/v4/chat.ts +24 -1
  10. package/src/modules/v4/dice.ts +39 -0
  11. package/src/modules/v4/encode.ts +170 -0
  12. package/src/modules/v4/geo.ts +53 -0
  13. package/src/modules/v4/palette.ts +103 -0
  14. package/src/modules/v4/placeholder.ts +122 -0
  15. package/src/modules/v4/statistics.ts +55 -0
  16. package/src/modules/v4/text.ts +300 -0
  17. package/src/modules/v4/tic_tac_toe.ts +33 -1
  18. package/src/modules/v4/validate.ts +55 -0
  19. package/src/modules/v4.ts +2 -0
  20. package/src/routes/delete.ts +72 -0
  21. package/src/routes/get.ts +47 -0
  22. package/src/routes/index.ts +2 -2
  23. package/src/routes/patch.ts +45 -0
  24. package/src/utils/version.ts +5 -0
  25. package/tests/integration/api.test.ts +395 -0
  26. package/tests/tsconfig.json +3 -0
  27. package/tests/unit/algorithms.test.ts +120 -0
  28. package/tests/unit/color.test.ts +33 -0
  29. package/tests/unit/convert.test.ts +42 -0
  30. package/tests/unit/dice.test.ts +57 -0
  31. package/tests/unit/domain.test.ts +47 -0
  32. package/tests/unit/encode.test.ts +108 -0
  33. package/tests/unit/geo.test.ts +45 -0
  34. package/tests/unit/hash.test.ts +37 -0
  35. package/tests/unit/hyperplanning.test.ts +77 -0
  36. package/tests/unit/levenshtein.test.ts +34 -0
  37. package/tests/unit/palette.test.ts +53 -0
  38. package/tests/unit/personal.test.ts +65 -0
  39. package/tests/unit/statistics.test.ts +57 -0
  40. package/tests/unit/text.test.ts +107 -0
  41. package/tests/unit/time.test.ts +67 -0
  42. package/tests/unit/token.test.ts +61 -0
  43. package/tests/unit/username.test.ts +34 -0
  44. package/tests/unit/validate.test.ts +71 -0
  45. package/tsconfig.test.json +9 -0
@@ -0,0 +1,170 @@
1
+ import { MAX_STRING_LENGTH } from '../../constants.js';
2
+
3
+ const MORSE_TABLE: Record<string, string> = {
4
+ A: '.-',
5
+ B: '-...',
6
+ C: '-.-.',
7
+ D: '-..',
8
+ E: '.',
9
+ F: '..-.',
10
+ G: '--.',
11
+ H: '....',
12
+ I: '..',
13
+ J: '.---',
14
+ K: '-.-',
15
+ L: '.-..',
16
+ M: '--',
17
+ N: '-.',
18
+ O: '---',
19
+ P: '.--.',
20
+ Q: '--.-',
21
+ R: '.-.',
22
+ S: '...',
23
+ T: '-',
24
+ U: '..-',
25
+ V: '...-',
26
+ W: '.--',
27
+ X: '-..-',
28
+ Y: '-.--',
29
+ Z: '--..',
30
+ '0': '-----',
31
+ '1': '.----',
32
+ '2': '..---',
33
+ '3': '...--',
34
+ '4': '....-',
35
+ '5': '.....',
36
+ '6': '-....',
37
+ '7': '--...',
38
+ '8': '---..',
39
+ '9': '----.',
40
+ '.': '.-.-.-',
41
+ ',': '--..--',
42
+ '?': '..--..',
43
+ "'": '.----.',
44
+ '!': '-.-.--',
45
+ '/': '-..-.',
46
+ '(': '-.--.',
47
+ ')': '-.--.-',
48
+ '&': '.-...',
49
+ ':': '---...',
50
+ ';': '-.-.-.',
51
+ '=': '-...-',
52
+ '+': '.-.-.',
53
+ '-': '-....-',
54
+ _: '..--.-',
55
+ '"': '.-..-.',
56
+ $: '...-..-',
57
+ '@': '.--.-.',
58
+ };
59
+
60
+ const MORSE_REVERSE: Record<string, string> = Object.fromEntries(Object.entries(MORSE_TABLE).map(([k, v]) => [v, k]));
61
+
62
+ function checkText(value: string): void {
63
+ if (!value) throw new Error('A value is required');
64
+ if (typeof value !== 'string') throw new Error('Value must be a string');
65
+ if (value.length > MAX_STRING_LENGTH)
66
+ throw new Error(`Value must be less than ${MAX_STRING_LENGTH} characters long`);
67
+ }
68
+
69
+ export function base64encode(value: string): string {
70
+ checkText(value);
71
+ return Buffer.from(value, 'utf-8').toString('base64');
72
+ }
73
+
74
+ export function base64decode(value: string): string {
75
+ checkText(value);
76
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error('Invalid Base64 string');
77
+ return Buffer.from(value, 'base64').toString('utf-8');
78
+ }
79
+
80
+ export function urlencode(value: string): string {
81
+ checkText(value);
82
+ return encodeURIComponent(value);
83
+ }
84
+
85
+ export function urldecode(value: string): string {
86
+ checkText(value);
87
+ try {
88
+ return decodeURIComponent(value);
89
+ } catch {
90
+ throw new Error('Invalid URL-encoded string');
91
+ }
92
+ }
93
+
94
+ export function morse(value: string): string {
95
+ checkText(value);
96
+ return value
97
+ .toUpperCase()
98
+ .split(' ')
99
+ .map((word) =>
100
+ word
101
+ .split('')
102
+ .map((c) => {
103
+ if (c === '\n' || c === '\t') return '';
104
+ const code = MORSE_TABLE[c];
105
+ if (!code) throw new Error(`Unsupported character in Morse code: '${c}'`);
106
+ return code;
107
+ })
108
+ .filter(Boolean)
109
+ .join(' '),
110
+ )
111
+ .join(' / ');
112
+ }
113
+
114
+ export function unmorse(value: string): string {
115
+ checkText(value);
116
+ return value
117
+ .split(' / ')
118
+ .map((word) =>
119
+ word
120
+ .split(' ')
121
+ .map((code) => {
122
+ if (!code) return '';
123
+ const char = MORSE_REVERSE[code];
124
+ if (!char) throw new Error(`Unsupported Morse sequence: '${code}'`);
125
+ return char;
126
+ })
127
+ .join(''),
128
+ )
129
+ .join(' ');
130
+ }
131
+
132
+ export function rot13(value: string): string {
133
+ checkText(value);
134
+ return value.replace(/[a-zA-Z]/g, (c) => {
135
+ const base = c <= 'Z' ? 65 : 97;
136
+ return String.fromCharCode(((c.charCodeAt(0) - base + 13) % 26) + base);
137
+ });
138
+ }
139
+
140
+ export function caesar(value: string, shift: string): string {
141
+ checkText(value);
142
+ const n = Number(shift);
143
+ if (isNaN(n)) throw new Error('Shift must be a number');
144
+ const s = ((n % 26) + 26) % 26;
145
+ return value.replace(/[a-zA-Z]/g, (c) => {
146
+ const base = c <= 'Z' ? 65 : 97;
147
+ return String.fromCharCode(((c.charCodeAt(0) - base + s) % 26) + base);
148
+ });
149
+ }
150
+
151
+ export function binary(value: string): string {
152
+ checkText(value);
153
+ return value
154
+ .split('')
155
+ .map((c) => c.charCodeAt(0).toString(2).padStart(8, '0'))
156
+ .join(' ');
157
+ }
158
+
159
+ export function unbinary(value: string): string {
160
+ checkText(value);
161
+ if (!/^[01\s]+$/.test(value)) throw new Error('Invalid binary string');
162
+ return value
163
+ .trim()
164
+ .split(/\s+/)
165
+ .map((b) => {
166
+ if (b.length !== 8) throw new Error('Each binary group must contain 8 bits');
167
+ return String.fromCharCode(parseInt(b, 2));
168
+ })
169
+ .join('');
170
+ }
@@ -0,0 +1,53 @@
1
+ export interface GeoResult {
2
+ distance: { km: number; miles: number; nauticalMiles: number };
3
+ bearing: { degrees: number; cardinal: string };
4
+ from: { lat: number; lon: number };
5
+ to: { lat: number; lon: number };
6
+ }
7
+
8
+ const EARTH_RADIUS_KM = 6371;
9
+ const CARDINALS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
10
+
11
+ function toRad(deg: number): number {
12
+ return (deg * Math.PI) / 180;
13
+ }
14
+
15
+ function toDeg(rad: number): number {
16
+ return (rad * 180) / Math.PI;
17
+ }
18
+
19
+ function parseCoord(value: string, name: string, min: number, max: number): number {
20
+ const n = Number(value);
21
+ if (isNaN(n)) throw new Error(`${name} must be a number`);
22
+ if (n < min || n > max) throw new Error(`${name} must be between ${min} and ${max}`);
23
+ return n;
24
+ }
25
+
26
+ export default function geo(lat1: string, lon1: string, lat2: string, lon2: string): GeoResult {
27
+ const a = parseCoord(lat1, 'lat1', -90, 90);
28
+ const b = parseCoord(lon1, 'lon1', -180, 180);
29
+ const c = parseCoord(lat2, 'lat2', -90, 90);
30
+ const d = parseCoord(lon2, 'lon2', -180, 180);
31
+
32
+ const dLat = toRad(c - a);
33
+ const dLon = toRad(d - b);
34
+ const sa = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a)) * Math.cos(toRad(c)) * Math.sin(dLon / 2) ** 2;
35
+ const distance = 2 * EARTH_RADIUS_KM * Math.asin(Math.sqrt(sa));
36
+
37
+ const y = Math.sin(toRad(d - b)) * Math.cos(toRad(c));
38
+ const x =
39
+ Math.cos(toRad(a)) * Math.sin(toRad(c)) - Math.sin(toRad(a)) * Math.cos(toRad(c)) * Math.cos(toRad(d - b));
40
+ const bearing = (toDeg(Math.atan2(y, x)) + 360) % 360;
41
+ const cardinal = CARDINALS[Math.round(bearing / 22.5) % 16]!;
42
+
43
+ return {
44
+ distance: {
45
+ km: +distance.toFixed(3),
46
+ miles: +(distance * 0.621371).toFixed(3),
47
+ nauticalMiles: +(distance * 0.539957).toFixed(3),
48
+ },
49
+ bearing: { degrees: +bearing.toFixed(2), cardinal },
50
+ from: { lat: a, lon: b },
51
+ to: { lat: c, lon: d },
52
+ };
53
+ }
@@ -0,0 +1,103 @@
1
+ export interface PaletteColor {
2
+ hex: string;
3
+ rgb: string;
4
+ hsl: string;
5
+ }
6
+
7
+ export interface PaletteResult {
8
+ base: PaletteColor;
9
+ type: string;
10
+ colors: PaletteColor[];
11
+ }
12
+
13
+ function hexToRgb(hex: string): [number, number, number] {
14
+ const clean = hex.replace('#', '');
15
+ if (!/^[0-9a-fA-F]{6}$/.test(clean)) throw new Error('Invalid HEX color (use #RRGGBB)');
16
+ return [parseInt(clean.slice(0, 2), 16), parseInt(clean.slice(2, 4), 16), parseInt(clean.slice(4, 6), 16)];
17
+ }
18
+
19
+ function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
20
+ const r1 = r / 255,
21
+ g1 = g / 255,
22
+ b1 = b / 255;
23
+ const max = Math.max(r1, g1, b1),
24
+ min = Math.min(r1, g1, b1);
25
+ const l = (max + min) / 2;
26
+
27
+ if (max === min) return [0, 0, l];
28
+
29
+ const d = max - min;
30
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
31
+ let h = 0;
32
+ if (max === r1) h = (g1 - b1) / d + (g1 < b1 ? 6 : 0);
33
+ else if (max === g1) h = (b1 - r1) / d + 2;
34
+ else h = (r1 - g1) / d + 4;
35
+
36
+ return [(h * 60 + 360) % 360, s, l];
37
+ }
38
+
39
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
40
+ const c = (1 - Math.abs(2 * l - 1)) * s;
41
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
42
+ const m = l - c / 2;
43
+ let r1 = 0,
44
+ g1 = 0,
45
+ b1 = 0;
46
+ if (h < 60) [r1, g1, b1] = [c, x, 0];
47
+ else if (h < 120) [r1, g1, b1] = [x, c, 0];
48
+ else if (h < 180) [r1, g1, b1] = [0, c, x];
49
+ else if (h < 240) [r1, g1, b1] = [0, x, c];
50
+ else if (h < 300) [r1, g1, b1] = [x, 0, c];
51
+ else [r1, g1, b1] = [c, 0, x];
52
+ return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)];
53
+ }
54
+
55
+ function format(r: number, g: number, b: number): PaletteColor {
56
+ const [h, s, l] = rgbToHsl(r, g, b);
57
+ return {
58
+ hex: `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`,
59
+ rgb: `rgb(${r}, ${g}, ${b})`,
60
+ hsl: `hsl(${h.toFixed(1)}, ${(s * 100).toFixed(1)}%, ${(l * 100).toFixed(1)}%)`,
61
+ };
62
+ }
63
+
64
+ function fromHueShifts(base: [number, number, number], shifts: number[]): PaletteColor[] {
65
+ const [h, s, l] = base;
66
+ return shifts.map((shift) => {
67
+ const newH = (h + shift + 360) % 360;
68
+ const [r, g, b] = hslToRgb(newH, s, l);
69
+ return format(r, g, b);
70
+ });
71
+ }
72
+
73
+ export default function palette(color: string, type: string): PaletteResult {
74
+ if (!color) throw new Error('A base color is required');
75
+ if (!type) throw new Error('A palette type is required');
76
+
77
+ const [r, g, b] = hexToRgb(color);
78
+ const base = format(r, g, b);
79
+ const hsl = rgbToHsl(r, g, b);
80
+
81
+ let colors: PaletteColor[];
82
+ switch (type) {
83
+ case 'complementary':
84
+ colors = fromHueShifts(hsl, [0, 180]);
85
+ break;
86
+ case 'triadic':
87
+ colors = fromHueShifts(hsl, [0, 120, 240]);
88
+ break;
89
+ case 'analogous':
90
+ colors = fromHueShifts(hsl, [-60, -30, 0, 30, 60]);
91
+ break;
92
+ case 'tetradic':
93
+ colors = fromHueShifts(hsl, [0, 90, 180, 270]);
94
+ break;
95
+ case 'split-complementary':
96
+ colors = fromHueShifts(hsl, [0, 150, 210]);
97
+ break;
98
+ default:
99
+ throw new Error('Type must be one of: complementary, triadic, analogous, tetradic, split-complementary');
100
+ }
101
+
102
+ return { base, type, colors };
103
+ }
@@ -0,0 +1,122 @@
1
+ import { createCanvas } from 'canvas';
2
+
3
+ export interface PlaceholderOptions {
4
+ width: number;
5
+ height: number;
6
+ bg?: string;
7
+ color?: string;
8
+ text?: string;
9
+ rows?: number;
10
+ avatar?: boolean;
11
+ }
12
+
13
+ function parseSize(value: string | undefined, name: string, def: number): number {
14
+ if (value === undefined) return def;
15
+ const n = Number(value);
16
+ if (isNaN(n)) throw new Error(`${name} must be a number`);
17
+ if (n < 1 || n > 4000) throw new Error(`${name} must be between 1 and 4000`);
18
+ return Math.floor(n);
19
+ }
20
+
21
+ function normalizeColor(value: string | undefined, def: string): string {
22
+ if (!value) return def;
23
+ const clean = value.startsWith('#') ? value : `#${value}`;
24
+ if (!/^#[0-9a-fA-F]{3,6}$/.test(clean)) throw new Error('Invalid color (use hex like #ff6600)');
25
+ return clean;
26
+ }
27
+
28
+ function generateImage(opts: PlaceholderOptions): Buffer {
29
+ const { width, height } = opts;
30
+ const bg = normalizeColor(opts.bg, '#cccccc');
31
+ const color = normalizeColor(opts.color, '#333333');
32
+ const text = opts.text ?? `${width}×${height}`;
33
+
34
+ const canvas = createCanvas(width, height);
35
+ const ctx = canvas.getContext('2d');
36
+
37
+ ctx.fillStyle = bg;
38
+ ctx.fillRect(0, 0, width, height);
39
+
40
+ const fontSize = Math.max(12, Math.min(width, height) / 8);
41
+ ctx.font = `bold ${fontSize}px sans-serif`;
42
+ ctx.fillStyle = color;
43
+ ctx.textAlign = 'center';
44
+ ctx.textBaseline = 'middle';
45
+ ctx.fillText(text, width / 2, height / 2);
46
+
47
+ return canvas.toBuffer('image/png');
48
+ }
49
+
50
+ function generateSkeleton(opts: PlaceholderOptions): string {
51
+ const { width, height } = opts;
52
+ const bg = normalizeColor(opts.bg, '#e2e8f0');
53
+ const shimmer = normalizeColor(opts.color, '#f1f5f9');
54
+ const rows = Math.max(1, Math.min(20, opts.rows ?? 3));
55
+ const avatar = !!opts.avatar;
56
+
57
+ const padding = Math.min(width, height) * 0.05;
58
+ const avatarSize = avatar ? Math.min(width, height) * 0.2 : 0;
59
+ const lineHeight = (height - padding * 2 - avatarSize - (avatar ? padding : 0)) / rows;
60
+ const lineThickness = Math.max(8, lineHeight * 0.5);
61
+ const radius = lineThickness / 2;
62
+
63
+ const lines: string[] = [];
64
+ const startY = padding + (avatar ? avatarSize + padding : 0);
65
+ for (let i = 0; i < rows; i++) {
66
+ const y = startY + i * lineHeight + (lineHeight - lineThickness) / 2;
67
+ const lineWidth = (width - padding * 2) * (i === rows - 1 ? 0.6 : 0.95);
68
+ lines.push(
69
+ `<rect x="${padding}" y="${y}" width="${lineWidth}" height="${lineThickness}" rx="${radius}" fill="url(#shimmer)" />`,
70
+ );
71
+ }
72
+
73
+ const avatarShape = avatar
74
+ ? `<circle cx="${padding + avatarSize / 2}" cy="${padding + avatarSize / 2}" r="${avatarSize / 2}" fill="url(#shimmer)" />`
75
+ : '';
76
+
77
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
78
+ <defs>
79
+ <linearGradient id="shimmer" x1="0%" y1="0%" x2="100%" y2="0%">
80
+ <stop offset="0%" stop-color="${bg}">
81
+ <animate attributeName="offset" values="-2; 1" dur="1.5s" repeatCount="indefinite" />
82
+ </stop>
83
+ <stop offset="50%" stop-color="${shimmer}">
84
+ <animate attributeName="offset" values="-1.5; 1.5" dur="1.5s" repeatCount="indefinite" />
85
+ </stop>
86
+ <stop offset="100%" stop-color="${bg}">
87
+ <animate attributeName="offset" values="-1; 2" dur="1.5s" repeatCount="indefinite" />
88
+ </stop>
89
+ </linearGradient>
90
+ </defs>
91
+ <rect width="${width}" height="${height}" fill="${bg}" />
92
+ ${avatarShape}
93
+ ${lines.join('\n ')}
94
+ </svg>`;
95
+ }
96
+
97
+ export interface PlaceholderResult {
98
+ type: string;
99
+ contentType: string;
100
+ body: Buffer | string;
101
+ }
102
+
103
+ export default function placeholder(type: string, query: Record<string, string | undefined>): PlaceholderResult {
104
+ const opts: PlaceholderOptions = {
105
+ width: parseSize(query.width, 'width', 800),
106
+ height: parseSize(query.height, 'height', 600),
107
+ bg: query.bg,
108
+ color: query.color,
109
+ text: query.text,
110
+ rows: query.rows ? parseInt(query.rows, 10) : undefined,
111
+ avatar: query.avatar === 'true' || query.avatar === '1',
112
+ };
113
+
114
+ switch (type) {
115
+ case 'image':
116
+ return { type, contentType: 'image/png', body: generateImage(opts) };
117
+ case 'skeleton':
118
+ return { type, contentType: 'image/svg+xml', body: generateSkeleton(opts) };
119
+ default:
120
+ throw new Error('Type must be one of: image, skeleton');
121
+ }
122
+ }
@@ -0,0 +1,55 @@
1
+ import { MAX_STRING_LENGTH } from '../../constants.js';
2
+
3
+ export interface StatisticsResult {
4
+ count: number;
5
+ sum: number;
6
+ min: number;
7
+ max: number;
8
+ range: number;
9
+ mean: number;
10
+ median: number;
11
+ mode: number[];
12
+ variance: number;
13
+ stddev: number;
14
+ }
15
+
16
+ export default function statistics(values: string): StatisticsResult {
17
+ if (!values) throw new Error('A list of values is required');
18
+ if (typeof values !== 'string') throw new Error('Values must be a comma-separated string');
19
+
20
+ const arr = values.split(',').map((v) => Number(v.trim()));
21
+
22
+ if (arr.some(isNaN)) throw new Error('Values must contain only numbers');
23
+ if (arr.length < 1) throw new Error('At least one value is required');
24
+ if (arr.length > MAX_STRING_LENGTH) throw new Error(`Cannot process more than ${MAX_STRING_LENGTH} values`);
25
+
26
+ const count = arr.length;
27
+ const sum = arr.reduce((a, b) => a + b, 0);
28
+ const min = Math.min(...arr);
29
+ const max = Math.max(...arr);
30
+ const mean = sum / count;
31
+
32
+ const sorted = [...arr].sort((a, b) => a - b);
33
+ const median = count % 2 === 0 ? (sorted[count / 2 - 1]! + sorted[count / 2]!) / 2 : sorted[Math.floor(count / 2)]!;
34
+
35
+ const counts = new Map<number, number>();
36
+ for (const n of arr) counts.set(n, (counts.get(n) ?? 0) + 1);
37
+ const maxCount = Math.max(...counts.values());
38
+ const mode = maxCount === 1 ? [] : [...counts.entries()].filter(([, c]) => c === maxCount).map(([n]) => n);
39
+
40
+ const variance = arr.reduce((acc, n) => acc + (n - mean) ** 2, 0) / count;
41
+ const stddev = Math.sqrt(variance);
42
+
43
+ return {
44
+ count,
45
+ sum: +sum.toFixed(6),
46
+ min,
47
+ max,
48
+ range: max - min,
49
+ mean: +mean.toFixed(6),
50
+ median: +median.toFixed(6),
51
+ mode,
52
+ variance: +variance.toFixed(6),
53
+ stddev: +stddev.toFixed(6),
54
+ };
55
+ }