@forthtilliath/ts-kit 0.13.0 → 0.15.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 (47) hide show
  1. package/README.md +53 -7
  2. package/dist/array/index.d.ts +3 -0
  3. package/dist/array/index.d.ts.map +1 -1
  4. package/dist/array/index.js +3 -0
  5. package/dist/array/indexById.d.ts +15 -0
  6. package/dist/array/indexById.d.ts.map +1 -0
  7. package/dist/array/indexById.js +14 -0
  8. package/dist/array/moveItem.d.ts +18 -0
  9. package/dist/array/moveItem.d.ts.map +1 -0
  10. package/dist/array/moveItem.js +9 -0
  11. package/dist/array/pickRandom.d.ts +14 -0
  12. package/dist/array/pickRandom.d.ts.map +1 -0
  13. package/dist/array/pickRandom.js +15 -0
  14. package/dist/color/contrastRatio.d.ts +40 -0
  15. package/dist/color/contrastRatio.d.ts.map +1 -0
  16. package/dist/color/contrastRatio.js +62 -0
  17. package/dist/color/index.d.ts +2 -0
  18. package/dist/color/index.d.ts.map +1 -0
  19. package/dist/color/index.js +1 -0
  20. package/dist/csv/escapeCsvField.d.ts +6 -2
  21. package/dist/csv/escapeCsvField.d.ts.map +1 -1
  22. package/dist/csv/escapeCsvField.js +13 -8
  23. package/dist/csv/index.d.ts +1 -0
  24. package/dist/csv/index.d.ts.map +1 -1
  25. package/dist/csv/index.js +1 -0
  26. package/dist/csv/neutralizeCsvFormula.d.ts +27 -0
  27. package/dist/csv/neutralizeCsvFormula.d.ts.map +1 -0
  28. package/dist/csv/neutralizeCsvFormula.js +25 -0
  29. package/dist/csv/toCsv.d.ts +6 -1
  30. package/dist/csv/toCsv.d.ts.map +1 -1
  31. package/dist/csv/toCsv.js +10 -4
  32. package/dist/date/formatRelativeTime.d.ts +2 -0
  33. package/dist/date/formatRelativeTime.d.ts.map +1 -1
  34. package/dist/date/formatRelativeTime.js +37 -3
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -0
  38. package/dist/security/createAttemptLimiter.d.ts +65 -0
  39. package/dist/security/createAttemptLimiter.d.ts.map +1 -0
  40. package/dist/security/createAttemptLimiter.js +96 -0
  41. package/dist/security/index.d.ts +2 -0
  42. package/dist/security/index.d.ts.map +1 -0
  43. package/dist/security/index.js +1 -0
  44. package/dist/string/pluralize.d.ts +3 -1
  45. package/dist/string/pluralize.d.ts.map +1 -1
  46. package/dist/string/pluralize.js +17 -2
  47. package/package.json +4 -4
package/README.md CHANGED
@@ -57,12 +57,19 @@ Importing them is harmless anywhere: the DOM is only touched when called.
57
57
  - `getMostRecentIds(rows, limit?)` — most recently occurring distinct ids
58
58
  (`{ id, occurredAt }[]`), most recent first, deduplicated, limited
59
59
  (default 5).
60
+ - `indexById(items)` — `ReadonlyMap` from each item's `id` to the item (last
61
+ wins on duplicates), to build once instead of a `find` per rendered row.
62
+ - `moveItem(items, from, offset)` — copy of `items` with the element at
63
+ `from` moved `offset` places (negative = towards the start); returns
64
+ `items` itself when nothing moves, so a state setter can skip the update.
60
65
  - `nextInCycle(ids, currentId)` — next id in a short list (e.g. cycling
61
66
  through recents on tap) — wraps to the first if `currentId` is at the end
62
67
  or no longer in the list.
63
68
  - `rankByNameMatch(items, query, getName)` — ranks `items` by relevance to
64
69
  `query`: earlier match position first, then shorter name — for a
65
70
  search-as-you-type list.
71
+ - `pickRandom(array, random?)` — one element picked at random, or
72
+ `undefined` for an empty array; same `random` as `shuffle`.
66
73
  - `shuffle(array, random?)` — shuffled copy (Fisher–Yates); `random`
67
74
  (default `Math.random`) can be swapped for a seeded generator in tests.
68
75
  - `sortUpcomingFirst(items, getDates, { now? })` — upcoming items first
@@ -114,16 +121,35 @@ Importing them is harmless anywhere: the DOM is only touched when called.
114
121
  rarely needed since `FArray` is already assignable to `T[]` anywhere a
115
122
  plain array is expected, but useful when the runtime type matters too.
116
123
 
124
+ ### `color`
125
+
126
+ - `contrastRatio(a, b)` — WCAG 2 contrast ratio between two hex colours
127
+ (`#rgb`/`#rrggbb`), from 1 to 21; compare with `TEXT_CONTRAST` (4.5) or
128
+ `GRAPHIC_CONTRAST` (3). Throws a `TypeError` on an invalid colour.
129
+ - `relativeLuminance(hex)` — WCAG 2 relative luminance, 0 (black) to 1.
130
+ - `readableTextOn(background)` — `"#ffffff"` or `"#000000"`, whichever reads
131
+ best on that background.
132
+
117
133
  ### `csv`
118
134
 
135
+ Every text cell that starts with `=`, `+`, `-`, `@`, a tab or a carriage
136
+ return is prefixed with `'` by `toCsv` and `escapeCsvField`, so Excel,
137
+ LibreOffice or Sheets show it instead of running it as a formula (CSV
138
+ injection: `=HYPERLINK(…)` typed as a name becomes a trapped link). Numbers,
139
+ and numbers written as text (`"-3"`), are left as they are. Pass
140
+ `{ neutralizeFormulas: false }` only for a file no spreadsheet opens.
141
+
119
142
  - `parseCsvLine(line, delimiter?)` — splits one CSV line into trimmed cells;
120
143
  quoted cells may contain the delimiter, `""` stands for a literal quote.
121
144
  Delimiter defaults to `","`.
122
- - `toCsv(rows, delimiter?)` — serializes whole rows to CSV, every cell quoted
123
- and escaped, rows joined with `\n`. Delimiter defaults to `","`.
124
- - `escapeCsvField(value, delimiter?)` — escapes a single field (RFC 4180),
125
- quoting it only if it contains a `"`, the delimiter or a line break — to
126
- build rows cell by cell. Delimiter defaults to `";"` (French-locale Excel).
145
+ - `toCsv(rows, delimiter?, { neutralizeFormulas? })` — serializes whole rows
146
+ to CSV, every cell quoted and escaped, rows joined with `\n`. Delimiter
147
+ defaults to `","`.
148
+ - `escapeCsvField(value, delimiter?, { neutralizeFormulas? })` — escapes a
149
+ single field (RFC 4180), quoting it only if it contains a `"`, the
150
+ delimiter or a line break — to build rows cell by cell. Delimiter defaults
151
+ to `";"` (French-locale Excel).
152
+ - `neutralizeCsvFormula(value)` — the formula guard above, on its own.
127
153
  - `formatCsvNumber(value, decimals?)` — formats a number with a comma decimal
