@forthtilliath/ts-kit 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -103,6 +103,21 @@ import { chunk } from "@forthtilliath/ts-kit/array/chunk";
103
103
  - `randomId()` — `crypto.randomUUID()` with fallbacks for older/insecure
104
104
  contexts.
105
105
 
106
+ ### `image`
107
+
108
+ - `compressImage(file, { maxWidth?, quality? })` — browser-side compression
109
+ via the Canvas API: scales down to `maxWidth` (default 2400px, aspect ratio
110
+ kept) and converts to WebP (default quality 0.82). Non-image files are
111
+ returned unchanged.
112
+
113
+ ### `markdown`
114
+
115
+ - `parseChangelogNotes(notes)` — parses a small subset of Markdown
116
+ (`### heading`, `- item`, `**bold**`) commonly found in GitHub release
117
+ notes into a list of typed blocks (`heading` / `item` / `text`, each with
118
+ `bold`-aware segments), ready for a UI layer to render without a full
119
+ Markdown dependency.
120
+
106
121
  ### `maths`
107
122
 
108
123
  - `sum(numbers)` — sum of an array of numbers.
@@ -144,6 +159,11 @@ import { chunk } from "@forthtilliath/ts-kit/array/chunk";
144
159
  - `escapeHtml(text)` — basic HTML entity escaping (`&`, `<`, `>`, `"`) for
145
160
  inserting user text into an HTML template.
146
161
 
162
+ ### `version`
163
+
164
+ - `compareVersions(a, b)` — compares two `"x.y.z"` version strings, segment
165
+ by segment; `-1`/`0`/`1`.
166
+
147
167
  ## Scripts
148
168
 
149
169
  ```bash
@@ -0,0 +1,12 @@
1
+ export interface CompressImageOptions {
2
+ /** Images wider than this are scaled down, keeping their aspect ratio. */
3
+ maxWidth?: number;
4
+ /** WebP quality, between 0 and 1. */
5
+ quality?: number;
6
+ }
7
+ /**
8
+ * Compresses an image in the browser via the Canvas API and converts it to WebP.
9
+ * Non-image files are returned unchanged.
10
+ */
11
+ export declare function compressImage(file: File, { maxWidth, quality }?: CompressImageOptions): Promise<File>;
12
+ //# sourceMappingURL=compressImage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compressImage.d.ts","sourceRoot":"","sources":["../../src/image/compressImage.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,oBAAoB;IACnC,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qCAAqC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,IAAI,EAAE,IAAI,EACV,EAAE,QAAe,EAAE,OAAc,EAAE,GAAE,oBAAyB,GAC7D,OAAO,CAAC,IAAI,CAAC,CAiDf"}
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Compresses an image in the browser via the Canvas API and converts it to WebP.
3
+ * Non-image files are returned unchanged.
4
+ */
5
+ export async function compressImage(file, { maxWidth = 2400, quality = 0.82 } = {}) {
6
+ if (!file.type.startsWith("image/"))
7
+ return file;
8
+ return new Promise((resolve, reject) => {
9
+ const img = new Image();
10
+ const objectUrl = URL.createObjectURL(file);
11
+ img.onload = () => {
12
+ URL.revokeObjectURL(objectUrl);
13
+ let width = img.naturalWidth;
14
+ let height = img.naturalHeight;
15
+ if (width > maxWidth) {
16
+ height = Math.round((height * maxWidth) / width);
17
+ width = maxWidth;
18
+ }
19
+ const canvas = document.createElement("canvas");
20
+ canvas.width = width;
21
+ canvas.height = height;
22
+ const ctx = canvas.getContext("2d");
23
+ if (!ctx) {
24
+ reject(new Error("Canvas 2D context is not supported"));
25
+ return;
26
+ }
27
+ ctx.drawImage(img, 0, 0, width, height);
28
+ canvas.toBlob((blob) => {
29
+ if (!blob) {
30
+ reject(new Error("Image compression failed"));
31
+ return;
32
+ }
33
+ const name = file.name.replace(/\.[^.]+$/, ".webp");
34
+ resolve(new File([blob], name, { type: "image/webp" }));
35
+ }, "image/webp", quality);
36
+ };
37
+ img.onerror = () => {
38
+ URL.revokeObjectURL(objectUrl);
39
+ reject(new Error("Failed to load image"));
40
+ };
41
+ img.src = objectUrl;
42
+ });
43
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./compressImage.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/image/index.ts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC"}
@@ -0,0 +1 @@
1
+ export * from "./compressImage.js";
package/dist/index.d.ts CHANGED
@@ -4,8 +4,11 @@ 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 "./image/index.js";
8
+ export * from "./markdown/index.js";
7
9
  export * from "./maths/index.js";
8
10
  export * from "./number/index.js";
9
11
  export * from "./object/index.js";
10
12
  export * from "./string/index.js";
13
+ export * from "./version/index.js";
11
14
  //# 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,kBAAkB,CAAC;AACjC,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,10 @@ 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 "./image/index.js";
13
+ export * from "./markdown/index.js";
12
14
  export * from "./maths/index.js";
13
15
  export * from "./number/index.js";
14
16
  export * from "./object/index.js";
15
17
  export * from "./string/index.js";
18
+ 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,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.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -21,11 +21,12 @@
21
21
  "./*": "./dist/*.js"
22
22
  },
23
23
  "devDependencies": {
24
+ "@vitest/coverage-v8": "4.1.10",
24
25
  "eslint": "10.7.0",
25
26
  "jsdom": "^29.1.1",
26
27
  "typescript": "^5.9.3",
27
28
  "vitest": "^4.1.10",
28
- "@forthtilliath/eslint-config": "0.4.2",
29
+ "@forthtilliath/eslint-config": "0.5.0",
29
30
  "@forthtilliath/typescript-config": "0.0.0"
30
31
  },
31
32
  "scripts": {
@@ -35,6 +36,7 @@
35
36
  "lint": "eslint . --max-warnings 0",
36
37
  "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist",
37
38
  "test": "vitest run",
38
- "test:watch": "vitest"
39
+ "test:watch": "vitest",
40
+ "coverage": "vitest run --coverage"
39
41
  }
40
42
  }