Add collection and sorting utilities with Gitea release workflows
CI / test-and-build (push) Successful in 30s

Replace retrocycle eval calls with property traversal and regression coverage. Add bundle budgets, eval guards, package publishing, and contributor documentation.
This commit is contained in:
warkanum committed 2026-09-23 19:22:12 +02:00
1 parent 34be59ba01
commit 538ec38632
17 files changed
+545 -12

No files matched your search

+5
View File
@@ -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.
+5
View File
@@ -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.
+15
View File
@@ -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
+47
View File
@@ -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"
+43
View File
@@ -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/<owner>/npm/`, where `<owner>` 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/).
+70
View File
@@ -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<string, Row[]>
const byId = keyBy(rows, row => row.id) // Map<number, Row>
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 `<owner>` with the Gitea repository owner:
```ini
@warkypublic:registry=https://git.warky.dev/api/packages/<owner>/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.
+21 -4
View File
@@ -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"
}
}
+54
View File
@@ -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
}
}
+21
View File
@@ -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.');
+44
View File
@@ -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([[], []]);
});
});
+37
View File
@@ -0,0 +1,37 @@
/** Group items by key, preserving item and first-seen key order. */
export function groupBy<T, K>(items: readonly T[], keyOf: (item: T) => K): Map<K, T[]> {
const groups = new Map<K, T[]>();
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<T, K>(items: readonly T[], keyOf: (item: T) => K): Map<K, T> {
const result = new Map<K, T>();
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<T, K>(items: readonly T[], keyOf: (item: T) => K): T[] {
const seen = new Set<K>();
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<T>(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];
}
+3
View File
@@ -6,3 +6,6 @@ export * from "./i18n";
//export * from './logger'
export * from "./dom";
export * from "./collections";
export * from "./sorting";
+41 -1
View File
@@ -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
});
});
});
+15 -7
View File
@@ -90,10 +90,20 @@ export function retrocycle<T>($: 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): 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): 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);
}
+64
View File
@@ -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);
});
});
+54
View File
@@ -0,0 +1,54 @@
export type Comparator<T> = (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<SortValue> {
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<T>(selector: (item: T) => SortValue, options?: SortOptions): Comparator<T> {
const compare = createComparator(options);
return (a, b) => compare(selector(a), selector(b));
}
/** Apply comparators in order until one breaks the tie. */
export function chainComparators<T>(...comparators: Comparator<T>[]): Comparator<T> {
return (a, b) => {
for (const compare of comparators) {
const result = compare(a, b);
if (result !== 0) return result;
}
return 0;
};
}
+6
View File
@@ -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",