@sprqvntrs/helpers 0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 SPRQVNTRS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # @sprqvntrs/helpers
2
+
3
+ Common helper utilities for the SPRQVNTRS platform: date, environment, number, string
4
+ and timing helpers.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pnpm add @sprqvntrs/helpers
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```ts
15
+ import { requireEnv, formatNumber } from '@sprqvntrs/helpers';
16
+ ```
17
+
18
+ See `src/` for the full surface.
19
+
20
+ ## Raw TypeScript
21
+
22
+ This package ships raw TypeScript (`main` and `types` point at `index.ts`), so a Vite
23
+ consumer (Vite, React Router, Remix) must add the scope to `ssr.noExternal`:
24
+ `ssr: { noExternal: [/^@sprqvntrs\//] }`.
25
+
26
+ ## License
27
+
28
+ MIT
package/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @sprqvntrs/helpers
3
+ *
4
+ * Common helper utilities for the SPRQVNTRS platform.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ // =============================================================================
10
+ // Environment Helpers
11
+ // =============================================================================
12
+
13
+ export { requireEnv, optionalEnv, optionalBoolEnv, optionalIntEnv } from './src/env';
14
+
15
+ // =============================================================================
16
+ // String Helpers
17
+ // =============================================================================
18
+
19
+ export { slugify, normalizeEuropeanChars, generateRandomString } from './src/string';
20
+
21
+ // =============================================================================
22
+ // Date Helpers
23
+ // =============================================================================
24
+
25
+ export { slugifyDate, daysAgo } from './src/date';
26
+
27
+ // =============================================================================
28
+ // Timing Helpers
29
+ // =============================================================================
30
+
31
+ export { measureExecutionTime } from './src/timing';
32
+
33
+ // =============================================================================
34
+ // Number Helpers
35
+ // =============================================================================
36
+
37
+ export { formatNumber, formatDecimal, formatInteger, formatSmart } from './src/number';
38
+ export type { NumberFormatOptions } from './src/number';
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@sprqvntrs/helpers",
3
+ "version": "0.2.0",
4
+ "description": "Common helper utilities for the SPRQVNTRS platform",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "types": "./index.ts",
8
+ "exports": {
9
+ ".": "./index.ts"
10
+ },
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/SPRQVNTRS/platform.git",
15
+ "directory": "packages/helpers"
16
+ },
17
+ "files": [
18
+ "src/**/*",
19
+ "index.ts",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
25
+ "typecheck": "tsc --noEmit"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.0.0",
29
+ "typescript": "^5.6.0",
30
+ "vitest": "^3.2.4"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "homepage": "https://github.com/SPRQVNTRS/platform/tree/main/packages/helpers#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/SPRQVNTRS/platform/issues"
38
+ }
39
+ }
package/src/date.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Date manipulation helper functions
3
+ */
4
+
5
+ /**
6
+ * Formats a date string to a URL-friendly slug format (YYYY-MM-DD).
7
+ *
8
+ * Parses the input date and returns it in ISO date format,
9
+ * suitable for use in URLs, filenames, or database queries.
10
+ *
11
+ * @param date - A date string parseable by `new Date()`
12
+ * @returns The date formatted as YYYY-MM-DD
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * slugifyDate('2024-03-15T10:30:00Z');
17
+ * // => '2024-03-15'
18
+ *
19
+ * slugifyDate('March 15, 2024');
20
+ * // => '2024-03-15'
21
+ *
22
+ * slugifyDate('2024/03/15');
23
+ * // => '2024-03-15'
24
+ * ```
25
+ */
26
+ export function slugifyDate(date: string): string {
27
+ const dateObj = new Date(date);
28
+ return dateObj.toISOString().split('T')[0]!;
29
+ }
30
+
31
+ /**
32
+ * Calculates the number of days between a given date and today.
33
+ *
34
+ * Returns a positive number for dates in the past, representing
35
+ * how many days ago the date occurred.
36
+ *
37
+ * @param date - A date string parseable by `new Date()`
38
+ * @returns The number of whole days between the date and now
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * // If today is 2024-03-15:
43
+ * daysAgo('2024-03-10');
44
+ * // => 5
45
+ *
46
+ * daysAgo('2024-03-14');
47
+ * // => 1
48
+ *
49
+ * daysAgo('2024-03-15');
50
+ * // => 0
51
+ * ```
52
+ */
53
+ export function daysAgo(date: string): number {
54
+ const now = new Date();
55
+ const fixtureDate = new Date(date);
56
+ const diff = now.getTime() - fixtureDate.getTime();
57
+ return Math.floor(diff / (1000 * 60 * 60 * 24));
58
+ }
package/src/env.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Environment variable helper functions
3
+ */
4
+
5
+ /**
6
+ * Ensures a required environment variable exists
7
+ * @throws Error if the environment variable is not set
8
+ */
9
+ export function requireEnv(key: string): string {
10
+ const value = process.env[key];
11
+ if (!value) {
12
+ throw new Error(
13
+ `Missing required environment variable: ${key}\n` +
14
+ `Please ensure ${key} is set in your .env file or environment.`,
15
+ );
16
+ }
17
+ return value;
18
+ }
19
+
20
+ /**
21
+ * Gets an optional environment variable with a default value
22
+ */
23
+ export function optionalEnv(key: string, defaultValue: string): string {
24
+ return process.env[key] || defaultValue;
25
+ }
26
+
27
+ /**
28
+ * Gets an optional boolean environment variable
29
+ */
30
+ export function optionalBoolEnv(key: string, defaultValue: boolean): boolean {
31
+ const value = process.env[key];
32
+ if (!value) return defaultValue;
33
+ return value.toLowerCase() === 'true';
34
+ }
35
+
36
+ /**
37
+ * Gets an optional integer environment variable.
38
+ *
39
+ * Parses the environment variable as a base-10 integer.
40
+ * Returns the default value if the variable is not set or cannot be parsed.
41
+ *
42
+ * @param key - The environment variable name
43
+ * @param defaultValue - The value to return if the variable is not set or invalid
44
+ * @returns The parsed integer or the default value
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * const port = optionalIntEnv('PORT', 3000);
49
+ * // => 8080 if PORT=8080, or 3000 if not set
50
+ *
51
+ * const workers = optionalIntEnv('WORKER_COUNT', 4);
52
+ * // => 4 if WORKER_COUNT is not set or invalid
53
+ * ```
54
+ */
55
+ export function optionalIntEnv(key: string, defaultValue: number): number {
56
+ const value = process.env[key];
57
+ if (!value) return defaultValue;
58
+ const parsed = parseInt(value, 10);
59
+ return isNaN(parsed) ? defaultValue : parsed;
60
+ }
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { formatNumber, formatDecimal, formatInteger, formatSmart } from './number';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // formatNumber
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe('formatNumber', () => {
9
+ describe('decimal separator localisation', () => {
10
+ it('formats 3.6 with de locale using comma decimal', () => {
11
+ expect(formatNumber(3.6, 'de', { maximumFractionDigits: 1 })).toBe('3,6');
12
+ });
13
+
14
+ it('formats 3.6 with en locale using period decimal', () => {
15
+ expect(formatNumber(3.6, 'en', { maximumFractionDigits: 1 })).toBe('3.6');
16
+ });
17
+
18
+ it('accepts a full BCP-47 tag (de-DE) in place of a short code', () => {
19
+ expect(formatNumber(3.6, 'de-DE', { maximumFractionDigits: 1 })).toBe('3,6');
20
+ });
21
+ });
22
+
23
+ describe('collapse behaviour with maximumFractionDigits: 1', () => {
24
+ it('rounds 0.001 to "0" for de locale', () => {
25
+ expect(formatNumber(0.001, 'de', { maximumFractionDigits: 1 })).toBe('0');
26
+ });
27
+
28
+ it('rounds 0.001 to "0" for en locale', () => {
29
+ expect(formatNumber(0.001, 'en', { maximumFractionDigits: 1 })).toBe('0');
30
+ });
31
+ });
32
+
33
+ describe('integers are unchanged', () => {
34
+ it('formats 100 with de locale and maximumFractionDigits: 1', () => {
35
+ expect(formatNumber(100, 'de', { maximumFractionDigits: 1 })).toBe('100');
36
+ });
37
+
38
+ it('formats 49 with en locale and maximumFractionDigits: 1', () => {
39
+ expect(formatNumber(49, 'en', { maximumFractionDigits: 1 })).toBe('49');
40
+ });
41
+ });
42
+
43
+ describe('grouping separators', () => {
44
+ it('uses period as thousands separator for de locale', () => {
45
+ expect(formatNumber(1234.56, 'de')).toBe('1.234,56');
46
+ });
47
+
48
+ it('uses comma as thousands separator for en locale', () => {
49
+ expect(formatNumber(1234.56, 'en')).toBe('1,234.56');
50
+ });
51
+
52
+ it('uses space (possibly narrow no-break) as thousands separator for fr locale', () => {
53
+ // Node ICU may emit U+202F (narrow no-break space) — normalise all whitespace
54
+ const result = formatNumber(1234.5, 'fr', { maximumFractionDigits: 1 }).replace(/\s/g, ' ');
55
+ expect(result).toMatch(/1 ?234,5/);
56
+ });
57
+ });
58
+
59
+ describe('zero', () => {
60
+ it('formats 0 as "0" for de', () => {
61
+ expect(formatNumber(0, 'de')).toBe('0');
62
+ });
63
+
64
+ it('formats 0 as "0" for en', () => {
65
+ expect(formatNumber(0, 'en')).toBe('0');
66
+ });
67
+
68
+ it('formats 0 as "0" for fr', () => {
69
+ expect(formatNumber(0, 'fr')).toBe('0');
70
+ });
71
+ });
72
+ });
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // formatDecimal
76
+ // ---------------------------------------------------------------------------
77
+
78
+ describe('formatDecimal', () => {
79
+ it('formats 5 with 1 decimal place for de locale', () => {
80
+ expect(formatDecimal(5, 'de', 1)).toBe('5,0');
81
+ });
82
+
83
+ it('formats 5 with 1 decimal place for en locale', () => {
84
+ expect(formatDecimal(5, 'en', 1)).toBe('5.0');
85
+ });
86
+
87
+ it('defaults to 1 decimal place when decimals is omitted', () => {
88
+ expect(formatDecimal(5, 'en')).toBe('5.0');
89
+ });
90
+
91
+ it('preserves trailing zeros to fill fixed precision', () => {
92
+ expect(formatDecimal(3, 'en', 2)).toBe('3.00');
93
+ });
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // formatInteger
98
+ // ---------------------------------------------------------------------------
99
+
100
+ describe('formatInteger', () => {
101
+ it('rounds 52.7 to 53 for de locale', () => {
102
+ expect(formatInteger(52.7, 'de')).toBe('53');
103
+ });
104
+
105
+ it('adds period thousands separator for de locale on 1300', () => {
106
+ expect(formatInteger(1300, 'de')).toBe('1.300');
107
+ });
108
+
109
+ it('adds comma thousands separator for en locale on 1300', () => {
110
+ expect(formatInteger(1300, 'en')).toBe('1,300');
111
+ });
112
+ });
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // formatSmart
116
+ // ---------------------------------------------------------------------------
117
+
118
+ describe('formatSmart', () => {
119
+ it('formats values >= 100 as an integer (0 decimal places)', () => {
120
+ expect(formatSmart(250, 'en')).toBe('250');
121
+ });
122
+
123
+ it('formats values >= 10 with 1 decimal place', () => {
124
+ expect(formatSmart(42.37, 'en')).toBe('42.4');
125
+ });
126
+
127
+ it('formats values >= 1 with 2 decimal places', () => {
128
+ expect(formatSmart(4.378, 'en')).toBe('4.38');
129
+ });
130
+
131
+ it('formats values >= 0.01 with 3 decimal places', () => {
132
+ expect(formatSmart(0.045, 'en')).toBe('0.045');
133
+ });
134
+
135
+ it('formats values < 0.01 with 4 decimal places', () => {
136
+ expect(formatSmart(0.0012, 'en')).toBe('0.0012');
137
+ });
138
+
139
+ it('formats 0 with 4 decimal places for de (0 < 0.01 threshold)', () => {
140
+ expect(formatSmart(0, 'de')).toBe('0,0000');
141
+ });
142
+
143
+ it('formats 0 with 4 decimal places for en (0 < 0.01 threshold)', () => {
144
+ expect(formatSmart(0, 'en')).toBe('0.0000');
145
+ });
146
+ });
package/src/number.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Locale-aware number formatting helper functions.
3
+ *
4
+ * A thin wrapper around `Intl.NumberFormat` that produces locale-correct decimal
5
+ * and grouping separators on both server and client. Mirrors the API of the
6
+ * `remix-lcc` `useNumberFormat` hook so that server-side content rendering and
7
+ * scripts produce identical output without requiring a React context.
8
+ *
9
+ * ### Accepted locale values
10
+ *
11
+ * The `locale` parameter accepts any value that `Intl.NumberFormat` understands:
12
+ * - Short IANA subtag: `'en'`, `'de'`, `'fr'`
13
+ * - Full BCP-47 tag: `'de-DE'`, `'fr-FR'`, `'es-ES'`
14
+ *
15
+ * Both forms are equivalent — `Intl` normalises them internally.
16
+ *
17
+ * ### Decimal and grouping separator reference
18
+ *
19
+ * | Locale | Example output |
20
+ * |---------------------|----------------------|
21
+ * | `en` / `en-US` | `1,234.56` (comma group, period decimal) |
22
+ * | `de` / `es` / `it` / `nl` | `1.234,56` (period group, comma decimal) |
23
+ * | `fr` / `fr-FR` | `1 234,56` (narrow no-break space group, comma decimal) |
24
+ *
25
+ * @module number
26
+ */
27
+
28
+ /**
29
+ * Re-export of `Intl.NumberFormatOptions` for consumers that want to type
30
+ * options objects without importing from `lib.es2015.intl.d.ts` directly.
31
+ */
32
+ export type NumberFormatOptions = Intl.NumberFormatOptions;
33
+
34
+ /**
35
+ * Formats a number using the given locale and optional `Intl.NumberFormat` options.
36
+ *
37
+ * This is the lowest-level formatter — it delegates directly to `Intl.NumberFormat`
38
+ * with no opinion on precision. Use the higher-level helpers (`formatDecimal`,
39
+ * `formatInteger`, `formatSmart`) for consistent precision behaviour.
40
+ *
41
+ * **Collapse behaviour:** passing `{ maximumFractionDigits: 1 }` both localises
42
+ * the decimal separator AND silently rounds very small values to zero
43
+ * (e.g. `0.001` → `"0"`). This is the intended behaviour for the LCC content
44
+ * pipeline, where trace nutrient amounts should display as `"0"` rather than
45
+ * scientific notation.
46
+ *
47
+ * @param value - The number to format
48
+ * @param locale - IANA locale subtag (`'de'`) or BCP-47 tag (`'de-DE'`)
49
+ * @param options - Optional `Intl.NumberFormat` options (precision, style, etc.)
50
+ * @returns The locale-formatted string
51
+ *
52
+ * @example
53
+ * ```typescript
54
+ * formatNumber(1234.56, 'en');
55
+ * // => '1,234.56'
56
+ *
57
+ * formatNumber(1234.56, 'de');
58
+ * // => '1.234,56'
59
+ *
60
+ * formatNumber(3.6, 'de', { maximumFractionDigits: 1 });
61
+ * // => '3,6'
62
+ *
63
+ * // Collapse: maximumFractionDigits rounds 0.001 to "0"
64
+ * formatNumber(0.001, 'en', { maximumFractionDigits: 1 });
65
+ * // => '0'
66
+ * ```
67
+ */
68
+ export function formatNumber(value: number, locale: string, options?: Intl.NumberFormatOptions): string {
69
+ return new Intl.NumberFormat(locale, options).format(value);
70
+ }
71
+
72
+ /**
73
+ * Formats a number with a fixed number of decimal places using the given locale.
74
+ *
75
+ * Both `minimumFractionDigits` and `maximumFractionDigits` are set to `decimals`,
76
+ * so trailing zeros are always preserved (e.g. `5` → `'5.0'` at one decimal place).
77
+ *
78
+ * @param value - The number to format
79
+ * @param locale - IANA locale subtag (`'de'`) or BCP-47 tag (`'de-DE'`)
80
+ * @param decimals - Number of decimal places to show (default `1`)
81
+ * @returns The locale-formatted string with exactly `decimals` fraction digits
82
+ *
83
+ * @example
84
+ * ```typescript
85
+ * formatDecimal(5, 'en', 1);
86
+ * // => '5.0'
87
+ *
88
+ * formatDecimal(5, 'de', 1);
89
+ * // => '5,0'
90
+ *
91
+ * formatDecimal(3.14159, 'en', 2);
92
+ * // => '3.14'
93
+ * ```
94
+ */
95
+ export function formatDecimal(value: number, locale: string, decimals: number = 1): string {
96
+ return new Intl.NumberFormat(locale, {
97
+ minimumFractionDigits: decimals,
98
+ maximumFractionDigits: decimals,
99
+ }).format(value);
100
+ }
101
+
102
+ /**
103
+ * Rounds a number to the nearest integer and formats it with no decimal places.
104
+ *
105
+ * Grouping separators are applied according to the locale (e.g. `1300` → `'1.300'`
106
+ * in German, `'1,300'` in English).
107
+ *
108
+ * @param value - The number to format (will be rounded with `Math.round`)
109
+ * @param locale - IANA locale subtag (`'de'`) or BCP-47 tag (`'de-DE'`)
110
+ * @returns The locale-formatted integer string
111
+ *
112
+ * @example
113
+ * ```typescript
114
+ * formatInteger(52.7, 'de');
115
+ * // => '53'
116
+ *
117
+ * formatInteger(1300, 'de');
118
+ * // => '1.300'
119
+ *
120
+ * formatInteger(1300, 'en');
121
+ * // => '1,300'
122
+ * ```
123
+ */
124
+ export function formatInteger(value: number, locale: string): string {
125
+ return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(Math.round(value));
126
+ }
127
+
128
+ /**
129
+ * Formats a number with magnitude-based precision using the given locale.
130
+ *
131
+ * Selects the number of decimal places based on the raw (non-absolute) value,
132
+ * matching the precision thresholds of the `remix-lcc` `useNumberFormat` hook:
133
+ *
134
+ * | Value range | Decimal places | Example (`en`) |
135
+ * |-------------|----------------|----------------|
136
+ * | `>= 100` | 0 (integer) | `250` → `'250'` |
137
+ * | `>= 10` | 1 | `42.37` → `'42.4'` |
138
+ * | `>= 1` | 2 | `4.378` → `'4.38'` |
139
+ * | `>= 0.01` | 3 | `0.045` → `'0.045'` |
140
+ * | `< 0.01` | 4 | `0.0012` → `'0.0012'` |
141
+ *
142
+ * @param value - The number to format
143
+ * @param locale - IANA locale subtag (`'de'`) or BCP-47 tag (`'de-DE'`)
144
+ * @returns The locale-formatted string with magnitude-appropriate precision
145
+ *
146
+ * @example
147
+ * ```typescript
148
+ * formatSmart(250, 'en'); // => '250'
149
+ * formatSmart(42.37, 'en'); // => '42.4'
150
+ * formatSmart(4.378, 'en'); // => '4.38'
151
+ * formatSmart(0.045, 'en'); // => '0.045'
152
+ * formatSmart(0.0012, 'en'); // => '0.0012'
153
+ * ```
154
+ */
155
+ export function formatSmart(value: number, locale: string): string {
156
+ if (value >= 100) return formatInteger(value, locale);
157
+ if (value >= 10) return formatDecimal(value, locale, 1);
158
+ if (value >= 1) return formatDecimal(value, locale, 2);
159
+ if (value >= 0.01) return formatDecimal(value, locale, 3);
160
+ return formatDecimal(value, locale, 4);
161
+ }
package/src/string.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * String manipulation helper functions
3
+ */
4
+
5
+ const slugifyCharMap: Record<string, string> = {
6
+ ä: 'a',
7
+ ö: 'o',
8
+ ü: 'u',
9
+ ß: 'ss',
10
+ Ä: 'A',
11
+ Ö: 'O',
12
+ Ü: 'U',
13
+ é: 'e',
14
+ è: 'e',
15
+ ê: 'e',
16
+ ë: 'e',
17
+ ç: 'c',
18
+ à: 'a',
19
+ â: 'a',
20
+ ô: 'o',
21
+ ù: 'u',
22
+ û: 'u',
23
+ î: 'i',
24
+ ï: 'i',
25
+ É: 'E',
26
+ È: 'E',
27
+ Ê: 'E',
28
+ Ë: 'E',
29
+ Ç: 'C',
30
+ À: 'A',
31
+ Â: 'A',
32
+ Ô: 'O',
33
+ Ù: 'U',
34
+ Û: 'U',
35
+ Î: 'I',
36
+ Ï: 'I',
37
+ };
38
+
39
+ /**
40
+ * Converts a string to a URL-friendly slug.
41
+ *
42
+ * Handles special characters, diacritics, and European characters by
43
+ * normalizing them to ASCII equivalents.
44
+ *
45
+ * @param text - The string to slugify
46
+ * @returns A lowercase, hyphenated slug safe for URLs
47
+ *
48
+ * @example
49
+ * ```typescript
50
+ * slugify('Hello World!');
51
+ * // => 'hello-world'
52
+ *
53
+ * slugify('Café München');
54
+ * // => 'cafe-munchen'
55
+ *
56
+ * slugify('Products / Services');
57
+ * // => 'products-services'
58
+ * ```
59
+ */
60
+ export function slugify(text: string): string {
61
+ return (
62
+ text
63
+ .normalize('NFD')
64
+ .replace(/[\u0300-\u036f]/g, '')
65
+ .replace(/\//g, '-')
66
+ .replace(/[^a-zA-Z0-9\s]/g, (ch) => slugifyCharMap[ch] || ch)
67
+ .toLowerCase()
68
+ .replace(/[\s.]+/g, '-')
69
+ .replace(/[^\w-]+/g, '')
70
+ .replace(/[-]+/g, '-')
71
+ .replace(/^-+/, '')
72
+ .replace(/-+$/, '')
73
+ );
74
+ }
75
+
76
+ const europeanCharMap: Record<string, string> = {
77
+ // Lowercase letters
78
+ à: 'a',
79
+ á: 'a',
80
+ â: 'a',
81
+ ã: 'a',
82
+ ä: 'ae',
83
+ å: 'a',
84
+ ā: 'a',
85
+ è: 'e',
86
+ é: 'e',
87
+ ê: 'e',
88
+ ë: 'e',
89
+ ē: 'e',
90
+ ì: 'i',
91
+ í: 'i',
92
+ î: 'i',
93
+ ï: 'i',
94
+ ī: 'i',
95
+ ı: 'i',
96
+ ò: 'o',
97
+ ó: 'o',
98
+ ô: 'o',
99
+ õ: 'o',
100
+ ö: 'oe',
101
+ ō: 'o',
102
+ ù: 'u',
103
+ ú: 'u',
104
+ û: 'u',
105
+ ü: 'ue',
106
+ ū: 'u',
107
+ ý: 'y',
108
+ ÿ: 'y',
109
+ ñ: 'n',
110
+ ń: 'n',
111
+ ß: 'ss',
112
+ ç: 'c',
113
+ ş: 's',
114
+ š: 's',
115
+ ś: 's',
116
+
117
+ // Uppercase letters
118
+ À: 'A',
119
+ Á: 'A',
120
+ Â: 'A',
121
+ Ã: 'A',
122
+ Ä: 'AE',
123
+ Å: 'A',
124
+ Ā: 'A',
125
+ È: 'E',
126
+ É: 'E',
127
+ Ê: 'E',
128
+ Ë: 'E',
129
+ Ē: 'E',
130
+ Ì: 'I',
131
+ Í: 'I',
132
+ Î: 'I',
133
+ Ï: 'I',
134
+ Ī: 'I',
135
+ İ: 'I',
136
+ Ò: 'O',
137
+ Ó: 'O',
138
+ Ô: 'O',
139
+ Õ: 'O',
140
+ Ö: 'OE',
141
+ Ō: 'O',
142
+ Ù: 'U',
143
+ Ú: 'U',
144
+ Û: 'U',
145
+ Ü: 'UE',
146
+ Ū: 'U',
147
+ Ý: 'Y',
148
+ Ñ: 'N',
149
+ Ń: 'N',
150
+ Ç: 'C',
151
+ Ş: 'S',
152
+ Š: 'S',
153
+ Ś: 'S',
154
+ };
155
+
156
+ /**
157
+ * Normalizes European characters to their ASCII equivalents.
158
+ *
159
+ * Unlike `slugify`, this preserves the original casing and spacing,
160
+ * only replacing accented/special characters with their closest ASCII representation.
161
+ * German umlauts are expanded (ä → ae, ö → oe, ü → ue).
162
+ *
163
+ * @param text - The string containing European characters
164
+ * @returns The string with European characters replaced by ASCII equivalents
165
+ *
166
+ * @example
167
+ * ```typescript
168
+ * normalizeEuropeanChars('Müller');
169
+ * // => 'Mueller'
170
+ *
171
+ * normalizeEuropeanChars('François Château');
172
+ * // => 'Francois Chateau'
173
+ *
174
+ * normalizeEuropeanChars('Straße');
175
+ * // => 'Strasse'
176
+ * ```
177
+ */
178
+ export function normalizeEuropeanChars(text: string): string {
179
+ return text.replace(/[^\u0000-\u007F]/g, (char) => europeanCharMap[char] || char);
180
+ }
181
+
182
+ /**
183
+ * Generates a random alphanumeric string of the specified length.
184
+ *
185
+ * Uses uppercase letters, lowercase letters, and digits (A-Z, a-z, 0-9).
186
+ * Note: This is not cryptographically secure; use `crypto.randomBytes`
187
+ * for security-sensitive applications.
188
+ *
189
+ * @param length - The desired length of the random string
190
+ * @returns A random alphanumeric string
191
+ *
192
+ * @example
193
+ * ```typescript
194
+ * generateRandomString(8);
195
+ * // => 'xK9mPq2L' (example output)
196
+ *
197
+ * generateRandomString(16);
198
+ * // => 'aBcDeFgH12345678' (example output)
199
+ * ```
200
+ */
201
+ export function generateRandomString(length: number): string {
202
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
203
+ let result = '';
204
+ for (let i = 0; i < length; i++) {
205
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
206
+ }
207
+ return result;
208
+ }
package/src/timing.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Timing and performance helper functions
3
+ */
4
+
5
+ /**
6
+ * Measures the execution time of a synchronous or asynchronous function.
7
+ *
8
+ * Wraps a function call and returns both its result and the time taken
9
+ * to execute in milliseconds. Useful for performance profiling and logging.
10
+ *
11
+ * @typeParam T - The return type of the function being measured
12
+ * @param fn - The function to measure (can be sync or async)
13
+ * @returns A tuple containing [result, executionTimeMs]
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * // Measure a synchronous function
18
+ * const [sum, timeMs] = await measureExecutionTime(() => {
19
+ * return Array.from({ length: 1000000 }, (_, i) => i).reduce((a, b) => a + b, 0);
20
+ * });
21
+ * console.log(`Sum: ${sum}, took ${timeMs}ms`);
22
+ *
23
+ * // Measure an async function
24
+ * const [data, fetchTime] = await measureExecutionTime(async () => {
25
+ * const response = await fetch('https://api.example.com/data');
26
+ * return response.json();
27
+ * });
28
+ * console.log(`Fetched ${data.length} items in ${fetchTime}ms`);
29
+ *
30
+ * // Use with existing async functions
31
+ * const [user, queryTime] = await measureExecutionTime(() => db.users.findOne({ id: 123 }));
32
+ * if (queryTime > 100) {
33
+ * console.warn(`Slow query: ${queryTime}ms`);
34
+ * }
35
+ * ```
36
+ */
37
+ export async function measureExecutionTime<T>(fn: () => T | Promise<T>): Promise<[T, number]> {
38
+ const start = performance.now();
39
+ const result = await Promise.resolve(fn());
40
+ const end = performance.now();
41
+ const executionTime = end - start;
42
+
43
+ return [result, executionTime];
44
+ }