@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 +21 -0
- package/README.md +28 -0
- package/index.ts +38 -0
- package/package.json +39 -0
- package/src/date.ts +58 -0
- package/src/env.ts +60 -0
- package/src/number.test.ts +146 -0
- package/src/number.ts +161 -0
- package/src/string.ts +208 -0
- package/src/timing.ts +44 -0
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
|
+
}
|