128
154
  separator (French-locale spreadsheets) instead of JS's dot.
129
155
  - `downloadCsv(filename, content)` — browser download as `text/csv`, with a
@@ -144,7 +170,9 @@ Importing them is harmless anywhere: the DOM is only touched when called.
144
170
  - `mondayFirstWeekday(date)` — weekday index with Monday = `0` … Sunday = `6`,
145
171
  from a `Date` or a `YYYY-MM-DD` string; offsets a Monday-first month grid.
146
172
  - `formatRelativeTime(date, baseDate?, locale?)` — human-readable relative
147
- time ("3 hours ago"), backed by `Intl.RelativeTimeFormat`.
173
+ time ("3 hours ago"), backed by `Intl.RelativeTimeFormat`. Without it
174
+ (Hermes, React Native on Android), a built-in French/English fallback
175
+ ("il y a 3 heures"; other locales in English) instead of a crash.
148
176
  - `formatRecentDate(date, { now?, locale? })` — a past moment as a "last
149
177
  saved…" line reads best: `"aujourd'hui à 21:42"`, `"hier à 08:05"`, then
150
178
  `"le 11 octobre 2026"` (`locale: "en"`: `"today at…"`, `"on October 11, 2026"`).
@@ -240,6 +268,22 @@ Building blocks for writing an iCalendar (`.ics`, RFC 5545) file.
240
268
  - `deepEqual(a, b)` — recursive structural equality check.
241
269
  - `mapValues(obj, fn)` — maps every value of an object through `fn`.
242
270
 
271
+ ### `security`
272
+
273
+ - `createAttemptLimiter({ storage, key, freeAttempts?, delaysMs?, now? })` —
274
+ growing delays between attempts at a secret (a PIN…): 4 free failures,
275
+ then 30 s, 1 min, 5 min, 15 min for each one after. `attempt(verify)`
276
+ returns `{ ok: true }` or `{ ok: false, lockedUntil }` (0 when no delay
277
+ is running) and doesn't call `verify` while locked; `getLockedUntil()`,
278
+ `reset()`. The counter lives in the injected `storage` (e.g.
279
+ expo-secure-store's `getItemAsync`/`setItemAsync`/`deleteItemAsync`); an
280
+ unreadable or tampered value counts as used up, not as zero, a clock set
281
+ back restarts the delay from now, and parallel attempts are serialized.
282
+ Limit: the delay is measured with the device clock, so setting it forward
283
+ ends the wait early — the failure count still grows, and each new failure
284
+ imposes the longest delay again. Keep the secret itself out of reach
285
+ (stored hashed, in the Android Keystore through expo-secure-store).
286
+
243
287
  ### `string`
244
288
 
245
289
  - `capitalize(str)` — uppercases the first letter, lowercases the rest.
@@ -250,7 +294,9 @@ Building blocks for writing an iCalendar (`.ics`, RFC 5545) file.
250
294
  - `pluralize(count, singular, plural? | { plural?, locale? })` — picks the
251
295
  singular or the plural form. Which counts take the singular follows the
252
296
  locale's rules (`Intl.PluralRules`, `"en"` by default): only ±1 in
253
- English, 0 and 1.5 too in French. Without an explicit plural form, a naive
297
+ English, 0 and 1.5 too in French. Where `Intl.PluralRules` is missing
298
+ (Hermes, React Native on Android), French keeps the singular below 2 and
299
+ other languages only for ±1. Without an explicit plural form, a naive
254
300
  suffix: English `s`/`es`/`ies`, French `s` (nothing after `s`/`x`/`z`),
255
301
  `s` elsewhere.
256
302
  - `createPluralize(locale)` — `pluralize` bound to a locale, declared once per
@@ -2,7 +2,10 @@ export * from "./chunk.js";
2
2
  export * from "./createColumnStorage.js";
3
3
  export * from "./flattenDeep.js";
4
4
  export * from "./getMostRecentIds.js";
5
+ export * from "./indexById.js";
6
+ export * from "./moveItem.js";
5
7
  export * from "./nextInCycle.js";
8
+ export * from "./pickRandom.js";
6
9
  export * from "./rankByNameMatch.js";
7
10
  export * from "./shuffle.js";
8
11
  export * from "./sortUpcomingFirst.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/array/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,0BAA0B,CAAC;AACzC,cAAc,kBAAkB,CAAC;AACjC,cAAc,uBAAuB,CAAC;AACtC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/array/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,0BAA0B,CAAC;AACzC,cAAc,kBAAkB,CAAC;AACjC,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,kBAAkB,CAAC;AACjC,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,gBAAgB,CAAC"}
@@ -2,7 +2,10 @@ export * from "./chunk.js";
2
2
  export * from "./createColumnStorage.js";
3
3
  export * from "./flattenDeep.js";
4
4
  export * from "./getMostRecentIds.js";
5
+ export * from "./indexById.js";
6
+ export * from "./moveItem.js";
5
7
  export * from "./nextInCycle.js";
8
+ export * from "./pickRandom.js";
6
9
  export * from "./rankByNameMatch.js";
7
10
  export * from "./shuffle.js";
8
11
  export * from "./sortUpcomingFirst.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Indexes items by their `id`, to build once (e.g. in a `useMemo`) and read
