From 538ec3863280f0396e80d6fc08fde8efe74e2046 Mon Sep 17 00:00:00 2001 From: Hein Date: Wed, 23 Sep 2026 19:22:12 +0200 Subject: [PATCH] Add collection and sorting utilities with Gitea release workflows Replace retrocycle eval calls with property traversal and regression coverage. Add bundle budgets, eval guards, package publishing, and contributor documentation. --- .changeset/collection-sorting-tools.md | 5 ++ .changeset/quiet-reference-paths.md | 5 ++ .gitea/workflows/ci.yml | 15 ++++++ .gitea/workflows/publish.yml | 47 +++++++++++++++++ AGENTS.md | 43 ++++++++++++++++ README.md | 70 ++++++++++++++++++++++++++ package.json | 25 +++++++-- scripts/bundle-budgets.json | 54 ++++++++++++++++++++ scripts/check-bundle.mjs | 21 ++++++++ src/collections/index.test.ts | 44 ++++++++++++++++ src/collections/index.ts | 37 ++++++++++++++ src/index.ts | 3 ++ src/object/decycle.test.ts | 42 +++++++++++++++- src/object/decycle.ts | 22 +++++--- src/sorting/index.test.ts | 64 +++++++++++++++++++++++ src/sorting/index.ts | 54 ++++++++++++++++++++ vite.config.js | 6 +++ 17 files changed, 545 insertions(+), 12 deletions(-) create mode 100644 .changeset/collection-sorting-tools.md create mode 100644 .changeset/quiet-reference-paths.md create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/publish.yml create mode 100644 AGENTS.md create mode 100644 scripts/bundle-budgets.json create mode 100644 scripts/check-bundle.mjs create mode 100644 src/collections/index.test.ts create mode 100644 src/collections/index.ts create mode 100644 src/sorting/index.test.ts create mode 100644 src/sorting/index.ts diff --git a/.changeset/collection-sorting-tools.md b/.changeset/collection-sorting-tools.md new file mode 100644 index 0000000..b0b8cd9 --- /dev/null +++ b/.changeset/collection-sorting-tools.md @@ -0,0 +1,5 @@ +--- +"@warkypublic/artemis-kit": minor +--- + +Add typed collection helpers (groupBy, keyBy, uniqueBy, partition) and configurable sorting comparators through root, collections, and sorting exports. Add CI bundle size budgets and fail builds on direct eval warnings. diff --git a/.changeset/quiet-reference-paths.md b/.changeset/quiet-reference-paths.md new file mode 100644 index 0000000..8ec5b84 --- /dev/null +++ b/.changeset/quiet-reference-paths.md @@ -0,0 +1,5 @@ +--- +"@warkypublic/artemis-kit": patch +--- + +Replace direct eval in retrocycle with JSONPath property traversal to eliminate bundler eval warnings when importing artemis-kit. Preserve circular and shared reference restoration, including escaped property names. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..31eca39 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,15 @@ +name: CI +on: [push, pull_request] +jobs: + test-and-build: + runs-on: ubuntu-latest + steps: + - uses: https://github.com/actions/checkout@v4 + - uses: https://github.com/pnpm/action-setup@v4 + - uses: https://github.com/actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test + - run: pnpm check:bundle diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..17742db --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,47 @@ +name: Publish package +on: + push: + tags: + - 'v*' + +jobs: + publish: + runs-on: ubuntu-latest + env: + PACKAGE_REGISTRY: https://git.warky.dev/api/packages/${{ gitea.repository_owner }}/npm/ + steps: + - uses: https://github.com/actions/checkout@v4 + - uses: https://github.com/pnpm/action-setup@v4 + - uses: https://github.com/actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://git.warky.dev/api/packages/${{ gitea.repository_owner }}/npm/ + scope: '@warkypublic' + cache: pnpm + - name: Verify release tag + env: + RELEASE_REF: ${{ gitea.ref }} + run: | + node --input-type=module <<'JS' + import { readFileSync } from 'node:fs'; + const { version } = JSON.parse(readFileSync('package.json', 'utf8')); + if (process.env.RELEASE_REF !== `refs/tags/v${version}`) { + throw new Error(`Release tag must be v${version} to match package.json`); + } + JS + - run: pnpm install --frozen-lockfile + - run: pnpm test + - run: pnpm check:bundle + - name: Publish to Gitea Packages + env: + # npm uses token authentication; PACKAGE_REGISTRY_USERNAME is not required. + NODE_AUTH_TOKEN: ${{ secrets.PACKAGE_REGISTRY_TOKEN }} + run: | + set -eu + : "${NODE_AUTH_TOKEN:?Set the PACKAGE_REGISTRY_TOKEN Actions secret with package write access}" + version=$(node -p "require('./package.json').version") + case "$version" in + *-*) dist_tag=next ;; + *) dist_tag=latest ;; + esac + pnpm publish --no-git-checks --registry "$PACKAGE_REGISTRY" --tag "$dist_tag" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..05b078b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Repository guidance + +Keep README.md focused on package usage and installation. Document development +checks, CI workflows, and release procedures in this file. This repository uses +Gitea at https://git.warky.dev; workflows belong in `.gitea/workflows/`. + +## Development checks + +Run `pnpm test` for the test suite and `pnpm check:bundle` for a production build +with bundle checks. Builds fail on direct eval warnings. The bundle check measures +raw and gzip bytes for every emitted JavaScript file against +`scripts/bundle-budgets.json`; missing outputs and unbudgeted chunks also fail. +Review intentional size changes before updating budgets. Gitea Actions runs both commands on +pushes and pull requests via `.gitea/workflows/ci.yml`, using a runner with the +`ubuntu-latest` label. Action sources use explicit URLs so they do not depend on +the server’s default action host. No additional runtime dependencies are required by the +collection or sorting helpers. + +## Publishing to Gitea Packages + +`.gitea/workflows/publish.yml` publishes `@warkypublic/artemis-kit` when a `v*` +tag is pushed to Gitea. The destination is +`https://git.warky.dev/api/packages//npm/`, where `` is the Gitea +repository's user or organization (not necessarily the npm scope `warkypublic`). + +1. The workflow uses the existing `PACKAGE_REGISTRY_TOKEN` Actions secret. + It must have **Packages: Read & Write** access from a user allowed to publish + under the repository owner. npm token authentication does not require the + existing `PACKAGE_REGISTRY_USERNAME` secret. Do not put the token in the repository. +2. Run `pnpm exec changeset version` to apply pending changesets. Review and commit + the updated version, changelog, and consumed changesets with the release changes. +3. Create and push a matching tag to the Gitea remote, for example `v1.1.0` for + package version `1.1.0`. The workflow rejects mismatched tags, runs the tests and + bundle checks, then publishes using pnpm so the `publishConfig` overrides point + consumers to the built JavaScript and declarations. + +Stable versions receive the `latest` dist-tag; prereleases receive `next`. +Existing package versions cannot be overwritten; release a new version instead. +This workflow adds Gitea as a publishing destination without changing the default +registry for local publishing. After the first upload, link the package to this +repository in the package settings to show it in the repository's Packages tab. +See the [Gitea npm registry documentation](https://docs.gitea.com/usage/packages/npm/) +and [package access rules](https://docs.gitea.com/usage/packages/overview/). diff --git a/README.md b/README.md index c01fc74..c091474 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,63 @@ TypeScript/JavaScript utility library. Tree-shakeable, split exports per module. ## Modules +### `collections` + +```ts +import { groupBy, keyBy, uniqueBy, partition } from "@warkypublic/artemis-kit/collections" + +const rows = [ + { id: 1, team: "red", score: 10 }, + { id: 2, team: "blue", score: 5 }, + { id: 3, team: "red", score: 20 }, +] +const teams = groupBy(rows, row => row.team) // Map +const byId = keyBy(rows, row => row.id) // Map +const representatives = uniqueBy(rows, row => row.team) // first row per team +const [high, low] = partition(rows, row => row.score >= 10) +``` + +All helpers accept readonly arrays and preserve input order without mutating the input. +Items retain their original references. `groupBy` and `keyBy` return `Map` instances; +keys use Map/Set equality (object identity, with NaN equal to NaN). +`keyBy` keeps the last item for a duplicate key; `uniqueBy` keeps the first. +`partition` returns `[matching, nonMatching]` arrays. + +### `sorting` + +```ts +import { compareBy, chainComparators, createComparator } from "@warkypublic/artemis-kit/sorting" + +const sorted = [...rows].sort(chainComparators( + compareBy<(typeof rows)[number]>(row => row.team, { locale: "en" }), + compareBy<(typeof rows)[number]>(row => row.score, { direction: "desc" }), +)) +const labels = ["item10", "item2"].sort(createComparator({ locale: "en" })) +// ["item2", "item10"] +``` + +Comparators accept strings, numbers, dates, null, and undefined. Options: + +| Option | Default | Behavior | +|---|---|---| +| `direction` | `"asc"` | `"asc"` or `"desc"` | +| `empty` | `"last"` | `"first"` or `"last"`, independent of direction | +| `locale` | Runtime locale | Locale string or ordered list of locales | +| `collator` | `{ numeric: true }` | `Intl.Collator` options, created once per comparator | + +Empty values are null, undefined, `""`, NaN, and invalid dates. Whitespace strings +are not empty. Numbers compare numerically and dates by timestamp. Mixed types +sort numbers, then dates, then strings in ascending order; strings are never coerced +to numbers. `chainComparators` returns zero when all criteria tie. + +Use `compareBy` to sort rows with missing values: JavaScript's `Array.sort` always +moves bare undefined elements to the end without invoking the comparator, even with +`empty: "first"`. Sorting mutates the array; copy it first as shown above. + +These helpers and their types are also available from the package root. + +--- + ### `strings` ```ts import { ... } from "@warkypublic/artemis-kit/strings" @@ -221,3 +278,16 @@ import { OpenAPI, Claude } from "@warkypublic/artemis-kit/llm" **OpenAPI options:** `{ url?, apiKey?, maxTokens?, temperature?, topP?, n?, stream?, stop? }` **Claude options:** `{ url?, apiKey?, model?, maxTokens?, temperature?, topP?, stopSequences?, system? }` + +## Installing from Gitea Packages + +To install from Gitea, put this scope mapping in the consuming project's `.npmrc`, +replacing `` with the Gitea repository owner: + +```ini +@warkypublic:registry=https://git.warky.dev/api/packages//npm/ +``` + +Then run `pnpm add @warkypublic/artemis-kit`. For private packages, also configure +an authentication token with package-read access in your user-level npm config or +CI secrets. The scope mapping routes all `@warkypublic` packages to this registry. diff --git a/package.json b/package.json index eb979d3..d0ab9a3 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "./dom": "./src/dom/index.ts", "./i18n": "./src/i18n/index.ts", "./logger": "./src/logger/index.ts", - "./llm": "./src/llm/index.ts" + "./llm": "./src/llm/index.ts", + "./collections": "./src/collections/index.ts", + "./sorting": "./src/sorting/index.ts" }, "typesVersions": { "*": { @@ -53,6 +55,12 @@ ], "llm": [ "./src/llm/index.ts" + ], + "collections": [ + "./src/collections/index.ts" + ], + "sorting": [ + "./src/sorting/index.ts" ] } }, @@ -72,7 +80,9 @@ "./dom": "./dist/dom.js", "./i18n": "./dist/i18n.js", "./logger": "./dist/logger.js", - "./llm": "./dist/llm.js" + "./llm": "./dist/llm.js", + "./collections": "./dist/collections.js", + "./sorting": "./dist/sorting.js" }, "typesVersions": { "*": { @@ -108,6 +118,12 @@ ], "llm": [ "./dist/llm.d.ts" + ], + "collections": [ + "./dist/collections.d.ts" + ], + "sorting": [ + "./dist/sorting.d.ts" ] } } @@ -122,7 +138,8 @@ "clean": "rm -rf dist", "prepublishOnly": "npm run build", "test": "vitest run", - "lint": "eslint src" + "lint": "eslint src", + "check:bundle": "npm run build && node scripts/check-bundle.mjs" }, "keywords": [ "string", @@ -171,4 +188,4 @@ }, "homepage": "https://github.com/Warky-Devs/artemis-kit#readme", "packageManager": "pnpm@9.6.0+sha512.38dc6fba8dba35b39340b9700112c2fe1e12f10b17134715a4aa98ccf7bb035e76fd981cf0bb384dfa98f8d6af5481c2bef2f4266a24bfa20c34eb7147ce0b5e" -} \ No newline at end of file +} diff --git a/scripts/bundle-budgets.json b/scripts/bundle-budgets.json new file mode 100644 index 0000000..d8d627b --- /dev/null +++ b/scripts/bundle-budgets.json @@ -0,0 +1,54 @@ +{ + "base64.js": { + "bytes": 2200, + "gzip": 800 + }, + "collections.js": { + "bytes": 850, + "gzip": 425 + }, + "dataqueue.js": { + "bytes": 12000, + "gzip": 3500 + }, + "dom.js": { + "bytes": 500, + "gzip": 350 + }, + "i18n.js": { + "bytes": 7000, + "gzip": 2400 + }, + "index.js": { + "bytes": 3800, + "gzip": 1400 + }, + "llm.js": { + "bytes": 3000, + "gzip": 1200 + }, + "logger.js": { + "bytes": 1800, + "gzip": 850 + }, + "mime.js": { + "bytes": 205000, + "gzip": 26000 + }, + "object.js": { + "bytes": 4500, + "gzip": 1800 + }, + "promise.js": { + "bytes": 1000, + "gzip": 550 + }, + "sorting.js": { + "bytes": 1400, + "gzip": 700 + }, + "strings.js": { + "bytes": 14500, + "gzip": 4900 + } +} diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs new file mode 100644 index 0000000..be54911 --- /dev/null +++ b/scripts/check-bundle.mjs @@ -0,0 +1,21 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { gzipSync } from 'node:zlib'; + +const dist = new URL('../dist/', import.meta.url); +const budgets = JSON.parse(await readFile(new URL('./bundle-budgets.json', import.meta.url), 'utf8')); +const files = (await readdir(dist)).filter(file => file.endsWith('.js')); +let failed = false; +for (const file of new Set([...files, ...Object.keys(budgets)])) { + const budget = budgets[file]; + if (!budget || !files.includes(file)) { + console.error(`${file}: missing ${budget ? 'build output' : 'bundle budget'}`); + failed = true; + continue; + } + const source = await readFile(new URL(file, dist)); + const bytes = source.byteLength; + const gzip = gzipSync(source).byteLength; + console.log(`${file}: ${bytes}/${budget.bytes} bytes; gzip ${gzip}/${budget.gzip} bytes`); + if (bytes > budget.bytes || gzip > budget.gzip) failed = true; +} +if (failed) throw new Error('Bundle budget check failed. Review size changes before updating budgets.'); diff --git a/src/collections/index.test.ts b/src/collections/index.test.ts new file mode 100644 index 0000000..6fa1577 --- /dev/null +++ b/src/collections/index.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { groupBy, keyBy, partition, uniqueBy } from './index'; + +describe('collections', () => { + const rows = Object.freeze([ + Object.freeze({ id: 1, team: 'a' }), + Object.freeze({ id: 2, team: 'b' }), + Object.freeze({ id: 3, team: 'a' }), + ]); + + it('groups in insertion order without changing inputs', () => { + const grouped = groupBy(rows, row => row.team); + expect([...grouped]).toEqual([['a', [rows[0], rows[2]]], ['b', [rows[1]]]]); + expect(grouped.get('a')[0]).toBe(rows[0]); + }); + + it('keeps the last duplicate for keyBy and the first for uniqueBy', () => { + expect([...keyBy(rows, row => row.team)]).toEqual([['a', rows[2]], ['b', rows[1]]]); + expect(uniqueBy(rows, row => row.team)).toEqual([rows[0], rows[1]]); + }); + + it('supports object identity, NaN, and prototype-like keys', () => { + const key = {}; + const keys = [key, {}, key, NaN, NaN, '__proto__', 'constructor']; + expect(groupBy(keys, value => value).get(key)).toEqual([key, key]); + expect(keyBy(keys, value => value).has('__proto__')).toBe(true); + expect(uniqueBy(keys, value => value)).toHaveLength(5); + }); + + it('partitions in order into independent arrays', () => { + const [matching, remaining] = partition(rows, row => row.team === 'a'); + expect(matching).toEqual([rows[0], rows[2]]); + expect(remaining).toEqual([rows[1]]); + expect(partition(rows, () => true)).toEqual([[...rows], []]); + expect(partition(rows, () => false)).toEqual([[], [...rows]]); + }); + + it('handles empty inputs', () => { + expect(groupBy([], value => value).size).toBe(0); + expect(keyBy([], value => value).size).toBe(0); + expect(uniqueBy([], value => value)).toEqual([]); + expect(partition([], () => true)).toEqual([[], []]); + }); +}); diff --git a/src/collections/index.ts b/src/collections/index.ts new file mode 100644 index 0000000..87df102 --- /dev/null +++ b/src/collections/index.ts @@ -0,0 +1,37 @@ +/** Group items by key, preserving item and first-seen key order. */ +export function groupBy(items: readonly T[], keyOf: (item: T) => K): Map { + const groups = new Map(); + for (const item of items) { + const key = keyOf(item); + const group = groups.get(key); + if (group) group.push(item); + else groups.set(key, [item]); + } + return groups; +} + +/** Index items by key. The last item wins for duplicate keys. */ +export function keyBy(items: readonly T[], keyOf: (item: T) => K): Map { + const result = new Map(); + for (const item of items) result.set(keyOf(item), item); + return result; +} + +/** Keep the first item for each key, preserving input order. */ +export function uniqueBy(items: readonly T[], keyOf: (item: T) => K): T[] { + const seen = new Set(); + return items.filter(item => { + const key = keyOf(item); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +/** Split items into [matching, non-matching], preserving order. */ +export function partition(items: readonly T[], predicate: (item: T) => boolean): [T[], T[]] { + const matching: T[] = []; + const remaining: T[] = []; + for (const item of items) (predicate(item) ? matching : remaining).push(item); + return [matching, remaining]; +} diff --git a/src/index.ts b/src/index.ts index 5e2a557..b37cb16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,3 +6,6 @@ export * from "./i18n"; //export * from './logger' export * from "./dom"; + +export * from "./collections"; +export * from "./sorting"; diff --git a/src/object/decycle.test.ts b/src/object/decycle.test.ts index 4a3294a..8b0820f 100644 --- a/src/object/decycle.test.ts +++ b/src/object/decycle.test.ts @@ -2,6 +2,46 @@ import { describe, it, expect } from 'vitest'; import { decycle, retrocycle } from './decycle'; describe('decycle and retrocycle functions', () => { + it('restores shared references through mixed object and array paths', () => { + const shared = { id: 'shared' }; + const restored = retrocycle(JSON.parse(JSON.stringify(decycle({ + items: [{ nested: shared }], + objectRef: shared, + arrayRefs: [shared], + })))); + expect(restored.objectRef).toBe(restored.items[0].nested); + expect(restored.arrayRefs[0]).toBe(restored.items[0].nested); + }); + + it.each(['', 'a"b', 'a\\b', 'a][b', 'line\n\t\u0000', '雪', '0', 'constructor'])( + 'restores references to the property %j', (key) => { + const shared = { id: 'shared' }; + const restored = retrocycle(JSON.parse(JSON.stringify(decycle({ + [key]: shared, + references: [shared], + })))); + expect(restored.references[0]).toBe(restored[key]); + }, + ); + + it('decodes JSON escapes in reference paths', () => { + const target = {}; + const restored = retrocycle({ + 'a/b': target, + ref: { $ref: '$["\\u0061\\/b"]' }, + }); + expect(restored.ref).toBe(target); + }); + + it.each(['$.target', '$[target]', '$["target"]()', '$["\\uZZZZ"]', '$["target"', ''])( + 'leaves invalid reference paths unchanged: %s', (path) => { + const ref = { $ref: path }; + const restored = retrocycle({ target: {}, objectRef: ref, arrayRefs: [ref] }); + expect(restored.objectRef).toBe(ref); + expect(restored.arrayRefs[0]).toBe(ref); + }, + ); + it('should handle non-circular objects correctly', () => { const obj = { a: 1, b: 'string', c: true }; const decycled = decycle(obj); @@ -184,4 +224,4 @@ describe('decycle and retrocycle functions', () => { expect(restored.b).toBe('string'); expect(restored.self).toBe(restored); // Circular reference restored }); -}); \ No newline at end of file +}); diff --git a/src/object/decycle.ts b/src/object/decycle.ts index b8357d4..e323146 100644 --- a/src/object/decycle.ts +++ b/src/object/decycle.ts @@ -90,10 +90,20 @@ export function retrocycle($: T): T { "use strict"; // Regular expression to validate JSONPath format - // NOTE: This function uses eval() which can be a security risk. - // Consider using a safer alternative in production environments. // eslint-disable-next-line no-control-regex - const px = /^\$(?:\[(?:\d+|"(?:[^\\"\u0000-\u001f]|\\(?:[\\"\/bfnrt]|u[0-9a-zA-Z]{4}))*")\])*$/; + const px = /^\$(?:\[(?:\d+|"(?:[^\\"\u0000-\u001f]|\\(?:[\\"/bfnrt]|u[0-9a-fA-F]{4}))*")\])*$/; + + // Paths contain only bracketed array indices or JSON-encoded property names. + // Decode each component and walk from the root without executing JavaScript. + function resolvePath(path: string): any { + const components = path.match(/\[(?:\d+|"(?:[^"\\]|\\.)*")\]/g) ?? []; + let value: any = $; + for (const component of components) { + const key = component.slice(1, -1); + value = value[key.startsWith('"') ? JSON.parse(key) : Number(key)]; + } + return value; + } (function rez(value: any): void { // The rez function walks recursively through the object looking for $ref @@ -106,8 +116,7 @@ export function retrocycle($: T): T { if (typeof element === "object" && element !== null) { const path = element.$ref; if (typeof path === "string" && px.test(path)) { - // Security warning: eval is used here - value[i] = eval(path); + value[i] = resolvePath(path); } else { rez(element); } @@ -119,8 +128,7 @@ export function retrocycle($: T): T { if (typeof item === "object" && item !== null) { const path = item.$ref; if (typeof path === "string" && px.test(path)) { - // Security warning: eval is used here - value[name] = eval(path); + value[name] = resolvePath(path); } else { rez(item); } diff --git a/src/sorting/index.test.ts b/src/sorting/index.test.ts new file mode 100644 index 0000000..008596e --- /dev/null +++ b/src/sorting/index.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { chainComparators, compareBy, createComparator, type SortValue } from './index'; + +describe('sorting', () => { + it('sorts numeric strings naturally and accepts collator overrides', () => { + expect(['item10', 'item2', 'item1'].sort(createComparator({ locale: 'en' }))) + .toEqual(['item1', 'item2', 'item10']); + expect(['10', '2'].sort(createComparator({ locale: 'en', collator: { numeric: false } }))) + .toEqual(['10', '2']); + expect(createComparator({ locale: 'en', collator: { sensitivity: 'base' } })('A', 'a')).toBe(0); + }); + + it('respects the chosen locale', () => { + expect(['ä', 'z'].sort(createComparator({ locale: 'sv' }))).toEqual(['z', 'ä']); + expect(['ä', 'z'].sort(createComparator({ locale: 'de' }))).toEqual(['ä', 'z']); + }); + + it('compares numbers, infinities, and dates', () => { + expect([10, -1, 2, Infinity, -Infinity].sort(createComparator())) + .toEqual([-Infinity, -1, 2, 10, Infinity]); + expect(createComparator()(Infinity, Infinity)).toBe(0); + const earlier = new Date('2020-01-01'); + const later = new Date('2021-01-01'); + expect([earlier, later].sort(createComparator({ direction: 'desc' }))).toEqual([later, earlier]); + }); + + it.each(['asc', 'desc'] as const)('keeps empties in the configured position for %s', direction => { + const empties: SortValue[] = [null, undefined, '', NaN, new Date(NaN)]; + for (const empty of empties) { + const last = createComparator({ direction }); + const first = createComparator({ direction, empty: 'first' }); + expect(last(empty, 1)).toBeGreaterThan(0); + expect(last(1, empty)).toBeLessThan(0); + expect(first(empty, 1)).toBeLessThan(0); + expect(first(1, empty)).toBeGreaterThan(0); + for (const other of empties) expect(last(empty, other)).toBe(0); + } + const rows = [{ value: undefined }, { value: 2 }, { value: null }, { value: 1 }]; + expect(rows.sort(compareBy(row => row.value, { direction })).map(row => row.value)) + .toEqual(direction === 'asc' ? [1, 2, undefined, null] : [2, 1, undefined, null]); + }); + + it('defines a consistent mixed-type order without numeric coercion', () => { + const date = new Date(0); + expect(['1', date, 2].sort(createComparator())).toEqual([2, date, '1']); + expect(['1', date, 2].sort(createComparator({ direction: 'desc' }))).toEqual(['1', date, 2]); + }); + + it('sorts rows by multiple selectors and preserves ties', () => { + const rows = [ + { team: 'b', data: { score: 3 } }, + { team: 'a', data: { score: 1 } }, + { team: 'a', data: { score: 5 } }, + { team: 'a', data: { score: 5 } }, + ]; + const compare = chainComparators( + compareBy<(typeof rows)[number]>(row => row.team, { locale: 'en' }), + compareBy<(typeof rows)[number]>(row => row.data.score, { direction: 'desc' }), + ); + expect([...rows].sort(compare)).toEqual([rows[2], rows[3], rows[1], rows[0]]); + expect(compare(rows[2], rows[3])).toBe(0); + expect(chainComparators()(1, 2)).toBe(0); + }); +}); diff --git a/src/sorting/index.ts b/src/sorting/index.ts new file mode 100644 index 0000000..6c8b862 --- /dev/null +++ b/src/sorting/index.ts @@ -0,0 +1,54 @@ +export type Comparator = (a: T, b: T) => number; +export type SortValue = string | number | Date | null | undefined; + +export interface SortOptions { + direction?: 'asc' | 'desc'; + /** Empty values stay first/last regardless of direction. Defaults to last. */ + empty?: 'first' | 'last'; + locale?: string | string[]; + collator?: Intl.CollatorOptions; +} + +/** + * Compare strings, numbers, and dates without coercing strings to numbers. + * Empty means null, undefined, an empty string, NaN, or an invalid Date. + * Mixed non-empty types sort numbers, then dates, then strings in ascending order. + */ +export function createComparator(options: SortOptions = {}): Comparator { + const collator = new Intl.Collator(options.locale, { numeric: true, ...options.collator }); + const direction = options.direction === 'desc' ? -1 : 1; + const emptyOrder = options.empty === 'first' ? -1 : 1; + const isEmpty = (value: SortValue): boolean => value === null || value === undefined || value === '' || + (typeof value === 'number' && Number.isNaN(value)) || + (value instanceof Date && Number.isNaN(value.getTime())); + const rank = (value: SortValue): number => typeof value === 'number' ? 0 : value instanceof Date ? 1 : 2; + + return (a, b) => { + const aEmpty = isEmpty(a); + const bEmpty = isEmpty(b); + if (aEmpty || bEmpty) return aEmpty === bEmpty ? 0 : aEmpty ? emptyOrder : -emptyOrder; + const typeOrder = rank(a) - rank(b); + if (typeOrder) return typeOrder * direction; + if (typeof a === 'string' && typeof b === 'string') return collator.compare(a, b) * direction; + const left = a instanceof Date ? a.getTime() : a as number; + const right = b instanceof Date ? b.getTime() : b as number; + return (left < right ? -1 : left > right ? 1 : 0) * direction; + }; +} + +/** Create a row comparator using a selector, including nested properties. */ +export function compareBy(selector: (item: T) => SortValue, options?: SortOptions): Comparator { + const compare = createComparator(options); + return (a, b) => compare(selector(a), selector(b)); +} + +/** Apply comparators in order until one breaks the tie. */ +export function chainComparators(...comparators: Comparator[]): Comparator { + return (a, b) => { + for (const compare of comparators) { + const result = compare(a, b); + if (result !== 0) return result; + } + return 0; + }; +} diff --git a/vite.config.js b/vite.config.js index 19907df..23f14ce 100644 --- a/vite.config.js +++ b/vite.config.js @@ -7,6 +7,8 @@ export default defineConfig({ build: { lib: { entry: { + collections: resolve(__dirname, "src/collections/index.ts"), + sorting: resolve(__dirname, "src/sorting/index.ts"), index: resolve(__dirname, "src/index.ts"), object: resolve(__dirname, "src/object/index.ts"), dataqueue: resolve(__dirname, "src/dataqueue/index.ts"), @@ -28,6 +30,10 @@ export default defineConfig({ }, }, rollupOptions: { + onwarn(warning, warn) { + if (warning.code === "EVAL") throw new Error(warning.message); + warn(warning); + }, external: [ "fs", "path",