mirror of
https://github.com/Warky-Devs/artemis-kit.git
synced 2026-11-10 06:18:47 +00:00
Add collection and sorting utilities with Gitea release workflows
CI / test-and-build (push) Successful in 30s
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:
1 parent
34be59ba01
commit
538ec38632
17 files changed
+543
-10
No files matched your search
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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/).
|
||||||
@@ -8,6 +8,63 @@ TypeScript/JavaScript utility library. Tree-shakeable, split exports per module.
|
|||||||
|
|
||||||
## Modules
|
## 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`
|
### `strings`
|
||||||
```ts
|
```ts
|
||||||
import { ... } from "@warkypublic/artemis-kit/strings"
|
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? }`
|
**OpenAPI options:** `{ url?, apiKey?, maxTokens?, temperature?, topP?, n?, stream?, stop? }`
|
||||||
|
|
||||||
**Claude options:** `{ url?, apiKey?, model?, maxTokens?, temperature?, topP?, stopSequences?, system? }`
|
**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.
|
||||||
+20
-3
@@ -17,7 +17,9 @@
|
|||||||
"./dom": "./src/dom/index.ts",
|
"./dom": "./src/dom/index.ts",
|
||||||
"./i18n": "./src/i18n/index.ts",
|
"./i18n": "./src/i18n/index.ts",
|
||||||
"./logger": "./src/logger/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": {
|
"typesVersions": {
|
||||||
"*": {
|
"*": {
|
||||||
@@ -53,6 +55,12 @@
|
|||||||
],
|
],
|
||||||
"llm": [
|
"llm": [
|
||||||
"./src/llm/index.ts"
|
"./src/llm/index.ts"
|
||||||
|
],
|
||||||
|
"collections": [
|
||||||
|
"./src/collections/index.ts"
|
||||||
|
],
|
||||||
|
"sorting": [
|
||||||
|
"./src/sorting/index.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -72,7 +80,9 @@
|
|||||||
"./dom": "./dist/dom.js",
|
"./dom": "./dist/dom.js",
|
||||||
"./i18n": "./dist/i18n.js",
|
"./i18n": "./dist/i18n.js",
|
||||||
"./logger": "./dist/logger.js",
|
"./logger": "./dist/logger.js",
|
||||||
"./llm": "./dist/llm.js"
|
"./llm": "./dist/llm.js",
|
||||||
|
"./collections": "./dist/collections.js",
|
||||||
|
"./sorting": "./dist/sorting.js"
|
||||||
},
|
},
|
||||||
"typesVersions": {
|
"typesVersions": {
|
||||||
"*": {
|
"*": {
|
||||||
@@ -108,6 +118,12 @@
|
|||||||
],
|
],
|
||||||
"llm": [
|
"llm": [
|
||||||
"./dist/llm.d.ts"
|
"./dist/llm.d.ts"
|
||||||
|
],
|
||||||
|
"collections": [
|
||||||
|
"./dist/collections.d.ts"
|
||||||
|
],
|
||||||
|
"sorting": [
|
||||||
|
"./dist/sorting.d.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +138,8 @@
|
|||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"prepublishOnly": "npm run build",
|
"prepublishOnly": "npm run build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"lint": "eslint src"
|
"lint": "eslint src",
|
||||||
|
"check:bundle": "npm run build && node scripts/check-bundle.mjs"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"string",
|
"string",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
@@ -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([[], []]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -6,3 +6,6 @@ export * from "./i18n";
|
|||||||
|
|
||||||
//export * from './logger'
|
//export * from './logger'
|
||||||
export * from "./dom";
|
export * from "./dom";
|
||||||
|
|
||||||
|
export * from "./collections";
|
||||||
|
export * from "./sorting";
|
||||||
@@ -2,6 +2,46 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { decycle, retrocycle } from './decycle';
|
import { decycle, retrocycle } from './decycle';
|
||||||
|
|
||||||
describe('decycle and retrocycle functions', () => {
|
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', () => {
|
it('should handle non-circular objects correctly', () => {
|
||||||
const obj = { a: 1, b: 'string', c: true };
|
const obj = { a: 1, b: 'string', c: true };
|
||||||
const decycled = decycle(obj);
|
const decycled = decycle(obj);
|
||||||
|
|||||||
+15
-7
@@ -90,10 +90,20 @@ export function retrocycle<T>($: T): T {
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// Regular expression to validate JSONPath format
|
// 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
|
// 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 {
|
(function rez(value: any): void {
|
||||||
// The rez function walks recursively through the object looking for $ref
|
// 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) {
|
if (typeof element === "object" && element !== null) {
|
||||||
const path = element.$ref;
|
const path = element.$ref;
|
||||||
if (typeof path === "string" && px.test(path)) {
|
if (typeof path === "string" && px.test(path)) {
|
||||||
// Security warning: eval is used here
|
value[i] = resolvePath(path);
|
||||||
value[i] = eval(path);
|
|
||||||
} else {
|
} else {
|
||||||
rez(element);
|
rez(element);
|
||||||
}
|
}
|
||||||
@@ -119,8 +128,7 @@ export function retrocycle<T>($: T): T {
|
|||||||
if (typeof item === "object" && item !== null) {
|
if (typeof item === "object" && item !== null) {
|
||||||
const path = item.$ref;
|
const path = item.$ref;
|
||||||
if (typeof path === "string" && px.test(path)) {
|
if (typeof path === "string" && px.test(path)) {
|
||||||
// Security warning: eval is used here
|
value[name] = resolvePath(path);
|
||||||
value[name] = eval(path);
|
|
||||||
} else {
|
} else {
|
||||||
rez(item);
|
rez(item);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
lib: {
|
lib: {
|
||||||
entry: {
|
entry: {
|
||||||
|
collections: resolve(__dirname, "src/collections/index.ts"),
|
||||||
|
sorting: resolve(__dirname, "src/sorting/index.ts"),
|
||||||
index: resolve(__dirname, "src/index.ts"),
|
index: resolve(__dirname, "src/index.ts"),
|
||||||
object: resolve(__dirname, "src/object/index.ts"),
|
object: resolve(__dirname, "src/object/index.ts"),
|
||||||
dataqueue: resolve(__dirname, "src/dataqueue/index.ts"),
|
dataqueue: resolve(__dirname, "src/dataqueue/index.ts"),
|
||||||
@@ -28,6 +30,10 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
onwarn(warning, warn) {
|
||||||
|
if (warning.code === "EVAL") throw new Error(warning.message);
|
||||||
|
warn(warning);
|
||||||
|
},
|
||||||
external: [
|
external: [
|
||||||
"fs",
|
"fs",
|
||||||
"path",
|
"path",
|
||||||
|
|||||||
Reference in new issue
Block a user