@forthtilliath/ts-kit 0.3.0 → 0.5.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 (53) hide show
  1. package/README.md +36 -2
  2. package/dist/array/getMostRecentIds.d.ts +21 -0
  3. package/dist/array/getMostRecentIds.d.ts.map +1 -0
  4. package/dist/array/getMostRecentIds.js +30 -0
  5. package/dist/array/index.d.ts +3 -0
  6. package/dist/array/index.d.ts.map +1 -1
  7. package/dist/array/index.js +3 -0
  8. package/dist/array/nextInCycle.d.ts +15 -0
  9. package/dist/array/nextInCycle.d.ts.map +1 -0
  10. package/dist/array/nextInCycle.js +19 -0
  11. package/dist/array/rankByNameMatch.d.ts +19 -0
  12. package/dist/array/rankByNameMatch.d.ts.map +1 -0
  13. package/dist/array/rankByNameMatch.js +45 -0
  14. package/dist/date/getPeriodStartMs.d.ts +16 -0
  15. package/dist/date/getPeriodStartMs.d.ts.map +1 -0
  16. package/dist/date/getPeriodStartMs.js +24 -0
  17. package/dist/date/index.d.ts +1 -0
  18. package/dist/date/index.d.ts.map +1 -1
  19. package/dist/date/index.js +1 -0
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -0
  23. package/dist/markdown/index.d.ts +2 -0
  24. package/dist/markdown/index.d.ts.map +1 -0
  25. package/dist/markdown/index.js +1 -0
  26. package/dist/markdown/parseChangelogNotes.d.ts +16 -0
  27. package/dist/markdown/parseChangelogNotes.d.ts.map +1 -0
  28. package/dist/markdown/parseChangelogNotes.js +43 -0
  29. package/dist/number/formatCsvNumber.d.ts +14 -0
  30. package/dist/number/formatCsvNumber.d.ts.map +1 -0
  31. package/dist/number/formatCsvNumber.js +15 -0
  32. package/dist/number/index.d.ts +1 -0
  33. package/dist/number/index.d.ts.map +1 -1
  34. package/dist/number/index.js +1 -0
  35. package/dist/string/escapeCsvField.d.ts +14 -0
  36. package/dist/string/escapeCsvField.d.ts.map +1 -0
  37. package/dist/string/escapeCsvField.js +17 -0
  38. package/dist/string/escapeHtml.d.ts +12 -0
  39. package/dist/string/escapeHtml.d.ts.map +1 -0
  40. package/dist/string/escapeHtml.js +17 -0
  41. package/dist/string/index.d.ts +3 -0
  42. package/dist/string/index.d.ts.map +1 -1
  43. package/dist/string/index.js +3 -0
  44. package/dist/string/normalizeForSearch.d.ts +12 -0
  45. package/dist/string/normalizeForSearch.d.ts.map +1 -0
  46. package/dist/string/normalizeForSearch.js +25 -0
  47. package/dist/version/compareVersions.d.ts +7 -0
  48. package/dist/version/compareVersions.d.ts.map +1 -0
  49. package/dist/version/compareVersions.js +17 -0
  50. package/dist/version/index.d.ts +2 -0
  51. package/dist/version/index.d.ts.map +1 -0
  52. package/dist/version/index.js +1 -0
  53. package/package.json +1 -1
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @forthtilliath/ts-kit
2
2
 
3
3
  Framework-agnostic TypeScript utility functions — arrays, objects, strings,
4
- async control-flow, dates, numbers, browser file downloads, and a couple of
5
- `Array` extensions with better type-narrowing.
4
+ async control-flow, dates, numbers, browser file downloads, changelog/version
5
+ parsing, and a couple of `Array` extensions with better type-narrowing.
6
6
 
7
7
  ## Install
8
8
 
@@ -37,6 +37,15 @@ import { chunk } from "@forthtilliath/ts-kit/array/chunk";
37
37
 
38
38
  - `chunk(array, size)` — splits an array into chunks of a given size.
39
39
  - `flattenDeep(array)` — recursively flattens nested arrays.
