@forthtilliath/ts-kit 0.12.0 → 0.14.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 (40) hide show
  1. package/README.md +36 -8
  2. package/dist/array/index.d.ts +2 -0
  3. package/dist/array/index.d.ts.map +1 -1
  4. package/dist/array/index.js +2 -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/color/contrastRatio.d.ts +40 -0
  12. package/dist/color/contrastRatio.d.ts.map +1 -0
  13. package/dist/color/contrastRatio.js +62 -0
  14. package/dist/color/index.d.ts +2 -0
  15. package/dist/color/index.d.ts.map +1 -0
  16. package/dist/color/index.js +1 -0
  17. package/dist/csv/escapeCsvField.d.ts +6 -2
  18. package/dist/csv/escapeCsvField.d.ts.map +1 -1
  19. package/dist/csv/escapeCsvField.js +13 -8
  20. package/dist/csv/index.d.ts +1 -0
  21. package/dist/csv/index.d.ts.map +1 -1
  22. package/dist/csv/index.js +1 -0
  23. package/dist/csv/neutralizeCsvFormula.d.ts +27 -0
  24. package/dist/csv/neutralizeCsvFormula.d.ts.map +1 -0
  25. package/dist/csv/neutralizeCsvFormula.js +25 -0
  26. package/dist/csv/toCsv.d.ts +6 -1
  27. package/dist/csv/toCsv.d.ts.map +1 -1
  28. package/dist/csv/toCsv.js +10 -4
  29. package/dist/date/formatRelativeTime.d.ts +2 -0
  30. package/dist/date/formatRelativeTime.d.ts.map +1 -1
  31. package/dist/date/formatRelativeTime.js +37 -3
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/markdown/parseChangelogNotes.d.ts.map +1 -1
  36. package/dist/markdown/parseChangelogNotes.js +9 -2
  37. package/dist/string/pluralize.d.ts +3 -1
  38. package/dist/string/pluralize.d.ts.map +1 -1
  39. package/dist/string/pluralize.js +17 -2
  40. package/package.json +3 -3
package/README.md CHANGED
@@ -57,6 +57,11 @@ 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.
@@ -114,16 +119,35 @@ Importing them is harmless anywhere: the DOM is only touched when called.
114
119
  rarely needed since `FArray` is already assignable to `T[]` anywhere a
115
120
  plain array is expected, but useful when the runtime type matters too.
116
121
 
122
+ ### `color`
123
+
124
+ - `contrastRatio(a, b)` — WCAG 2 contrast ratio between two hex colours
125
+ (`#rgb`/`#rrggbb`), from 1 to 21; compare with `TEXT_CONTRAST` (4.5) or
126
+ `GRAPHIC_CONTRAST` (3). Throws a `TypeError` on an invalid colour.
127
+ - `relativeLuminance(hex)` — WCAG 2 relative luminance, 0 (black) to 1.
128
+ - `readableTextOn(background)` — `"#ffffff"` or `"#000000"`, whichever reads
129
+ best on that background.
130
+
117
131
  ### `csv`
118
132
 
133
+ Every text cell that starts with `=`, `+`, `-`, `@`, a tab or a carriage
134
+ return is prefixed with `'` by `toCsv` and `escapeCsvField`, so Excel,
135
+ LibreOffice or Sheets show it instead of running it as a formula (CSV
136
+ injection: `=HYPERLINK(…)` typed as a name becomes a trapped link). Numbers,
137
+ and numbers written as text (`"-3"`), are left as they are. Pass
138
+ `{ neutralizeFormulas: false }` only for a file no spreadsheet opens.
139
+
119
140
  - `parseCsvLine(line, delimiter?)` — splits one CSV line into trimmed cells;
120
141
  quoted cells may contain the delimiter, `""` stands for a literal quote.
121
142
  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).
143
+ - `toCsv(rows, delimiter?, { neutralizeFormulas? })` — serializes whole rows
144
+ to CSV, every cell quoted and escaped, rows joined with `\n`. Delimiter
145
+ defaults to `","`.
146
+ - `escapeCsvField(value, delimiter?, { neutralizeFormulas? })` — escapes a
147
+ single field (RFC 4180), quoting it only if it contains a `"`, the
148
+ delimiter or a line break — to build rows cell by cell. Delimiter defaults
149
+ to `";"` (French-locale Excel).
150
+ - `neutralizeCsvFormula(value)` — the formula guard above, on its own.
127
151
  - `formatCsvNumber(value, decimals?)` — formats a number with a comma decimal
128
152
  separator (French-locale spreadsheets) instead of JS's dot.
129
153
  - `downloadCsv(filename, content)` — browser download as `text/csv`, with a
@@ -144,7 +168,9 @@ Importing them is harmless anywhere: the DOM is only touched when called.
144
168
  - `mondayFirstWeekday(date)` — weekday index with Monday = `0` … Sunday = `6`,
145
169
  from a `Date` or a `YYYY-MM-DD` string; offsets a Monday-first month grid.
146
170
  - `formatRelativeTime(date, baseDate?, locale?)` — human-readable relative