3
+ * in O(1) instead of a `find` per rendered element (games × their players).
4
+ * With duplicate ids, the last item wins.
5
+ *
6
+ * @param items - The items to index.
7
+ * @returns A read-only map from each `id` to its item.
8
+ * @example
9
+ * const players = indexById([{ id: "a", name: "Ann" }]);
10
+ * players.get("a")?.name; // => "Ann"
11
+ */
12
+ export declare function indexById<T extends {
13
+ id: unknown;
14
+ }>(items: readonly T[]): ReadonlyMap<T["id"], T>;
15
+ //# sourceMappingURL=indexById.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"indexById.d.ts","sourceRoot":"","sources":["../../src/array/indexById.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,OAAO,CAAA;CAAE,EACjD,KAAK,EAAE,SAAS,CAAC,EAAE,GAClB,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAEzB"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Indexes items by their `id`, to build once (e.g. in a `useMemo`) and read
3
+ * in O(1) instead of a `find` per rendered element (games × their players).
4
+ * With duplicate ids, the last item wins.
5
+ *
6
+ * @param items - The items to index.
7
+ * @returns A read-only map from each `id` to its item.
8
+ * @example
9
+ * const players = indexById([{ id: "a", name: "Ann" }]);
10
+ * players.get("a")?.name; // => "Ann"
11
+ */
12
+ export function indexById(items) {
13
+ return new Map(items.map((item) => [item.id, item]));
14
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Returns a copy of `items` with the element at `from` moved `offset` places
3
+ * (negative = towards the start), the others shifting to make room. When
4
+ * nothing moves (`from` or the destination out of bounds, `offset` 0), the
5
+ * very same array is returned, so a state setter can skip a useless update.
6
+ *
7
+ * @param items - The array to copy.
8
+ * @param from - The position of the element to move.
9
+ * @param offset - How many places to move it.
10
+ * @returns A new array with the element moved, or `items` itself.
11
+ * @example
12
+ * moveItem(["a", "b", "c", "d"], 0, 2); // => ["b", "c", "a", "d"]
13
+ * moveItem(["a", "b", "c"], 2, -1); // => ["a", "c", "b"]
14
+ * moveItem(list, 0, -1) === list; // => true
15
+ */
16
+ export declare function moveItem<T>(items: T[], from: number, offset: number): T[];
17
+ export declare function moveItem<T>(items: readonly T[], from: number, offset: number): readonly T[];
18
+ //# sourceMappingURL=moveItem.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"moveItem.d.ts","sourceRoot":"","sources":["../../src/array/moveItem.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,CAAC,EAAE,CAAC;AAC3E,wBAAgB,QAAQ,CAAC,CAAC,EACxB,KAAK,EAAE,SAAS,CAAC,EAAE,EACnB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GACb,SAAS,CAAC,EAAE,CAAC"}
@@ -0,0 +1,9 @@
1
+ export function moveItem(items, from, offset) {
2
+ const to = from + offset;
3
+ const inBounds = (i) => Number.isInteger(i) && i >= 0 && i < items.length;
4
+ if (!inBounds(from) || !inBounds(to) || to === from)
5
+ return items;
6
+ const next = [...items];
7
+ next.splice(to, 0, ...next.splice(from, 1));
8
+ return next;
9
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Returns one element of an array, picked at random (each equally likely).
3
+ *
4
+ * @param array - The array to pick from.
5
+ * @param random - Returns a number in `[0, 1)`. Defaults to `Math.random`;
6
+ * pass a seeded generator for deterministic tests.
7
+ * @returns The picked element, or `undefined` if `array` is empty.
8
+ * @example
9
+ * pickRandom(["rock", "paper", "scissors"]); // => e.g. "paper"
10
+ * pickRandom([1, 2, 3], () => 0.99); // => 3
11
+ * pickRandom([]); // => undefined
12
+ */
13
+ export declare function pickRandom<T>(array: readonly T[], random?: () => number): T | undefined;
14
+ //# sourceMappingURL=pickRandom.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pickRandom.d.ts","sourceRoot":"","sources":["../../src/array/pickRandom.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAC1B,KAAK,EAAE,SAAS,CAAC,EAAE,EACnB,MAAM,GAAE,MAAM,MAAoB,GACjC,CAAC,GAAG,SAAS,CAEf"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Returns one element of an array, picked at random (each equally likely).
3
+ *
4
+ * @param array - The array to pick from.
5
+ * @param random - Returns a number in `[0, 1)`. Defaults to `Math.random`;
6
+ * pass a seeded generator for deterministic tests.
7
+ * @returns The picked element, or `undefined` if `array` is empty.
8
+ * @example
9
+ * pickRandom(["rock", "paper", "scissors"]); // => e.g. "paper"
10
+ * pickRandom([1, 2, 3], () => 0.99); // => 3
11
+ * pickRandom([]); // => undefined
12
+ */
13
+ export function pickRandom(array, random = Math.random) {
14
+ return array[Math.floor(random() * array.length)];
15
+ }
@@ -0,0 +1,40 @@
1
+ /** WCAG 2 AA minimum for body text. */
2
+ export declare const TEXT_CONTRAST = 4.5;
3
+ /** WCAG 2 AA minimum for large text and graphical objects (icons, bars). */
4
+ export declare const GRAPHIC_CONTRAST = 3;
5
+ /**
6
+ * The WCAG 2 relative luminance of a colour, from 0 (black) to 1 (white).
7
+ *
8
+ * @param hex - A `#rgb` or `#rrggbb` colour (`#` optional).
9
+ * @returns Its relative luminance.
10
+ * @throws {TypeError} If `hex` is not a 3- or 6-digit hex colour.
11
+ * @example
12
+ * relativeLuminance("#ffffff"); // => 1
13
+ */
14
+ export declare function relativeLuminance(hex: string): number;
15
+ /**
16
+ * The WCAG 2 contrast ratio between two colours, from 1 (identical) to 21
17
+ * (black on white), whatever their order. Compare it with `TEXT_CONTRAST`
18
+ * (4.5) or `GRAPHIC_CONTRAST` (3).
19
+ *
20
+ * @param a - A `#rgb` or `#rrggbb` colour.
21
+ * @param b - Another one.
22
+ * @returns The contrast ratio.
23
+ * @throws {TypeError} If either colour is not a 3- or 6-digit hex colour.
24
+ * @example
25
+ * contrastRatio("#000", "#fff"); // => 21
26
+ * contrastRatio("#767676", "#ffffff") >= TEXT_CONTRAST; // => true
27
+ */
28
+ export declare function contrastRatio(a: string, b: string): number;
29
+ /**
30
+ * Black or white, whichever reads best on `background` (pure black or white
31
+ * always reaches at least 4.58:1 on some side).
32
+ *
33
+ * @param background - A `#rgb` or `#rrggbb` colour.
34
+ * @returns `"#ffffff"` or `"#000000"`.
35
+ * @example
36
+ * readableTextOn("#16283d"); // => "#ffffff"
37
+ * readableTextOn("#84cc16"); // => "#000000"
38
+ */
39
+ export declare function readableTextOn(background: string): "#ffffff" | "#000000";
40
+ //# sourceMappingURL=contrastRatio.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contrastRatio.d.ts","sourceRoot":"","sources":["../../src/color/contrastRatio.ts"],"names":[],"mappings":"AAAA,uCAAuC;AACvC,eAAO,MAAM,aAAa,MAAM,CAAC;AAEjC,4EAA4E;AAC5E,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAgBlC;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAMrD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAI1D;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAKxE"}
@@ -0,0 +1,62 @@
1
+ /** WCAG 2 AA minimum for body text. */
2
+ export const TEXT_CONTRAST = 4.5;
3
+ /** WCAG 2 AA minimum for large text and graphical objects (icons, bars). */
4
+ export const GRAPHIC_CONTRAST = 3;
5
+ const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
6
+ function channels(hex) {
7
+ const match = HEX.exec(hex.trim());
8
+ if (!match?.[1])
9
+ throw new TypeError(`Invalid hex colour: "${hex}"`);
10
+ const digits = match[1].length === 3 ? match[1].replace(/./g, "$&$&") : match[1];
11
+ return [0, 2, 4].map((i) => Number.parseInt(digits.slice(i, i + 2), 16));
12
+ }
13
+ /**
14
+ * The WCAG 2 relative luminance of a colour, from 0 (black) to 1 (white).
15
+ *
16
+ * @param hex - A `#rgb` or `#rrggbb` colour (`#` optional).
17
+ * @returns Its relative luminance.
18
+ * @throws {TypeError} If `hex` is not a 3- or 6-digit hex colour.
19
+ * @example
20
+ * relativeLuminance("#ffffff"); // => 1
21
+ */
22
+ export function relativeLuminance(hex) {
23
+ const [r, g, b] = channels(hex).map((channel) => {
24
+ const c = channel / 255;
25
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
26
+ });
27
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
28
+ }
29
+ /**
30
+ * The WCAG 2 contrast ratio between two colours, from 1 (identical) to 21
31
+ * (black on white), whatever their order. Compare it with `TEXT_CONTRAST`
32
+ * (4.5) or `GRAPHIC_CONTRAST` (3).
33
+ *
34
+ * @param a - A `#rgb` or `#rrggbb` colour.
35
+ * @param b - Another one.
36
+ * @returns The contrast ratio.
37
+ * @throws {TypeError} If either colour is not a 3- or 6-digit hex colour.
38
+ * @example
39
+ * contrastRatio("#000", "#fff"); // => 21
40
+ * contrastRatio("#767676", "#ffffff") >= TEXT_CONTRAST; // => true
41
+ */
42
+ export function contrastRatio(a, b) {
43
+ const la = relativeLuminance(a);
44
+ const lb = relativeLuminance(b);
45
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
46
+ }
47
+ /**
48
+ * Black or white, whichever reads best on `background` (pure black or white
49
+ * always reaches at least 4.58:1 on some side).
50
+ *
51
+ * @param background - A `#rgb` or `#rrggbb` colour.
52
+ * @returns `"#ffffff"` or `"#000000"`.
53
+ * @example
54
+ * readableTextOn("#16283d"); // => "#ffffff"
55
+ * readableTextOn("#84cc16"); // => "#000000"
56
+ */
57
+ export function readableTextOn(background) {
58
+ return contrastRatio("#ffffff", background) >=
59
+ contrastRatio("#000000", background)
60
+ ? "#ffffff"
61
+ : "#000000";
62
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./contrastRatio.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/color/index.ts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC"}
@@ -0,0 +1 @@
1
+ export * from "./contrastRatio.js";
@@ -1,8 +1,10 @@
1
+ import { type CsvFormulaOptions } from "./neutralizeCsvFormula.js";
1
2
  /**
2
3
  * Escapes a single CSV field per RFC 4180, quoting it only when needed: if it
3
4
  * contains a double quote, the delimiter or a line break, it's wrapped in
4
5
  * double quotes with any internal double quote doubled; otherwise it's
5
- * returned as-is.
6
+ * returned as-is. A field that would run as a spreadsheet formula (`=`, `+`,
7
+ * `-`, `@`…) is first prefixed with `'` (see `neutralizeCsvFormula`).
6
8
  *
7
9
  * Builds rows cell by cell. To serialize whole rows at once, see `toCsv`
8
10
  * (which always quotes, and defaults to `,`).
@@ -10,12 +12,14 @@
10
12
  * @param value - The raw field value.
11
13
  * @param delimiter - The cell separator the field will be joined with.
12
14
  * Defaults to `";"`, what French-locale Excel expects.
15
+ * @param options - `{ neutralizeFormulas }`, `true` by default.
13
16
  * @returns The value, quoted and escaped only if needed.
14
17
  * @example
15
18
  * escapeCsvField("Apple"); // => "Apple"
16
19
  * escapeCsvField("a;b"); // => '"a;b"'
17
20
  * escapeCsvField("a,b", ","); // => '"a,b"'
18
21
  * escapeCsvField('He said "hi"'); // => '"He said ""hi"""'
22
+ * escapeCsvField("=1+1"); // => "'=1+1"
19
23
  */
20
- export declare function escapeCsvField(value: string, delimiter?: string): string;
24
+ export declare function escapeCsvField(value: string, delimiter?: string, { neutralizeFormulas }?: CsvFormulaOptions): string;
21
25
  //# sourceMappingURL=escapeCsvField.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"escapeCsvField.d.ts","sourceRoot":"","sources":["../../src/csv/escapeCsvField.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,SAAM,GAAG,MAAM,CAQrE"}
1
+ {"version":3,"file":"escapeCsvField.d.ts","sourceRoot":"","sources":["../../src/csv/escapeCsvField.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,iBAAiB,EAEvB,MAAM,2BAA2B,CAAC;AAEnC;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EACb,SAAS,SAAM,EACf,EAAE,kBAAyB,EAAE,GAAE,iBAAsB,GACpD,MAAM,CASR"}
@@ -1,8 +1,10 @@
1
+ import { neutralizeCsvFormula, } from "./neutralizeCsvFormula.js";
1
2
  /**
2
3
  * Escapes a single CSV field per RFC 4180, quoting it only when needed: if it
3
4
  * contains a double quote, the delimiter or a line break, it's wrapped in
4
5
  * double quotes with any internal double quote doubled; otherwise it's
5
- * returned as-is.
6
+ * returned as-is. A field that would run as a spreadsheet formula (`=`, `+`,
7
+ * `-`, `@`…) is first prefixed with `'` (see `neutralizeCsvFormula`).
6
8
  *
7
9
  * Builds rows cell by cell. To serialize whole rows at once, see `toCsv`
8
10
  * (which always quotes, and defaults to `,`).
@@ -10,19 +12,22 @@
10
12
  * @param value - The raw field value.
11
13
  * @param delimiter - The cell separator the field will be joined with.
12
14
  * Defaults to `";"`, what French-locale Excel expects.
15
+ * @param options - `{ neutralizeFormulas }`, `true` by default.
13
16
  * @returns The value, quoted and escaped only if needed.
14
17
  * @example
15
18
  * escapeCsvField("Apple"); // => "Apple"
16
19
  * escapeCsvField("a;b"); // => '"a;b"'
17
20
  * escapeCsvField("a,b", ","); // => '"a,b"'
18
21
  * escapeCsvField('He said "hi"'); // => '"He said ""hi"""'
22
+ * escapeCsvField("=1+1"); // => "'=1+1"
19
23
  */
20
- export function escapeCsvField(value, delimiter = ";") {
21
- const needsQuoting = value.includes('"') ||
22
- value.includes(delimiter) ||
23
- value.includes("\n") ||
24
- value.includes("\r");
24
+ export function escapeCsvField(value, delimiter = ";", { neutralizeFormulas = true } = {}) {
25
+ const safe = neutralizeFormulas ? neutralizeCsvFormula(value) : value;
26
+ const needsQuoting = safe.includes('"') ||
27
+ safe.includes(delimiter) ||
28
+ safe.includes("\n") ||
29
+ safe.includes("\r");
25
30
  if (!needsQuoting)
26
- return value;
27
- return `"${value.replace(/"/g, '""')}"`;
31
+ return safe;
32
+ return `"${safe.replace(/"/g, '""')}"`;
28
33
  }
@@ -1,6 +1,7 @@
1
1
  export * from "./downloadCsv.js";
2
2
  export * from "./escapeCsvField.js";
3
3
  export * from "./formatCsvNumber.js";
4
+ export * from "./neutralizeCsvFormula.js";
4
5
  export * from "./parseCsvLine.js";
5
6
  export * from "./toCsv.js";
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/csv/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/csv/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,mBAAmB,CAAC;AAClC,cAAc,YAAY,CAAC"}
package/dist/csv/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./downloadCsv.js";
2
2
  export * from "./escapeCsvField.js";
3
3
  export * from "./formatCsvNumber.js";
4
+ export * from "./neutralizeCsvFormula.js";
4
5
  export * from "./parseCsvLine.js";
5
6
  export * from "./toCsv.js";
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Defuses CSV/formula injection: a text cell that starts with `=`, `+`, `-`,
3
+ * `@`, a tab or a carriage return is run as a formula by Excel, LibreOffice
4
+ * or Sheets (`=HYPERLINK("http://…","click")` becomes a trapped link). Such a
5
+ * cell is prefixed with an apostrophe so the spreadsheet shows it as text.
6
+ * Numbers written as text (`"-3"`, `"+1,5"`) are left untouched.
7
+ *
8
+ * `toCsv` and `escapeCsvField` already call it by default.
9
+ *
10
+ * @param value - The raw cell value.
11
+ * @returns The value, prefixed with `'` if it would run as a formula.
12
+ * @example
13
+ * neutralizeCsvFormula("=1+1"); // => "'=1+1"
14
+ * neutralizeCsvFormula("@SUM(A1)"); // => "'@SUM(A1)"
15
+ * neutralizeCsvFormula("-3"); // => "-3"
16
+ * neutralizeCsvFormula("Ann"); // => "Ann"
17
+ */
18
+ export declare function neutralizeCsvFormula(value: string): string;
19
+ export interface CsvFormulaOptions {
20
+ /**
21
+ * Prefixes with `'` the text cells a spreadsheet would run as formulas (see
22
+ * `neutralizeCsvFormula`). Defaults to `true`; turn it off only for a file
23
+ * no spreadsheet will open, or whose cells are not user input.
24
+ */
25
+ neutralizeFormulas?: boolean;
26
+ }
27
+ //# sourceMappingURL=neutralizeCsvFormula.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"neutralizeCsvFormula.d.ts","sourceRoot":"","sources":["../../src/csv/neutralizeCsvFormula.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAG1D;AAED,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B"}
@@ -0,0 +1,25 @@
1
+ // A number written as text ("-3", "+33 6", "1,5", "2e-3") stays a number.
2
+ const NUMERIC = /^[+-]?(\d+([.,]\d*)?|[.,]\d+)([eE][+-]?\d+)?$/;
3
+ const FORMULA_START = /^[=+\-@\t\r]/;
4
+ /**
5
+ * Defuses CSV/formula injection: a text cell that starts with `=`, `+`, `-`,
6
+ * `@`, a tab or a carriage return is run as a formula by Excel, LibreOffice
7
+ * or Sheets (`=HYPERLINK("http://…","click")` becomes a trapped link). Such a
8
+ * cell is prefixed with an apostrophe so the spreadsheet shows it as text.
9
+ * Numbers written as text (`"-3"`, `"+1,5"`) are left untouched.
10
+ *
11
+ * `toCsv` and `escapeCsvField` already call it by default.
12
+ *
13
+ * @param value - The raw cell value.
14
+ * @returns The value, prefixed with `'` if it would run as a formula.
15
+ * @example
16
+ * neutralizeCsvFormula("=1+1"); // => "'=1+1"
17
+ * neutralizeCsvFormula("@SUM(A1)"); // => "'@SUM(A1)"
18
+ * neutralizeCsvFormula("-3"); // => "-3"
19
+ * neutralizeCsvFormula("Ann"); // => "Ann"
20
+ */
21
+ export function neutralizeCsvFormula(value) {
22
+ if (!FORMULA_START.test(value) || NUMERIC.test(value))
23
+ return value;
24
+ return `'${value}`;
25
+ }
@@ -1,14 +1,19 @@
1
+ import { type CsvFormulaOptions } from "./neutralizeCsvFormula.js";
1
2
  /**
2
3
  * Serializes rows to CSV. Every cell is wrapped in double quotes (any internal
3
4
  * double quote doubled), so delimiters and line breaks inside a cell are safe.
5
+ * A text cell that would run as a spreadsheet formula (`=`, `+`, `-`, `@`…) is
6
+ * prefixed with `'` (see `neutralizeCsvFormula`); number cells never are.
4
7
  * Rows are joined with `\n`.
5
8
  *
6
9
  * @param rows - The rows to serialize, header row included if any.
7
10
  * @param delimiter - The cell separator. Defaults to `","`.
11
+ * @param options - `{ neutralizeFormulas }`, `true` by default.
8
12
  * @returns The CSV content.
9
13
  * @example
10
14
  * toCsv([["name", "age"], ["Ann", 32]]); // => '"name","age"\n"Ann","32"'
11
15
  * toCsv([['say "hi"']]); // => '"say ""hi"""'
16
+ * toCsv([["=1+1", -3]]); // => `"'=1+1","-3"`
12
17
  */
13
- export declare function toCsv(rows: readonly (readonly (string | number)[])[], delimiter?: string): string;
18
+ export declare function toCsv(rows: readonly (readonly (string | number)[])[], delimiter?: string, { neutralizeFormulas }?: CsvFormulaOptions): string;
14
19
  //# sourceMappingURL=toCsv.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"toCsv.d.ts","sourceRoot":"","sources":["../../src/csv/toCsv.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,wBAAgB,KAAK,CACnB,IAAI,EAAE,SAAS,CAAC,SAAS,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC,EAAE,EAC/C,SAAS,SAAM,GACd,MAAM,CAQR"}
1
+ {"version":3,"file":"toCsv.d.ts","sourceRoot":"","sources":["../../src/csv/toCsv.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,iBAAiB,EAEvB,MAAM,2BAA2B,CAAC;AAEnC;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,KAAK,CACnB,IAAI,EAAE,SAAS,CAAC,SAAS,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC,EAAE,EAC/C,SAAS,SAAM,EACf,EAAE,kBAAyB,EAAE,GAAE,iBAAsB,GACpD,MAAM,CAUR"}
package/dist/csv/toCsv.js CHANGED
@@ -1,19 +1,25 @@
1
+ import { neutralizeCsvFormula, } from "./neutralizeCsvFormula.js";
1
2
  /**
2
3
  * Serializes rows to CSV. Every cell is wrapped in double quotes (any internal
3
4
  * double quote doubled), so delimiters and line breaks inside a cell are safe.
5
+ * A text cell that would run as a spreadsheet formula (`=`, `+`, `-`, `@`…) is
6
+ * prefixed with `'` (see `neutralizeCsvFormula`); number cells never are.
4
7
  * Rows are joined with `\n`.
5
8
  *
6
9
  * @param rows - The rows to serialize, header row included if any.
7
10
  * @param delimiter - The cell separator. Defaults to `","`.
11
+ * @param options - `{ neutralizeFormulas }`, `true` by default.
8
12
  * @returns The CSV content.
9
13
  * @example
10
14
  * toCsv([["name", "age"], ["Ann", 32]]); // => '"name","age"\n"Ann","32"'
11
15
  * toCsv([['say "hi"']]); // => '"say ""hi"""'
16
+ * toCsv([["=1+1", -3]]); // => `"'=1+1","-3"`
12
17
  */
13
- export function toCsv(rows, delimiter = ",") {
18
+ export function toCsv(rows, delimiter = ",", { neutralizeFormulas = true } = {}) {
19
+ const text = (cell) => typeof cell === "string" && neutralizeFormulas
20
+ ? neutralizeCsvFormula(cell)
21
+ : String(cell);
14
22
  return rows
15
- .map((row) => row
16
- .map((cell) => `"${String(cell).replace(/"/g, '""')}"`)
17
- .join(delimiter))
23
+ .map((row) => row.map((cell) => `"${text(cell).replace(/"/g, '""')}"`).join(delimiter))
18
24
  .join("\n");
19
25
  }
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Formats the difference between two dates as a human-readable relative
3
3
  * string ("3 hours ago", "in 2 days"), backed by `Intl.RelativeTimeFormat`.
4
+ * Where the engine lacks it (Hermes on Android), a built-in fallback covers
5
+ * French and English, and formats any other locale in English.
4
6
  *
5
7
  * @param date - The date to describe.
6
8
  * @param baseDate - The reference date to compare against (defaults to now).
@@ -1 +1 @@
1
- {"version":3,"file":"formatRelativeTime.d.ts","sourceRoot":"","sources":["../../src/date/formatRelativeTime.ts"],"names":[],"mappings":"AAUA;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,IAAI,EACV,QAAQ,GAAE,IAAiB,EAC3B,MAAM,SAAO,GACZ,MAAM,CAWR"}
1
+ {"version":3,"file":"formatRelativeTime.d.ts","sourceRoot":"","sources":["../../src/date/formatRelativeTime.ts"],"names":[],"mappings":"AAuCA;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,IAAI,EACV,QAAQ,GAAE,IAAiB,EAC3B,MAAM,SAAO,GACZ,MAAM,CAmBR"}
@@ -7,9 +7,40 @@ const UNITS = [
7
7
  { unit: "minute", ms: 60 * 1000 },
8
8
  { unit: "second", ms: 1000 },
9
9
  ];
10
+ const FRENCH_UNITS = {
11
+ year: ["an", "ans"],
12
+ month: ["mois", "mois"],
13
+ week: ["semaine", "semaines"],
14
+ day: ["jour", "jours"],
15
+ hour: ["heure", "heures"],
16
+ minute: ["minute", "minutes"],
17
+ second: ["seconde", "secondes"],
18
+ };
19
+ // Hermes (React Native on Android) ships no `Intl.RelativeTimeFormat`: this
20
+ // covers French and English (any other locale gets English).
21
+ function formatWithoutIntl(value, unit, locale) {
22
+ const abs = Math.abs(value);
23
+ if (locale.split("-")[0]?.toLowerCase() === "fr") {
24
+ if (value === 0)
25
+ return "maintenant";
26
+ if (unit === "day" && abs === 1)
27
+ return value < 0 ? "hier" : "demain";
28
+ const [one, many] = FRENCH_UNITS[unit];
29
+ const text = `${abs} ${abs < 2 ? one : many}`;
30
+ return value < 0 ? `il y a ${text}` : `dans ${text}`;
31
+ }
32
+ if (value === 0)
33
+ return "now";
34
+ if (unit === "day" && abs === 1)
35
+ return value < 0 ? "yesterday" : "tomorrow";
36
+ const text = `${abs} ${unit}${abs === 1 ? "" : "s"}`;
37
+ return value < 0 ? `${text} ago` : `in ${text}`;
38
+ }
10
39
  /**
11
40
  * Formats the difference between two dates as a human-readable relative
12
41
  * string ("3 hours ago", "in 2 days"), backed by `Intl.RelativeTimeFormat`.
42
+ * Where the engine lacks it (Hermes on Android), a built-in fallback covers
43
+ * French and English, and formats any other locale in English.
13
44
  *
14
45
  * @param date - The date to describe.
15
46
  * @param baseDate - The reference date to compare against (defaults to now).
@@ -20,11 +51,14 @@ const UNITS = [
20
51
  */
21
52
  export function formatRelativeTime(date, baseDate = new Date(), locale = "en") {
22
53
  const diffMs = date.getTime() - baseDate.getTime();
23
- const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
54
+ const format = typeof Intl !== "undefined" && typeof Intl.RelativeTimeFormat === "function"
55
+ ? (value, unit) => new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(value, unit)
56
+ : (value, unit) => formatWithoutIntl(value, unit, locale);
24
57
  for (const { unit, ms } of UNITS) {
25
58
  if (Math.abs(diffMs) >= ms || unit === "second") {
26
- return rtf.format(Math.round(diffMs / ms), unit);
59
+ // `+ 0` turns a rounded -0 into 0.
60
+ return format(Math.round(diffMs / ms) + 0, unit);
27
61
  }
28
62
  }
29
- return rtf.format(0, "second");
63
+ return format(0, "second");
30
64
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./array/index.js";
2
2
  export * from "./async/index.js";
3
3
  export * from "./classes/index.js";
4
+ export * from "./color/index.js";
4
5
  export * from "./csv/index.js";
5
6
  export * from "./date/index.js";
6
7
  export * from "./files/index.js";
@@ -11,6 +12,7 @@ export * from "./image/index.js";
11
12
  export * from "./markdown/index.js";
12
13
  export * from "./number/index.js";
13
14
  export * from "./object/index.js";
15
+ export * from "./security/index.js";
14
16
  export * from "./string/index.js";
15
17
  export * from "./version/index.js";
16
18
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,oBAAoB,CAAC;AACnC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC"}
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  export * from "./array/index.js";
7
7
  export * from "./async/index.js";
8
8
  export * from "./classes/index.js";
9
+ export * from "./color/index.js";
9
10
  export * from "./csv/index.js";
10
11
  export * from "./date/index.js";
11
12
  export * from "./files/index.js";
@@ -16,5 +17,6 @@ export * from "./image/index.js";
16
17
  export * from "./markdown/index.js";
17
18
  export * from "./number/index.js";
18
19
  export * from "./object/index.js";
20
+ export * from "./security/index.js";
19
21
  export * from "./string/index.js";
20
22
  export * from "./version/index.js";
@@ -0,0 +1,65 @@
1
+ /** Where the failure counter is kept — e.g. expo-secure-store on Android. */
2
+ export interface AttemptLimiterStorage {
3
+ getItem: (key: string) => Promise<string | null>;
4
+ setItem: (key: string, value: string) => Promise<void>;
5
+ deleteItem: (key: string) => Promise<void>;
6
+ }
7
+ export interface AttemptLimiterOptions {
8
+ storage: AttemptLimiterStorage;
9
+ /** Storage key of the failure counter. */
10
+ key: string;
11
+ /** Failures allowed before any delay. @defaultValue 4 */
12
+ freeAttempts?: number;
13
+ /**
14
+ * Delay imposed after the first failure past `freeAttempts`, the second…
15
+ * the last one repeating for every failure after.
16
+ * @defaultValue 30 s, 1 min, 5 min, 15 min
17
+ */
18
+ delaysMs?: readonly number[];
19
+ /** @defaultValue `Date.now` */
20
+ now?: () => number;
21
+ }
22
+ export type AttemptResult = {
23
+ ok: true;
24
+ } | {
25
+ ok: false;
26
+ lockedUntil: number;
27
+ };
28
+ export interface AttemptLimiter {
29
+ /**
30
+ * Runs `verify` (e.g. compares a PIN) unless a delay is running, and
31
+ * updates the counter: reset on success, one more failure otherwise.
32
+ * `lockedUntil` is the end of the delay now imposed (0 for none). Attempts
33
+ * run one at a time, so parallel ones can't lose a failure.
34
+ */
35
+ attempt: (verify: () => boolean | Promise<boolean>) => Promise<AttemptResult>;
36
+ /** End of the running delay (a timestamp), or 0 if an attempt is allowed. */
37
+ getLockedUntil: () => Promise<number>;
38
+ /** Forgets every failure (e.g. once the PIN is reset another way). */
39
+ reset: () => Promise<void>;
40
+ /** The delay imposed after `count` consecutive failures (0 for none). */
41
+ delayAfter: (count: number) => number;
42
+ }
43
+ /**
44
+ * Limits attempts at a secret (a PIN, a passphrase) with growing delays:
45
+ * after `freeAttempts` failures, each new one imposes a wait. The counter is
46
+ * persisted through `storage`, so closing the app doesn't reset it, and an
47
+ * unreadable value is never taken as "no failure" (tampering with it would
48
+ * otherwise lift the limit): the next failure is then delayed at once. A
49
+ * last failure dated in the future (the clock was set back) restarts the
50
+ * delay from now instead of locking until that date.
51
+ *
52
+ * @example
53
+ * const pinLock = createAttemptLimiter({
54
+ * storage: {
55
+ * getItem: SecureStore.getItemAsync,
56
+ * setItem: SecureStore.setItemAsync,
57
+ * deleteItem: SecureStore.deleteItemAsync,
58
+ * },
59
+ * key: "pin-failures",
60
+ * });
61
+ * const result = await pinLock.attempt(() => isPinCorrect(typedPin));
62
+ * if (!result.ok && result.lockedUntil) showWaitUntil(result.lockedUntil);
63
+ */
64
+ export declare function createAttemptLimiter({ storage, key, freeAttempts, delaysMs, now, }: AttemptLimiterOptions): AttemptLimiter;
65
+ //# sourceMappingURL=createAttemptLimiter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createAttemptLimiter.d.ts","sourceRoot":"","sources":["../../src/security/createAttemptLimiter.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACjD,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACvD,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5C;AAED,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,qBAAqB,CAAC;IAC/B,0CAA0C;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,yDAAyD;IACzD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,+BAA+B;IAC/B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,aAAa,GAAG;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9E,MAAM,WAAW,cAAc;IAC7B;;;;;OAKG;IACH,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC;IAC9E,6EAA6E;IAC7E,cAAc,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IACtC,sEAAsE;IACtE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3B,yEAAyE;IACzE,UAAU,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;CACvC;AAUD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,oBAAoB,CAAC,EACnC,OAAO,EACP,GAAG,EACH,YAAgB,EAChB,QAA4B,EAC5B,GAAc,GACf,EAAE,qBAAqB,GAAG,cAAc,CA6ExC"}
@@ -0,0 +1,96 @@
1
+ const DEFAULT_DELAYS_MS = [30_000, 60_000, 5 * 60_000, 15 * 60_000];
2
+ const MAX_COUNT = 1_000_000;
3
+ /**
4
+ * Limits attempts at a secret (a PIN, a passphrase) with growing delays:
5
+ * after `freeAttempts` failures, each new one imposes a wait. The counter is
6
+ * persisted through `storage`, so closing the app doesn't reset it, and an
7
+ * unreadable value is never taken as "no failure" (tampering with it would
8
+ * otherwise lift the limit): the next failure is then delayed at once. A
9
+ * last failure dated in the future (the clock was set back) restarts the
10
+ * delay from now instead of locking until that date.
11
+ *
12
+ * @example
13
+ * const pinLock = createAttemptLimiter({
14
+ * storage: {
15
+ * getItem: SecureStore.getItemAsync,
16
+ * setItem: SecureStore.setItemAsync,
17
+ * deleteItem: SecureStore.deleteItemAsync,
18
+ * },
19
+ * key: "pin-failures",
20
+ * });
21
+ * const result = await pinLock.attempt(() => isPinCorrect(typedPin));
22
+ * if (!result.ok && result.lockedUntil) showWaitUntil(result.lockedUntil);
23
+ */
24
+ export function createAttemptLimiter({ storage, key, freeAttempts = 4, delaysMs = DEFAULT_DELAYS_MS, now = Date.now, }) {
25
+ let queue = Promise.resolve();
26
+ function delayAfter(count) {
27
+ if (count <= freeAttempts || delaysMs.length === 0)
28
+ return 0;
29
+ const index = Math.min(count - freeAttempts - 1, delaysMs.length - 1);
30
+ return delaysMs[index] ?? 0;
31
+ }
32
+ async function readFailures() {
33
+ const raw = await storage.getItem(key);
34
+ if (raw === null || raw === "")
35
+ return { count: 0, lastFailureAt: 0 };
36
+ try {
37
+ const { count, lastFailureAt } = JSON.parse(raw);
38
+ if (typeof count === "number" &&
39
+ Number.isInteger(count) &&
40
+ count >= 0 &&
41
+ count <= MAX_COUNT &&
42
+ typeof lastFailureAt === "number" &&
43
+ Number.isFinite(lastFailureAt) &&
44
+ lastFailureAt >= 0) {
45
+ return { count, lastFailureAt };
46
+ }
47
+ }
48
+ catch {
49
+ // Unreadable: handled below.
50
+ }
51
+ return { count: freeAttempts, lastFailureAt: 0 };
52
+ }
53
+ async function writeFailures(failures) {
54
+ await storage.setItem(key, JSON.stringify(failures));
55
+ }
56
+ async function lockedUntil() {
57
+ const failures = await readFailures();
58
+ const current = now();
59
+ if (failures.lastFailureAt > current) {
60
+ failures.lastFailureAt = current;
61
+ await writeFailures(failures);
62
+ }
63
+ const until = failures.lastFailureAt + delayAfter(failures.count);
64
+ return until > current ? until : 0;
65
+ }
66
+ function serialized(task) {
67
+ const run = queue.then(task, task);
68
+ queue = run.catch(() => undefined);
69
+ return run;
70
+ }
71
+ return {
72
+ delayAfter,
73
+ getLockedUntil: () => serialized(lockedUntil),
74
+ reset: () => serialized(() => storage.deleteItem(key)),
75
+ attempt: (verify) => serialized(async () => {
76
+ const locked = await lockedUntil();
77
+ if (locked)
78
+ return { ok: false, lockedUntil: locked };
79
+ if (await verify()) {
80
+ await storage.deleteItem(key);
81
+ return { ok: true };
82
+ }
83
+ const { count } = await readFailures();
84
+ const failures = {
85
+ count: Math.min(count + 1, MAX_COUNT),
86
+ lastFailureAt: now(),
87
+ };
88
+ await writeFailures(failures);
89
+ const delay = delayAfter(failures.count);
90
+ return {
91
+ ok: false,
92
+ lockedUntil: delay ? failures.lastFailureAt + delay : 0,
93
+ };
94
+ }),
95
+ };
96
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./createAttemptLimiter.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/security/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC"}
@@ -0,0 +1 @@
1
+ export * from "./createAttemptLimiter.js";
@@ -9,7 +9,9 @@ export interface PluralizeOptions {
9
9
  }
10
10
  /**
11
11
  * Pluralizes a word based on a count. Which counts take the singular follows
12
- * the locale's plural rules (`Intl.PluralRules`). Without an explicit plural
12
+ * the locale's plural rules (`Intl.PluralRules`); where the engine lacks it
13
+ * (Hermes on Android), French keeps the singular below 2 and other languages
14
+ * only for ±1. Without an explicit plural
13
15
  * form, the default suffix is naive: in English `s`, `y` → `ies`, `es` after
14
16
  * `s`/`x`/`z`/`sh`/`ch`; in French `s`, nothing after `s`/`x`/`z`; `s` in
15
17
  * any other language.
@@ -1 +1 @@
1
- {"version":3,"file":"pluralize.d.ts","sourceRoot":"","sources":["../../src/string/pluralize.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAyBD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,SAAS,CACvB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,GAAG,gBAAgB,GAClC,MAAM,CAKR;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,IACpC,OAAO,MAAM,EAAE,UAAU,MAAM,EAAE,SAAS,MAAM,KAAG,MAAM,CAElE"}
1
+ {"version":3,"file":"pluralize.d.ts","sourceRoot":"","sources":["../../src/string/pluralize.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAwCD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,SAAS,CACvB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,GAAG,gBAAgB,GAClC,MAAM,CAKR;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,IACpC,OAAO,MAAM,EAAE,UAAU,MAAM,EAAE,SAAS,MAAM,KAAG,MAAM,CAElE"}
@@ -1,5 +1,18 @@
1
1
  const pluralRules = new Map();
2
+ function languageOf(locale) {
3
+ return locale.split("-")[0]?.toLowerCase();
4
+ }
5
+ // Hermes (React Native on Android) ships no `Intl.PluralRules`: without it,
6
+ // French keeps the singular below 2, every other language only for ±1.
7
+ function fallbackTakesSingular(count, locale) {
8
+ return languageOf(locale) === "fr"
9
+ ? Math.abs(count) < 2
10
+ : Math.abs(count) === 1;
11
+ }
2
12
  function takesSingular(count, locale) {
13
+ if (typeof Intl === "undefined" || typeof Intl.PluralRules !== "function") {
14
+ return fallbackTakesSingular(count, locale);
15
+ }
3
16
  let rules = pluralRules.get(locale);
4
17
  if (!rules) {
5
18
  rules = new Intl.PluralRules(locale);
@@ -8,7 +21,7 @@ function takesSingular(count, locale) {
8
21
  return rules.select(count) === "one";
9
22
  }
10
23
  function defaultPlural(singular, locale) {
11
- const language = locale.split("-")[0]?.toLowerCase();
24
+ const language = languageOf(locale);
12
25
  if (language === "fr") {
13
26
  return /[sxz]$/i.test(singular) ? singular : `${singular}s`;
14
27
  }
@@ -22,7 +35,9 @@ function defaultPlural(singular, locale) {
22
35
  }
23
36
  /**
24
37
  * Pluralizes a word based on a count. Which counts take the singular follows
25
- * the locale's plural rules (`Intl.PluralRules`). Without an explicit plural
38
+ * the locale's plural rules (`Intl.PluralRules`); where the engine lacks it
39
+ * (Hermes on Android), French keeps the singular below 2 and other languages
40
+ * only for ±1. Without an explicit plural
26
41
  * form, the default suffix is naive: in English `s`, `y` → `ies`, `es` after
27
42
  * `s`/`x`/`z`/`sh`/`ch`; in French `s`, nothing after `s`/`x`/`z`; `s` in
28
43
  * any other language.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forthtilliath/ts-kit",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -22,12 +22,12 @@
22
22
  "./*": "./dist/*.js"
23
23
  },
24
24
  "devDependencies": {
25
- "@vitest/coverage-v8": "5.0.2",
25
+ "@vitest/coverage-v8": "5.0.5",
26
26
  "eslint": "10.7.0",
27
27
  "jsdom": "^30.1.1",
28
28
  "typescript": "^5.9.3",
29
- "vitest": "^5.0.2",
30
- "@forthtilliath/eslint-config": "0.9.0",
29
+ "vitest": "^5.0.5",
30
+ "@forthtilliath/eslint-config": "0.10.0",
31
31
  "@forthtilliath/typescript-config": "0.1.1"
32
32
  },
33
33
  "scripts": {