40
+ - `getMostRecentIds(rows, limit?)` — most recently occurring distinct ids
41
+ (`{ id, occurredAt }[]`), most recent first, deduplicated, limited
42
+ (default 5).
43
+ - `nextInCycle(ids, currentId)` — next id in a short list (e.g. cycling
44
+ through recents on tap) — wraps to the first if `currentId` is at the end
45
+ or no longer in the list.
46
+ - `rankByNameMatch(items, query, getName)` — ranks `items` by relevance to
47
+ `query`: earlier match position first, then shorter name — for a
48
+ search-as-you-type list.
40
49
 
41
50
  ### `async`
42
51
 
@@ -78,6 +87,9 @@ import { chunk } from "@forthtilliath/ts-kit/array/chunk";
78
87
  - `isSameDay(a, b)` — checks whether two dates fall on the same local day.
79
88
  - `formatRelativeTime(date, baseDate?, locale?)` — human-readable relative
80
89
  time ("3 hours ago"), backed by `Intl.RelativeTimeFormat`.
90
+ - `getPeriodStartMs(period, now?)` — start timestamp (ms) for a
91
+ `"today" | "7d" | "30d" | "all"` period filter, or `null` for `"all"`.
92
+ `"today"` is the current calendar day, not a rolling 24h window.
81
93
 
82
94
  ### `files`
83
95
 
@@ -91,6 +103,14 @@ import { chunk } from "@forthtilliath/ts-kit/array/chunk";
91
103
  - `randomId()` — `crypto.randomUUID()` with fallbacks for older/insecure
92
104
  contexts.
93
105
 
106
+ ### `markdown`
107
+
108
+ - `parseChangelogNotes(notes)` — parses a small subset of Markdown
109
+ (`### heading`, `- item`, `**bold**`) commonly found in GitHub release
110
+ notes into a list of typed blocks (`heading` / `item` / `text`, each with
111
+ `bold`-aware segments), ready for a UI layer to render without a full
112
+ Markdown dependency.
113
+
94
114
  ### `maths`
95
115
 
96
116
  - `sum(numbers)` — sum of an array of numbers.
@@ -102,6 +122,8 @@ import { chunk } from "@forthtilliath/ts-kit/array/chunk";
102
122
  - `round(value, decimals?)` — rounds to a given number of decimal places.
103
123
  - `formatBytes(bytes, decimals?)` — human-readable byte size ("1.5 MB").
104
124
  - `formatDuration(ms)` — human-readable duration ("1d 1h 1m 1s").
125
+ - `formatCsvNumber(value, decimals?)` — formats a number with a comma
126
+ decimal separator (French-locale spreadsheets) instead of JS's dot.
105
127
 
106
128
  ### `object`
107
129
 
@@ -122,6 +144,18 @@ import { chunk } from "@forthtilliath/ts-kit/array/chunk";
122
144
  hyphenated).
123
145
  - `pluralize(count, singular, plural?)` — naive English pluralization, with an
124
146
  override for irregular words.