147
- time ("3 hours ago"), backed by `Intl.RelativeTimeFormat`.
171
+ time ("3 hours ago"), backed by `Intl.RelativeTimeFormat`. Without it
172
+ (Hermes, React Native on Android), a built-in French/English fallback
173
+ ("il y a 3 heures"; other locales in English) instead of a crash.
148
174
  - `formatRecentDate(date, { now?, locale? })` — a past moment as a "last
149
175
  saved…" line reads best: `"aujourd'hui à 21:42"`, `"hier à 08:05"`, then
150
176
  `"le 11 octobre 2026"` (`locale: "en"`: `"today at…"`, `"on October 11, 2026"`).
@@ -210,7 +236,7 @@ Building blocks for writing an iCalendar (`.ics`, RFC 5545) file.
210
236
  ### `markdown`
211
237
 
212
238
  - `parseChangelogNotes(notes)` — parses a small subset of Markdown
213
- (`### heading`, `- item`, `**bold**`) commonly found in GitHub release
239
+ (`#` to `###### heading`, `- item`, `**bold**`) commonly found in GitHub release
214
240
  notes into a list of typed blocks (`heading` / `item` / `text`, each with
215
241
  `bold`-aware segments), ready for a UI layer to render without a full
216
242
  Markdown dependency.
@@ -250,7 +276,9 @@ Building blocks for writing an iCalendar (`.ics`, RFC 5545) file.
250
276
  - `pluralize(count, singular, plural? | { plural?, locale? })` — picks the
251
277
  singular or the plural form. Which counts take the singular follows the
252
278
  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
279
+ English, 0 and 1.5 too in French. Where `Intl.PluralRules` is missing
280
+ (Hermes, React Native on Android), French keeps the singular below 2 and
281
+ other languages only for ±1. Without an explicit plural form, a naive
254
282
  suffix: English `s`/`es`/`ies`, French `s` (nothing after `s`/`x`/`z`),
255
283
  `s` elsewhere.
256
284
  - `createPluralize(locale)` — `pluralize` bound to a locale, declared once per
@@ -2,6 +2,8 @@ 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";
6
8
  export * from "./rankByNameMatch.js";
7
9
  export * from "./shuffle.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,sBAAsB,CAAC;AACrC,cAAc,cAAc,CAAC;AAC7B,cAAc,wBAAwB,CAAC;AACvC,cAAc,gBAAgB,CAAC"}
@@ -2,6 +2,8 @@ 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";
6
8
  export * from "./rankByNameMatch.js";
7
9
  export * from "./shuffle.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,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";
@@ -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,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";
@@ -1 +1 @@
1
- {"version":3,"file":"parseChangelogNotes.d.ts","sourceRoot":"","sources":["../../src/markdown/parseChangelogNotes.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,OAAO,CAAC;CACf;AAED,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,gBAAgB,EAAE,CAAA;CAAE,GAC9C;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,gBAAgB,EAAE,CAAA;CAAE,CAAC;AAsBnD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc,EAAE,CAiBnE"}
1
+ {"version":3,"file":"parseChangelogNotes.d.ts","sourceRoot":"","sources":["../../src/markdown/parseChangelogNotes.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,OAAO,CAAC;CACf;AAED,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,gBAAgB,EAAE,CAAA;CAAE,GAC9C;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,gBAAgB,EAAE,CAAA;CAAE,CAAC;AA0BnD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc,EAAE,CAqBnE"}
@@ -23,14 +23,21 @@ function parseInlineSegments(text) {
23
23
  }
24
24
  return segments;
25
25
  }
26
+ // Any ATX heading level ("# " to "###### ") — release notes are not always
27
+ // written with "###" (e.g. "## What's new" on a hand-written GitHub release).
28
+ const headingPattern = /^#{1,6}\s+/;
26
29
  export function parseChangelogNotes(notes) {
27
30
  return notes
28
31
  .split("\n")
29
32
  .map((line) => line.trim())
30
33
  .filter((line) => line.length > 0)
31
34
  .map((line) => {
32
- if (line.startsWith("### ")) {
33
- return { type: "heading", text: line.slice(4).trim() };
35
+ const heading = headingPattern.exec(line);
36
+ if (heading) {
37
+ return {
38
+ type: "heading",
39
+ text: line.slice(heading[0].length).trim(),
40
+ };
34
41
  }
35
42
  if (line.startsWith("- ")) {
36
43
  return {
@@ -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.12.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -27,8 +27,8 @@
27
27
  "jsdom": "^30.1.1",
28
28
  "typescript": "^5.9.3",
29
29
  "vitest": "^5.0.2",
30
- "@forthtilliath/eslint-config": "0.9.0",
31
- "@forthtilliath/typescript-config": "0.1.1"
30
+ "@forthtilliath/typescript-config": "0.1.1",
31
+ "@forthtilliath/eslint-config": "0.9.0"
32
32
  },
33
33
  "scripts": {
34
34
  "dev": "tsc --watch -p tsconfig.build.json",