147
+ - `normalizeForSearch(text)` — lowercases, trims, strips accents, and expands
148
+ œ/æ ligatures (which `normalize("NFD")` alone doesn't decompose) — for
149
+ accent/case-insensitive search matching.
150
+ - `escapeCsvField(value)` — quotes and escapes a CSV field (RFC 4180) only if
151
+ it contains a `"`, `;`, or newline.
152
+ - `escapeHtml(text)` — basic HTML entity escaping (`&`, `<`, `>`, `"`) for
153
+ inserting user text into an HTML template.
154
+
155
+ ### `version`
156
+
157
+ - `compareVersions(a, b)` — compares two `"x.y.z"` version strings, segment
158
+ by segment; `-1`/`0`/`1`.
125
159
 
126
160
  ## Scripts
127
161
 
@@ -0,0 +1,21 @@
1
+ export interface RecentIdRow {
2
+ id: number | null;
3
+ occurredAt: string;
4
+ }
5
+ /**
6
+ * Returns the most recently occurring distinct ids, most recent first —
7
+ * useful for a "recents" picker (a food, a container, or any other dated
8
+ * entity). Rows with a `null` id (a reference deleted since) are ignored:
9
+ * there's nothing left to offer for them.
10
+ *
11
+ * @param rows - Rows carrying an id (or `null`) and an ISO/parsable date.
12
+ * @param limit - Max number of ids to return. Defaults to `5`.
13
+ * @returns Distinct ids, most recent first, limited to `limit`.
14
+ * @example
15
+ * getMostRecentIds([
16
+ * { id: 1, occurredAt: "2026-01-01T00:00:00.000Z" },
17
+ * { id: 2, occurredAt: "2026-01-03T00:00:00.000Z" },
18
+ * ]); // => [2, 1]
19
+ */
20
+ export declare function getMostRecentIds(rows: RecentIdRow[], limit?: number): number[];
21
+ //# sourceMappingURL=getMostRecentIds.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"getMostRecentIds.d.ts","sourceRoot":"","sources":["../../src/array/getMostRecentIds.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB;AAID;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,WAAW,EAAE,EACnB,KAAK,SAAgB,GACpB,MAAM,EAAE,CAcV"}
@@ -0,0 +1,30 @@
1
+ const DEFAULT_LIMIT = 5;
2
+ /**
3
+ * Returns the most recently occurring distinct ids, most recent first —
4
+ * useful for a "recents" picker (a food, a container, or any other dated
5
+ * entity). Rows with a `null` id (a reference deleted since) are ignored:
6
+ * there's nothing left to offer for them.
7
+ *
8
+ * @param rows - Rows carrying an id (or `null`) and an ISO/parsable date.
9
+ * @param limit - Max number of ids to return. Defaults to `5`.
10
+ * @returns Distinct ids, most recent first, limited to `limit`.
11
+ * @example
12
+ * getMostRecentIds([
13
+ * { id: 1, occurredAt: "2026-01-01T00:00:00.000Z" },
14
+ * { id: 2, occurredAt: "2026-01-03T00:00:00.000Z" },
15
+ * ]); // => [2, 1]
16
+ */
17
+ export function getMostRecentIds(rows, limit = DEFAULT_LIMIT) {
18
+ const sorted = [...rows].sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime());
19
+ const seen = new Set();
20
+ const result = [];
21
+ for (const row of sorted) {
22
+ if (row.id == null || seen.has(row.id))
23
+ continue;
24
+ seen.add(row.id);
25
+ result.push(row.id);
26
+ if (result.length >= limit)
27
+ break;
28
+ }
29
+ return result;
30
+ }
@@ -1,3 +1,6 @@
1
1
  export * from "./chunk.js";
2
2
  export * from "./flattenDeep.js";
3
+ export * from "./getMostRecentIds.js";
4
+ export * from "./nextInCycle.js";
5
+ export * from "./rankByNameMatch.js";
3
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/array/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/array/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC;AACjC,cAAc,uBAAuB,CAAC;AACtC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC"}
@@ -1,2 +1,5 @@
1
1
  export * from "./chunk.js";
2
2
  export * from "./flattenDeep.js";
3
+ export * from "./getMostRecentIds.js";
4
+ export * from "./nextInCycle.js";
5
+ export * from "./rankByNameMatch.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Returns the next element in a short list (e.g. cycling through a few
3
+ * recent items on tap). Wraps to the first element if the current one is no
4
+ * longer in the list (deleted, or out of range) or was the last one.
5
+ *
6
+ * @param ids - The list to cycle through.
7
+ * @param currentId - The currently selected id, or `null` for none.
8
+ * @returns The next id, or `null` if `ids` is empty.
9
+ * @example
10
+ * nextInCycle([1, 2, 3], 1); // => 2
11
+ * nextInCycle([1, 2, 3], 3); // => 1
12
+ * nextInCycle([1, 2, 3], null); // => 1
13
+ */
14
+ export declare function nextInCycle(ids: number[], currentId: number | null): number | null;
15
+ //# sourceMappingURL=nextInCycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nextInCycle.d.ts","sourceRoot":"","sources":["../../src/array/nextInCycle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,MAAM,EAAE,EACb,SAAS,EAAE,MAAM,GAAG,IAAI,GACvB,MAAM,GAAG,IAAI,CAIf"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Returns the next element in a short list (e.g. cycling through a few
3
+ * recent items on tap). Wraps to the first element if the current one is no
4
+ * longer in the list (deleted, or out of range) or was the last one.
5
+ *
6
+ * @param ids - The list to cycle through.
7
+ * @param currentId - The currently selected id, or `null` for none.
8
+ * @returns The next id, or `null` if `ids` is empty.
9
+ * @example
10
+ * nextInCycle([1, 2, 3], 1); // => 2
11
+ * nextInCycle([1, 2, 3], 3); // => 1
12
+ * nextInCycle([1, 2, 3], null); // => 1
13
+ */
14
+ export function nextInCycle(ids, currentId) {
15
+ if (ids.length === 0)
16
+ return null;
17
+ const currentIndex = currentId == null ? -1 : ids.indexOf(currentId);
18
+ return ids[(currentIndex + 1) % ids.length] ?? null;
19
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Ranks `items` by relevance to a text search: match position in the name
3
+ * (earlier is more relevant), then name length. Generic so it can be reused
4
+ * for any list of items with a displayable name — typically for a
5
+ * search-as-you-type list.
6
+ *
7
+ * @param items - The items to rank.
8
+ * @param query - The search text.
9
+ * @param getName - Reads the displayable name off an item.
10
+ * @returns Only the matching items, ranked most relevant first.
11
+ * @example
12
+ * rankByNameMatch(
13
+ * [{ label: "Salad with potato" }, { label: "Potato" }],
14
+ * "potato",
15
+ * (item) => item.label,
16
+ * ); // => [{ label: "Potato" }, { label: "Salad with potato" }]
17
+ */
18
+ export declare function rankByNameMatch<T>(items: T[], query: string, getName: (item: T) => string): T[];
19
+ //# sourceMappingURL=rankByNameMatch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rankByNameMatch.d.ts","sourceRoot":"","sources":["../../src/array/rankByNameMatch.ts"],"names":[],"mappings":"AAiBA;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAC/B,KAAK,EAAE,CAAC,EAAE,EACV,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,GAC3B,CAAC,EAAE,CAiBL"}
@@ -0,0 +1,45 @@
1
+ import { normalizeForSearch } from "../string/normalizeForSearch.js";
2
+ // Caches each item's normalized name by object reference: useful for fixed
3
+ // lists (module-level constants) searched on every keystroke in a search
4
+ // field, to avoid renormalizing every name on each call.
5
+ const normalizedNameCache = new WeakMap();
6
+ function getNormalizedName(item, getName) {
7
+ if (typeof item !== "object" || item === null)
8
+ return normalizeForSearch(getName(item));
9
+ const cached = normalizedNameCache.get(item);
10
+ if (cached !== undefined)
11
+ return cached;
12
+ const normalized = normalizeForSearch(getName(item));
13
+ normalizedNameCache.set(item, normalized);
14
+ return normalized;
15
+ }
16
+ /**
17
+ * Ranks `items` by relevance to a text search: match position in the name
18
+ * (earlier is more relevant), then name length. Generic so it can be reused
19
+ * for any list of items with a displayable name — typically for a
20
+ * search-as-you-type list.
21
+ *
22
+ * @param items - The items to rank.
23
+ * @param query - The search text.
24
+ * @param getName - Reads the displayable name off an item.
25
+ * @returns Only the matching items, ranked most relevant first.
26
+ * @example
27
+ * rankByNameMatch(
28
+ * [{ label: "Salad with potato" }, { label: "Potato" }],
29
+ * "potato",
30
+ * (item) => item.label,
31
+ * ); // => [{ label: "Potato" }, { label: "Salad with potato" }]
32
+ */
33
+ export function rankByNameMatch(items, query, getName) {
34
+ const normalizedQuery = normalizeForSearch(query);
35
+ const matches = [];
36
+ for (const item of items) {
37
+ const matchIndex = getNormalizedName(item, getName).indexOf(normalizedQuery);
38
+ if (matchIndex !== -1) {
39
+ matches.push({ item, matchIndex });
40
+ }
41
+ }
42
+ matches.sort((a, b) => a.matchIndex - b.matchIndex ||
43
+ getName(a.item).length - getName(b.item).length);
44
+ return matches.map((m) => m.item);
45
+ }
@@ -0,0 +1,16 @@
1
+ export type PeriodFilter = "all" | "today" | "7d" | "30d";
2
+ /**
3
+ * Returns the timestamp (ms) from which a record is included for a given
4
+ * period filter, or `null` for `"all"` (no date filter). `"today"` starts at
5
+ * the current calendar day, not a rolling 24h window.
6
+ *
7
+ * @param period - The period filter.
8
+ * @param now - The reference date. Defaults to `new Date()`.
9
+ * @returns The period's start timestamp in ms, or `null` for `"all"`.
10
+ * @example
11
+ * getPeriodStartMs("today"); // => start of today, local time, in ms
12
+ * getPeriodStartMs("7d"); // => now minus 7 days, in ms
13
+ * getPeriodStartMs("all"); // => null
14
+ */
15
+ export declare function getPeriodStartMs(period: PeriodFilter, now?: Date): number | null;
16
+ //# sourceMappingURL=getPeriodStartMs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"getPeriodStartMs.d.ts","sourceRoot":"","sources":["../../src/date/getPeriodStartMs.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,OAAO,GAAG,IAAI,GAAG,KAAK,CAAC;AAE1D;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,YAAY,EACpB,GAAG,OAAa,GACf,MAAM,GAAG,IAAI,CASf"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Returns the timestamp (ms) from which a record is included for a given
3
+ * period filter, or `null` for `"all"` (no date filter). `"today"` starts at
4
+ * the current calendar day, not a rolling 24h window.
5
+ *
6
+ * @param period - The period filter.
7
+ * @param now - The reference date. Defaults to `new Date()`.
8
+ * @returns The period's start timestamp in ms, or `null` for `"all"`.
9
+ * @example
10
+ * getPeriodStartMs("today"); // => start of today, local time, in ms
11
+ * getPeriodStartMs("7d"); // => now minus 7 days, in ms
12
+ * getPeriodStartMs("all"); // => null
13
+ */
14
+ export function getPeriodStartMs(period, now = new Date()) {
15
+ if (period === "all")
16
+ return null;
17
+ if (period === "today") {
18
+ const startOfDay = new Date(now);
19
+ startOfDay.setHours(0, 0, 0, 0);
20
+ return startOfDay.getTime();
21
+ }
22
+ const days = period === "7d" ? 7 : 30;
23
+ return now.getTime() - days * 24 * 60 * 60 * 1000;
24
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./formatRelativeTime.js";
2
+ export * from "./getPeriodStartMs.js";
2
3
  export * from "./isSameDay.js";
3
4
  export * from "./startOfDay.js";
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/date/index.ts"],"names":[],"mappings":"AAAA,cAAc,yBAAyB,CAAC;AACxC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/date/index.ts"],"names":[],"mappings":"AAAA,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC"}
@@ -1,3 +1,4 @@
1
1
  export * from "./formatRelativeTime.js";
2
+ export * from "./getPeriodStartMs.js";
2
3
  export * from "./isSameDay.js";
3
4
  export * from "./startOfDay.js";
package/dist/index.d.ts CHANGED
@@ -4,8 +4,10 @@ export * from "./classes/index.js";
4
4
  export * from "./date/index.js";
5
5
  export * from "./files/index.js";
6
6
  export * from "./id/index.js";
7
+ export * from "./markdown/index.js";
7
8
  export * from "./maths/index.js";
8
9
  export * from "./number/index.js";
9
10
  export * from "./object/index.js";
10
11
  export * from "./string/index.js";
12
+ export * from "./version/index.js";
11
13
  //# 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,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,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,iBAAiB,CAAC;AAChC,cAAc,kBAAkB,CAAC;AACjC,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,kBAAkB,CAAC;AACjC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC"}
package/dist/index.js CHANGED
@@ -9,7 +9,9 @@ export * from "./classes/index.js";
9
9
  export * from "./date/index.js";
10
10
  export * from "./files/index.js";
11
11
  export * from "./id/index.js";
12
+ export * from "./markdown/index.js";
12
13
  export * from "./maths/index.js";
13
14
  export * from "./number/index.js";
14
15
  export * from "./object/index.js";
15
16
  export * from "./string/index.js";
17
+ export * from "./version/index.js";
@@ -0,0 +1,2 @@
1
+ export * from "./parseChangelogNotes.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/markdown/index.ts"],"names":[],"mappings":"AAAA,cAAc,0BAA0B,CAAC"}
@@ -0,0 +1 @@
1
+ export * from "./parseChangelogNotes.js";
@@ -0,0 +1,16 @@
1
+ export interface ChangelogSegment {
2
+ text: string;
3
+ bold: boolean;
4
+ }
5
+ export type ChangelogBlock = {
6
+ type: "heading";
7
+ text: string;
8
+ } | {
9
+ type: "item";
10
+ segments: ChangelogSegment[];
11
+ } | {
12
+ type: "text";
13
+ segments: ChangelogSegment[];
14
+ };
15
+ export declare function parseChangelogNotes(notes: string): ChangelogBlock[];
16
+ //# sourceMappingURL=parseChangelogNotes.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,43 @@
1
+ // GitHub release notes (and changelogs in general) are usually written in a
2
+ // small subset of Markdown ("### Heading", "- item", "**bold**"). Rendered
3
+ // as-is in a plain UI text node, the raw syntax shows up unstyled — this
4
+ // parser turns it into a small set of typed blocks a UI layer can render
5
+ // (heading / bullet item / plain text), without pulling in a full Markdown
6
+ // dependency for this narrow use case.
7
+ function parseInlineSegments(text) {
8
+ const segments = [];
9
+ const boldPattern = /\*\*(.+?)\*\*/g;
10
+ let lastIndex = 0;
11
+ let match;
12
+ while ((match = boldPattern.exec(text)) !== null) {
13
+ if (match.index > lastIndex) {
14
+ segments.push({ text: text.slice(lastIndex, match.index), bold: false });
15
+ }
16
+ // The capture group is mandatory in the pattern, so it always matches —
17
+ // the `?? ""` fallback is only here to satisfy `noUncheckedIndexedAccess`.
18
+ segments.push({ text: match[1] ?? "", bold: true });
19
+ lastIndex = boldPattern.lastIndex;
20
+ }
21
+ if (lastIndex < text.length) {
22
+ segments.push({ text: text.slice(lastIndex), bold: false });
23
+ }
24
+ return segments;
25
+ }
26
+ export function parseChangelogNotes(notes) {
27
+ return notes
28
+ .split("\n")
29
+ .map((line) => line.trim())
30
+ .filter((line) => line.length > 0)
31
+ .map((line) => {
32
+ if (line.startsWith("### ")) {
33
+ return { type: "heading", text: line.slice(4).trim() };
34
+ }
35
+ if (line.startsWith("- ")) {
36
+ return {
37
+ type: "item",
38
+ segments: parseInlineSegments(line.slice(2).trim()),
39
+ };
40
+ }
41
+ return { type: "text", segments: parseInlineSegments(line) };
42
+ });
43
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Formats a number as a raw value (no unit suffix) for use in a spreadsheet
3
+ * (sum, average...), with a comma decimal separator as expected by
4
+ * French-locale spreadsheets instead of JS's dot.
5
+ *
6
+ * @param value - The number to format.
7
+ * @param decimals - Number of decimal places. Defaults to `1`.
8
+ * @returns The formatted number, comma as decimal separator.
9
+ * @example
10
+ * formatCsvNumber(1.5); // => "1,5"
11
+ * formatCsvNumber(12.345, 2); // => "12,35"
12
+ */
13
+ export declare function formatCsvNumber(value: number, decimals?: number): string;
14
+ //# sourceMappingURL=formatCsvNumber.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formatCsvNumber.d.ts","sourceRoot":"","sources":["../../src/number/formatCsvNumber.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,SAAI,GAAG,MAAM,CAEnE"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Formats a number as a raw value (no unit suffix) for use in a spreadsheet
3
+ * (sum, average...), with a comma decimal separator as expected by
4
+ * French-locale spreadsheets instead of JS's dot.
5
+ *
6
+ * @param value - The number to format.
7
+ * @param decimals - Number of decimal places. Defaults to `1`.
8
+ * @returns The formatted number, comma as decimal separator.
9
+ * @example
10
+ * formatCsvNumber(1.5); // => "1,5"
11
+ * formatCsvNumber(12.345, 2); // => "12,35"
12
+ */
13
+ export function formatCsvNumber(value, decimals = 1) {
14
+ return value.toFixed(decimals).replace(".", ",");
15
+ }
@@ -1,5 +1,6 @@
1
1
  export * from "./clamp.js";
2
2
  export * from "./formatBytes.js";
3
+ export * from "./formatCsvNumber.js";
3
4
  export * from "./formatDuration.js";
4
5
  export * from "./round.js";
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/number/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/number/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC"}
@@ -1,4 +1,5 @@
1
1
  export * from "./clamp.js";
2
2
  export * from "./formatBytes.js";
3
+ export * from "./formatCsvNumber.js";
3
4
  export * from "./formatDuration.js";
4
5
  export * from "./round.js";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Escapes a CSV field per RFC 4180: wraps it in double quotes and doubles any
3
+ * internal double quote if the value contains a double quote, a semicolon
4
+ * (the delimiter), or a newline — returned as-is otherwise.
5
+ *
6
+ * @param value - The raw field value.
7
+ * @returns The value, quoted and escaped only if needed.
8
+ * @example
9
+ * escapeCsvField("Apple"); // => "Apple"
10
+ * escapeCsvField("a;b"); // => '"a;b"'
11
+ * escapeCsvField('He said "hi"'); // => '"He said ""hi"""'
12
+ */
13
+ export declare function escapeCsvField(value: string): string;
14
+ //# sourceMappingURL=escapeCsvField.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"escapeCsvField.d.ts","sourceRoot":"","sources":["../../src/string/escapeCsvField.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAGpD"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Escapes a CSV field per RFC 4180: wraps it in double quotes and doubles any
3
+ * internal double quote if the value contains a double quote, a semicolon
4
+ * (the delimiter), or a newline — returned as-is otherwise.
5
+ *
6
+ * @param value - The raw field value.
7
+ * @returns The value, quoted and escaped only if needed.
8
+ * @example
9
+ * escapeCsvField("Apple"); // => "Apple"
10
+ * escapeCsvField("a;b"); // => '"a;b"'
11
+ * escapeCsvField('He said "hi"'); // => '"He said ""hi"""'
12
+ */
13
+ export function escapeCsvField(value) {
14
+ if (!/[";\n]/.test(value))
15
+ return value;
16
+ return `"${value.replace(/"/g, '""')}"`;
17
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Escapes the HTML entities that matter when inserting arbitrary text into
3
+ * an HTML template (`&`, `<`, `>`, `"`), without pulling in a templating
4
+ * dependency.
5
+ *
6
+ * @param text - The text to escape.
7
+ * @returns `text` with `&`, `<`, `>` and `"` replaced by their HTML entities.
8
+ * @example
9
+ * escapeHtml("Tom & Jerry <script>"); // => "Tom &amp; Jerry &lt;script&gt;"
10
+ */
11
+ export declare function escapeHtml(text: string): string;
12
+ //# sourceMappingURL=escapeHtml.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"escapeHtml.d.ts","sourceRoot":"","sources":["../../src/string/escapeHtml.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAM/C"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Escapes the HTML entities that matter when inserting arbitrary text into
3
+ * an HTML template (`&`, `<`, `>`, `"`), without pulling in a templating
4
+ * dependency.
5
+ *
6
+ * @param text - The text to escape.
7
+ * @returns `text` with `&`, `<`, `>` and `"` replaced by their HTML entities.
8
+ * @example
9
+ * escapeHtml("Tom & Jerry <script>"); // => "Tom &amp; Jerry &lt;script&gt;"
10
+ */
11
+ export function escapeHtml(text) {
12
+ return text
13
+ .replace(/&/g, "&amp;")
14
+ .replace(/</g, "&lt;")
15
+ .replace(/>/g, "&gt;")
16
+ .replace(/"/g, "&quot;");
17
+ }
@@ -1,4 +1,7 @@
1
1
  export * from "./capitalize.js";
2
+ export * from "./escapeCsvField.js";
3
+ export * from "./escapeHtml.js";
4
+ export * from "./normalizeForSearch.js";
2
5
  export * from "./pluralize.js";
3
6
  export * from "./slugify.js";
4
7
  export * from "./truncate.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/string/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/string/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,qBAAqB,CAAC;AACpC,cAAc,iBAAiB,CAAC;AAChC,cAAc,yBAAyB,CAAC;AACxC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,eAAe,CAAC"}
@@ -1,4 +1,7 @@
1
1
  export * from "./capitalize.js";
2
+ export * from "./escapeCsvField.js";
3
+ export * from "./escapeHtml.js";
4
+ export * from "./normalizeForSearch.js";
2
5
  export * from "./pluralize.js";
3
6
  export * from "./slugify.js";
4
7
  export * from "./truncate.js";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Normalizes text for accent/case-insensitive search matching: expands
3
+ * œ/æ ligatures, strips accents, lower-cases, and trims.
4
+ *
5
+ * @param text - The text to normalize.
6
+ * @returns The normalized text.
7
+ * @example
8
+ * normalizeForSearch("Pâtes À La Crème"); // => "pates a la creme"
9
+ * normalizeForSearch("Œuf"); // => "oeuf"
10
+ */
11
+ export declare function normalizeForSearch(text: string): string;
12
+ //# sourceMappingURL=normalizeForSearch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalizeForSearch.d.ts","sourceRoot":"","sources":["../../src/string/normalizeForSearch.ts"],"names":[],"mappings":"AASA;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMvD"}
@@ -0,0 +1,25 @@
1
+ // The œ/æ ligatures do NOT decompose via normalize("NFD") (unlike accented
2
+ // letters): they're standalone Unicode letters, not a base + combining mark.
3
+ // Without this explicit replacement, searching "Œuf" (the real ligature)
4
+ // never matches "Oeuf" in data spelled without the ligature (as most text
5
+ // databases are).
6
+ function expandLigatures(text) {
7
+ return text.replace(/[œŒ]/g, "oe").replace(/[æÆ]/g, "ae");
8
+ }
9
+ /**
10
+ * Normalizes text for accent/case-insensitive search matching: expands
11
+ * œ/æ ligatures, strips accents, lower-cases, and trims.
12
+ *
13
+ * @param text - The text to normalize.
14
+ * @returns The normalized text.
15
+ * @example
16
+ * normalizeForSearch("Pâtes À La Crème"); // => "pates a la creme"
17
+ * normalizeForSearch("Œuf"); // => "oeuf"
18
+ */
19
+ export function normalizeForSearch(text) {
20
+ return expandLigatures(text)
21
+ .normalize("NFD")
22
+ .replace(/[̀-ͯ]/g, "")
23
+ .toLowerCase()
24
+ .trim();
25
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Compares two "x.y.z" version strings, segment by segment.
3
+ *
4
+ * @returns -1 if `a` < `b`, 0 if they're equal, 1 if `a` > `b`.
5
+ */
6
+ export declare function compareVersions(a: string, b: string): number;
7
+ //# sourceMappingURL=compareVersions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compareVersions.d.ts","sourceRoot":"","sources":["../../src/version/compareVersions.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAU5D"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Compares two "x.y.z" version strings, segment by segment.
3
+ *
4
+ * @returns -1 if `a` < `b`, 0 if they're equal, 1 if `a` > `b`.
5
+ */
6
+ export function compareVersions(a, b) {
7
+ const partsA = a.split(".").map(Number);
8
+ const partsB = b.split(".").map(Number);
9
+ const length = Math.max(partsA.length, partsB.length);
10
+ for (let i = 0; i < length; i++) {
11
+ const numA = partsA[i] ?? 0;
12
+ const numB = partsB[i] ?? 0;
13
+ if (numA !== numB)
14
+ return numA > numB ? 1 : -1;
15
+ }
16
+ return 0;
17
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./compareVersions.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/version/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAC"}
@@ -0,0 +1 @@
1
+ export * from "./compareVersions.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forthtilliath/ts-kit",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "publishConfig": {