@vielzeug/codex 26.10.7 → 26.10.10

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.
@@ -1,9 +1,9 @@
1
1
  {
2
- "apiSource": "export * from './array/chunk';\nexport * from './array/filterMap';\nexport * from './array/groupBy';\nexport * from './array/indexBy';\nexport * from './array/partition';\nexport * from './array/sort';\nexport * from './array/uniq';\nexport * from './async/abortable';\nexport * from './async/abortError';\nexport * from './async/attempt';\nexport * from './async/backoff';\nexport * from './async/parallel';\nexport * from './async/retry';\nexport * from './async/sleep';\nexport * from './async/taskPool';\nexport * from './async/waitFor';\nexport * from './cache/cache';\nexport * from './cache/memo';\nexport * from './encoding/base64url';\nexport * from './encoding/compression';\nexport * from './encoding/share-code';\nexport * from './errors';\nexport * from './function/debounce';\nexport * from './function/once';\nexport * from './function/pipe';\nexport * from './function/tapper';\nexport * from './function/throttle';\nexport * from './guards/combinators';\nexport * from './guards/isDefined';\nexport * from './guards/isEqual';\nexport * from './guards/isNil';\nexport * from './guards/isPlainObject';\nexport * from './math/allocate';\nexport * from './math/clamp';\nexport * from './math/range';\nexport * from './object/getPath';\nexport * from './object/hash';\nexport * from './object/omit';\nexport * from './object/pick';\nexport * from './random/random';\nexport * from './random/source';\nexport * from './string/camelCase';\nexport * from './types';\n",
2
+ "apiSource": "export * from './array/chunk';\nexport * from './array/filterMap';\nexport * from './array/groupBy';\nexport * from './array/indexBy';\nexport * from './array/paginate';\nexport * from './array/partition';\nexport * from './array/sort';\nexport * from './array/uniq';\nexport * from './async/abortable';\nexport * from './async/abortError';\nexport * from './async/attempt';\nexport * from './async/backoff';\nexport * from './async/parallel';\nexport * from './async/retry';\nexport * from './async/sleep';\nexport * from './async/taskPool';\nexport * from './async/waitFor';\nexport * from './cache/cache';\nexport * from './cache/memo';\nexport * from './encoding/base64url';\nexport * from './encoding/compression';\nexport * from './encoding/share-code';\nexport * from './errors';\nexport * from './function/debounce';\nexport * from './function/once';\nexport * from './function/pipe';\nexport * from './function/tapper';\nexport * from './function/throttle';\nexport * from './guards/combinators';\nexport * from './guards/isDefined';\nexport * from './guards/isEqual';\nexport * from './guards/isNil';\nexport * from './guards/isPlainObject';\nexport * from './math/allocate';\nexport * from './math/clamp';\nexport * from './math/range';\nexport * from './object/getPath';\nexport * from './object/hash';\nexport * from './object/omit';\nexport * from './object/pick';\nexport * from './random/random';\nexport * from './random/source';\nexport * from './string/camelCase';\nexport * from './types';\n",
3
3
  "docs": {
4
- "index": "---\ntitle: 'Arsenal: Utility library for TypeScript'\ndescription: Tree-shakeable TypeScript utilities with focused category entry points for arrays, async work, caching, objects, strings, math, and guards.\npackage: arsenal\ncategory: utilities\nkeywords: [utility, array, string, object, math, async, debounce, throttle, cache]\nexports: [groupBy, retry, debounce, isEqual, taskPool, cache, fuzzyFilter, tryParseJson, allocate, hash]\nrelated: [tempo, sourcerer, spell, coins]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"arsenal\" />\n\n## Why Arsenal?\n\nArsenal provides typed, zero-dependency utilities that reduce repeated application code: collection transforms, numeric operations, retry and cancellation, caching, safe-path traversal, and deterministic serialization. The root stays curated while category entry points expose the broader toolkit. Exact platform aliases remain excluded.\n\n```ts\n// Before\nconst users = JSON.parse(raw).filter((user) => user.name.includes(query));\n\n// After\nimport { fuzzyFilter } from '@vielzeug/arsenal/array';\nimport { tryParseJson } from '@vielzeug/arsenal/object';\n\nconst parsed = tryParseJson(raw);\nconst users = parsed.ok ? fuzzyFilter(parsed.value as User[], query, { select: (user) => user.name }) : [];\n```\n\n| Feature | Arsenal | lodash-es | Remeda |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"arsenal\" type=\"size\" /> | ~72 kB | ~18 kB |\n| Typed root utilities | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Partial | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Category entry points | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Partial | Partial |\n| Async task pool and cache | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Arsenal when** you need one typed utility dependency with focused subpaths for specialized behavior.\n\n**Consider narrower alternatives when** you need only platform APIs or a small functional subset.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/arsenal\n```\n\n```sh [npm]\nnpm install @vielzeug/arsenal\n```\n\n```sh [yarn]\nyarn add @vielzeug/arsenal\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { groupBy, retry } from '@vielzeug/arsenal';\nimport { cache } from '@vielzeug/arsenal/cache';\nimport { taskPool } from '@vielzeug/arsenal/async';\n\nconst byRole = groupBy([{ role: 'admin' }, { role: 'user' }], (user) => user.role);\n\nconst pool = taskPool({ concurrency: 2 });\nconst health = await pool.run((signal) => retry(() => fetch('/health', { signal }).then((response) => response.json())));\n\nconst responses = cache<string, unknown>({ ttlMs: 60_000 });\nconst profile = await responses.getOrLoad('/profile', () => fetch('/profile').then((response) => response.json()));\n\npool.dispose();\nconsole.log(byRole, health, profile);\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **`retry`**: retry async work with cancellation support from package root\n- **`taskPool`**: bounded, disposable concurrent work from `/async`\n- **`cache`**: identity-keyed TTL cache with async load deduplication from `/cache`\n- **`fuzzyFilter`**: explicit-field fuzzy filtering from `/array`\n- **`tryParseJson`**: preserve JSON syntax failures from `/object`\n- **`allocate`**: lossless proportional distribution from `/math`\n- **`chunk`, `zip`, `compact`**: typed collection transforms from `/array`\n- **`range`, `average`, `median`**: common numeric operations from `/math`\n- **`assert`, `tap`**: typed control-flow helpers from `/function`\n- **`isEqual`**: structural equality from package root\n- **`hash`**: deterministic serialization for stable cache keys from `/object`\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Spell](/spell/): validate `unknown` JSON data after `tryParseJson`.\n- [Vault](/vault/): persistent storage; Arsenal cache is in-memory only.\n- [Tempo](/tempo/): date/time utilities kept outside Arsenal.\n- [Coins](/coins/): money formatting and currency conversion.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: 'Arsenal: API Reference'\ndescription: Reference for Arsenal root utilities and category entry points.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution | Common gotcha |\n| --- | --- | --- | --- |\n| `groupBy` | Group values by key (prototype-pollution guarded) | Sync | Root export |\n| `chunk` / `zip` / `compact` | Typed collection transforms | Sync | Available from `/array` |\n| `range` / `average` / `median` | Numeric generation and aggregation | Sync | Available from `/math` |\n| `assert` | Assert and narrow a condition | Sync | Available from `/function` |\n| `retry` | Retry async work | Async | Rethrows final error |\n| `taskPool` | Bound concurrent tasks | Async | Available from `/async` |\n| `cache` | In-memory identity-keyed cache | Async | Available from `/cache` |\n| `fuzzyFilter` | Filter string or selected object fields | Sync | Object collections require `select` |\n| `fuzzyScore` | Rank string or selected object fields | Sync | Object collections require `select` |\n| `tryParseJson` | Preserve JSON syntax result | Sync | Returns `unknown` on success |\n| `getPath` | Optional object lookup (safe-path guarded) | Sync | Available from `/object` |\n| `allocate` | Lossless proportional distribution | Sync | Available from `/math` |\n| `isEqual` | Structural equality | Sync | Root export |\n| `hash` | Deterministic serialization for cache keys | Sync | Available from `/object` |\n| `compressBytes` / `decompressBytes` | Native byte compression with bounded decompression | Async | Requires native compression streams; decoding requires an output limit |\n| `pack` / `unpack` | Versioned, compressed, URL-safe payload envelope | Async | Both sides bound the payload with `maxBytes`; compression falls back to raw |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/arsenal` | Curated common utilities |\n| `@vielzeug/arsenal/array` | Immutable transforms, set operations, sorting, fuzzy search |\n| `@vielzeug/arsenal/async` | Retry, cancellation, task pool, timing |\n| `@vielzeug/arsenal/cache` | In-memory cache and memoization |\n| `@vielzeug/arsenal/encoding` | Base64url and native byte compression |\n| `@vielzeug/arsenal/function` | Assertions, composition, timing, teardown |\n| `@vielzeug/arsenal/guards` | Type guards, predicate combinators, equality guards |\n| `@vielzeug/arsenal/math` | Ranges, aggregation, statistics, interpolation, exact allocation |\n| `@vielzeug/arsenal/object` | Paths, transforms, hash, JSON parse result |\n| `@vielzeug/arsenal/random` | Cryptographic-entropy selection and shuffle |\n| `@vielzeug/arsenal/string` | Unicode-aware case transforms and similarity |\n\n## Array\n\n### Typed transforms\n\n```ts\nchunk<T>(input: readonly T[], size?: number): T[][]\ncompact<T>(array: readonly T[]): Array<Exclude<T, false | '' | 0 | 0n | null | undefined>>\nfirst<T>(array: readonly T[], fallback?: T): T | undefined\nlast<T>(array: readonly T[], fallback?: T): T | undefined\nreplace<T>(array: readonly T[], predicate: (value: T) => boolean, value: T): T[]\nrotate<T>(array: readonly T[], positions: number, options?: { wrap?: boolean }): T[]\nzip(...arrays): Array<tuple>\nunzip(rows): tupleOfArrays\n```\n\n`take`, `takeLast`, `drop`, and `dropLast` return new arrays and normalize negative counts to zero. Set operations accept optional selectors for object identity.\n\n### fuzzyFilter / fuzzyScore\n\n```ts\nfuzzyFilter(strings: readonly string[], query: string, options?: FuzzyOptions): string[]\nfuzzyFilter<T>(items: readonly T[], query: string, options: FuzzySelection<T>): T[]\nfuzzyScore(strings: readonly string[], query: string, options?: FuzzyOptions): ScoredResult<string>[]\nfuzzyScore<T>(items: readonly T[], query: string, options: FuzzySelection<T>): ScoredResult<T>[]\n```\n\n`fuzzyFilter` preserves input order. `fuzzyScore` orders results by descending score.\n\n```ts\nimport { fuzzyFilter } from '@vielzeug/arsenal/array';\n\nconst users = [{ email: 'alice@example.com', name: 'Alice' }];\nconst matches = fuzzyFilter(users, 'alice', { select: (user) => [user.name, user.email] });\n```\n\n---\n\n## Async\n\n### taskPool\n\n```ts\ninterface TaskPool {\n run<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>;\n idle(): Promise<void>;\n dispose(reason?: unknown): void;\n readonly active: number;\n readonly pending: number;\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n}\n\ntaskPool(options?: { concurrency?: number }): TaskPool\n```\n\n`dispose()` aborts running cooperative tasks and rejects pending tasks.\n\n```ts\nimport { taskPool } from '@vielzeug/arsenal/async';\n\nconst pool = taskPool({ concurrency: 2 });\nconst user = await pool.run((signal) => fetch('/user', { signal }).then((response) => response.json()));\npool.dispose();\n```\n\n---\n\n## Cache\n\n### cache\n\n```ts\ninterface Cache<K, T> {\n entries(): ReadonlyArray<readonly [K, T]>;\n get(key: K): T | undefined;\n set(key: K, value: T, options?: { ttlMs?: number }): void;\n getOrLoad(key: K, load: () => Promise<T>): Promise<T>;\n delete(key: K): boolean;\n clear(): void;\n readonly size: number;\n}\n\ncache<K, T>(options?: CacheOptions): Cache<K, T>\n```\n\nKeys use native `Map` identity. Expiry is lazy, evaluated by `get`, `getOrLoad`, `set`, and `entries`. `entries()` trims expired values and returns a new insertion-ordered snapshot without exposing internal storage. The `size` getter does not evict expired entries.\n\n```ts\nimport { cache } from '@vielzeug/arsenal/cache';\n\nconst profiles = cache<string, Profile>({ ttlMs: 60_000 });\nconst profile = await profiles.getOrLoad('me', loadProfile);\n```\n\n---\n\n## Encoding\n\nURL-safe base64 (`base64url`) codecs without `btoa`/`Buffer`, so the same code runs in browsers, workers, and Node.\n\n```ts\nimport { base64UrlToBytes, base64UrlToText, bytesToBase64Url, textToBase64Url, utf8Bytes } from '@vielzeug/arsenal';\n\ntextToBase64Url('{\"build\":\"hero\"}'); // never contains '+', '/', or '='\nbase64UrlToText(textToBase64Url('League of Legends')); // round-trips any UTF-8 text\nbytesToBase64Url(Uint8Array.from([0, 1, 250])); // byte-level codec for binary payloads\nbase64UrlToBytes(code); // throws on characters outside the alphabet\nutf8Bytes('Hunter äöü'); // 11: byte length of a UTF-8 string\n```\n\n### compressBytes\n\n```ts\ncompressBytes(bytes: Uint8Array, options?: CompressionOptions): Promise<Uint8Array>\n```\n\nCompresses bytes using the native `CompressionStream` API without modifying the input.\n\n| Option | Type | Default | Meaning |\n| --- | --- | --- | --- |\n| `format` | `CompressionFormat` | `'deflate-raw'` | Compression format; the receiver must use the same format |\n\n**Returns:** Compressed bytes. Rejects with `ArsenalSerializationError` when the format or capability is unavailable, or stream processing fails. Does not fall back to another format.\n\n**Example:** Encode compressed bytes for a URL.\n\n```ts\nimport { bytesToBase64Url, compressBytes } from '@vielzeug/arsenal';\n\nconst bytes = new TextEncoder().encode(JSON.stringify({ name: 'Hunters' }));\nconst code = bytesToBase64Url(await compressBytes(bytes));\n```\n\n---\n\n### decompressBytes\n\n```ts\ndecompressBytes(bytes: Uint8Array, options: DecompressionOptions): Promise<Uint8Array>\n```\n\nDecompresses bytes using the native `DecompressionStream` API, enforcing the output limit while reading chunks.\n\n| Option | Type | Default | Meaning |\n| --- | --- | --- | --- |\n| `format` | `CompressionFormat` | `'deflate-raw'` | Format used by the sender |\n| `maxOutputBytes` | `number` | Required | Non-negative safe integer; maximum decoded byte length |\n\n**Returns:** Decompressed bytes. Rejects with `ArsenalSerializationError` for invalid limits, corrupt or truncated data, unavailable formats/capabilities, or output exceeding the limit. A failed stream is canceled and its reader lock released. Bound encoded input separately before calling this function.\n\n**Example:** Decode text within an application-defined byte limit.\n\n```ts\nimport { compressBytes, decompressBytes } from '@vielzeug/arsenal';\n\nconst compressed = await compressBytes(new TextEncoder().encode('Hunters'));\nconst bytes = await decompressBytes(compressed, { maxOutputBytes: 8192 });\nconst text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);\n```\n\n---\n\n### pack\n\n```ts\npack(payload: unknown, options: ShareCodeOptions): Promise<string>\n```\n\nSerializes `payload` as JSON and encodes it into a compact share code: `prefix` + mode marker + base64url bytes, compressed with raw DEFLATE when the compressed form is shorter and the runtime supports the streams.\n\n| Option | Type | Meaning |\n| --- | --- | --- |\n| `maxBytes` | `number` | Positive safe integer; maximum serialized payload size |\n| `prefix` | `string` | 1–8 alphanumeric characters identifying the code family and version |\n\n**Returns:** The shorter of the raw (`prefix` + `r`) and compressed (`prefix` + `d`) representations. Throws `ShareCodeError` (`too-large`, `invalid`) for oversized payloads and misconfigured options.\n\n**Example:** Encode a shareable build as a code small enough for a URL.\n\n```ts\nimport { pack } from '@vielzeug/arsenal';\n\nconst code = await pack({ cards: ['slash', 'guard'] }, { maxBytes: 8192, prefix: 'ab1' });\n```\n\n---\n\n### unpack\n\n```ts\nunpack(code: string, options: ShareCodeOptions): Promise<unknown>\n```\n\nDecodes a share code produced by `pack` with the same options. Both sides are bounded: input longer than `maxBytes` could carry is refused up front, streamed decompression stops at `maxBytes`, and non-canonical base64url is rejected by round-trip.\n\n**Returns:** The parsed JSON payload. Throws `ShareCodeError` (`invalid` for foreign or malformed codes, `too-large` for budget overruns, `compression-unavailable` when a compressed code arrives on a runtime without `DecompressionStream`). Underlying compression failures surface as `ArsenalSerializationError`.\n\n**Example:** Decode untrusted input within the byte budget.\n\n```ts\nimport { unpack } from '@vielzeug/arsenal';\n\nconst payload = await unpack(code, { maxBytes: 8192, prefix: 'ab1' });\n```\n\n## Object\n\n### tryParseJson\n\n```ts\ntype JsonParseResult = { ok: true; value: unknown } | { error: SyntaxError; ok: false };\n\ntryParseJson(text: string): JsonParseResult\n```\n\nUse a schema validator after success to refine `unknown` data.\n\n```ts\nimport { tryParseJson } from '@vielzeug/arsenal/object';\n\nconst result = tryParseJson(raw);\nif (!result.ok) throw result.error;\n```\n\n### getPath\n\n```ts\ngetPath<T extends Record<string, unknown>, P extends string>(item: T, path: P): PathValue<T, P> | undefined\ngetPathOr<T extends Record<string, unknown>, P extends string, F>(item: T, path: P, fallback: F): PathValue<T, P> | F\nrequirePath<T extends Record<string, unknown>, P extends string>(item: T, path: P): Exclude<PathValue<T, P>, undefined>\n```\n\n### hash\n\n```ts\nhash(value: unknown, options?: HashOptions): string\n```\n\nProduces a deterministic, order-independent serialization. Handles `Date`, `RegExp`, `Set`, `Map`, and `bigint`. Circular references produce a sentinel. Useful for stable cache keys.\n\n---\n\n## Math\n\n### Numeric toolkit\n\n```ts\nrange(stop: number): number[]\nrange(start: number, stop: number, step?: number): number[]\nclamp(value: number, min?: number, max?: number): number\nsum<T>(values: readonly T[], select?: (value: T) => number): number\naverage<T>(values: readonly T[], select?: (value: T) => number): number | undefined\nmedian<T>(values: readonly T[], select?: (value: T) => number): number | undefined\nvariance<T>(values: readonly T[], select?: (value: T) => number): number\nstandardDeviation<T>(values: readonly T[], select?: (value: T) => number): number\n```\n\n`sum` and `average` reject non-finite values. `gcd` and `lcm` require safe integers. `linspace` requires finite bounds and a positive integer point count. `variance` and `standardDeviation` compute population statistics.\n\n### allocate\n\n```ts\nallocate(amount: number, ratios: number[] | number): number[]\nallocate(amount: bigint, ratios: number[] | number): bigint[]\n```\n\nDistributes an amount proportionally across ratios. The indivisible remainder is applied to the last bucket so the sum equals the original amount exactly: critical for financial operations.\n\n## Types\n\n```ts\ntype FuzzyOptions = {\n normalize?: boolean;\n threshold?: number;\n};\n\ntype FuzzySelection<T> = FuzzyOptions & {\n select: (item: T) => string | readonly string[];\n};\n\ntype ScoredResult<T> = { item: T; score: number };\n\ntype CacheOptions = {\n capacity?: number;\n now?: () => number;\n ttlMs?: number;\n};\n\ninterface CompressionOptions {\n format?: CompressionFormat;\n}\n\ninterface DecompressionOptions extends CompressionOptions {\n maxOutputBytes: number;\n}\n```\n\n## Errors\n\n- `RangeError`: invalid numeric bounds, capacity, concurrency, or retry count.\n- `TypeError`: invalid value types, unsupported comparison, or required path missing.\n- `ArsenalSerializationError`: memo or hash cannot serialize supplied input; compression fails, is unavailable, receives an invalid output limit, or exceeds that limit. Stream failures retain the original error as `cause`.\n- `ShareCodeError`: a share code cannot be encoded or decoded; the `reason` field is `invalid`, `too-large`, or `compression-unavailable`.\n",
6
- "usage": "---\ntitle: 'Arsenal: Usage Guide'\ndescription: Use Arsenal root utilities for common work and category entry points for specialized collection, async, cache, object, and string behavior.\n---\n\n[[toc]]\n\n## Basic Usage\n\nStart at package root for common utilities. Move to a category entry point for the broader typed toolkit. Arsenal keeps helpers that improve readability, narrowing, immutable updates, or edge-case consistency; exact platform aliases stay out.\n\n```ts\nimport { groupBy, retry } from '@vielzeug/arsenal';\n\nconst users = [\n { id: 'a1', role: 'admin' },\n { id: 'u1', role: 'user' },\n { id: 'u2', role: 'user' },\n];\n\nconst byRole = groupBy(users, (user) => user.role);\nconst health = await retry(() => fetch('/health').then((response) => response.json()));\n\nconsole.log(byRole, health);\n```\n\nUse category imports for APIs absent from root:\n\n```ts\nimport { fuzzyFilter } from '@vielzeug/arsenal/array';\nimport { taskPool } from '@vielzeug/arsenal/async';\nimport { cache } from '@vielzeug/arsenal/cache';\nimport { tryParseJson } from '@vielzeug/arsenal/object';\n```\n\n## Transform Collections\n\nUse `/array` for transforms that preserve input immutability. `filterMap` combines mapping and omission; `indexBy` and `groupBy` build lookup structures without mutation.\n\n```ts\nimport { filterMap, indexBy, sort } from '@vielzeug/arsenal/array';\n\nconst products = [\n { id: 'p1', price: 20, published: true },\n { id: 'p2', price: 10, published: false },\n { id: 'p3', price: 15, published: true },\n];\n\nconst publishedLabels = filterMap(products, (product) =>\n product.published ? `${product.id}: ${product.price}` : undefined,\n);\nconst byId = indexBy(products, (product) => product.id);\nconst byPrice = sort(products, (product) => product.price);\n\nconsole.log(publishedLabels, byId, byPrice);\n```\n\n## Search Explicit Fields\n\nSearch string arrays directly. Object collections require `select`, so callers define exactly what can match.\n\n```ts\nimport { fuzzyFilter, fuzzyScore } from '@vielzeug/arsenal/array';\n\nconst users = [\n { email: 'alice@example.com', name: 'Alice' },\n { email: 'bob@example.com', name: 'Bob' },\n];\n\nconst matches = fuzzyFilter(users, 'alice', { select: (user) => [user.name, user.email] });\nconst ranked = fuzzyScore(users, 'ali', { select: (user) => user.name });\n```\n\n## Work with Object Data\n\nUse `/object` for paths, key selection, stable cache keys, and object transforms.\n\n```ts\nimport { getPathOr, hash, omit, pick } from '@vielzeug/arsenal/object';\n\nconst config = { api: { host: 'localhost', port: 3000 }, debug: true };\nconst port = getPathOr(config, 'api.port', 8080);\nconst publicConfig = pick(config, ['api']);\nconst productionConfig = omit(config, ['debug']);\nconst key = hash({ port, productionConfig });\n\nconsole.log(publicConfig, key);\n```\n\n## Parse and Validate JSON\n\n`tryParseJson` distinguishes syntax failure from schema failure. Treat successful values as `unknown`, then validate with Spell or application code.\n\n```ts\nimport { tryParseJson } from '@vielzeug/arsenal/object';\nimport { s } from '@vielzeug/spell';\n\nconst User = s.object({ id: s.string(), name: s.string() });\nconst parsed = tryParseJson(raw);\n\nif (!parsed.ok) throw parsed.error;\n\nconst user = User.parse(parsed.value);\n```\n\n## Bound Concurrent Work\n\nUse `parallel` for one finite collection. Use `taskPool` when tasks arrive over time or need disposal.\n\n```ts\nimport { parallel, taskPool } from '@vielzeug/arsenal/async';\n\nconst metadata = await parallel(urls, (url) => fetch(url).then((response) => response.json()), { limit: 4 });\n\nconst pool = taskPool({ concurrency: 2 });\nconst profile = await pool.run((signal) => fetch('/profile', { signal }).then((response) => response.json()));\n\nawait pool.idle();\npool.dispose();\n\nconsole.log(metadata, profile);\n```\n\n## Cache Loaded Values\n\nUse `cache` for process-local values. Keys retain native `Map` identity. `getOrLoad` deduplicates concurrent loads for one key.\n\n```ts\nimport { cache } from '@vielzeug/arsenal/cache';\n\ntype Profile = { id: string; name: string };\n\nconst profiles = cache<string, Profile>({ capacity: 100, ttlMs: 60_000 });\nconst profile = await profiles.getOrLoad('me', () => fetch('/profile').then((response) => response.json()));\n\nconsole.log(profiles.entries());\nprofiles.delete('me');\nconst freshProfile = await profiles.getOrLoad('me', () => fetch('/profile').then((response) => response.json()));\n\nconsole.log(profile, freshProfile);\n```\n\n## Test Deterministic Randomness\n\nRandom helpers use cryptographic entropy by default. Pass `RandomSource` in tests when output must be deterministic.\n\n```ts\nimport { random, randomFloat, type RandomSource } from '@vielzeug/arsenal/random';\n\nconst source: RandomSource = { next: () => 0.5 };\n\nrandomFloat(source); // 0.5\nrandom(1, 4, source); // 3\n```\n\n## Encode Text for URLs and QR Codes\n\nShare codes ride in URLs and QR payloads, where `+`, `/`, and padding `=` break consumers. The base64url codecs are environment-independent: no `btoa`, no `Buffer`, so the same encode/decode pair works in the browser, in workers, and in Node.\n\n```ts\nimport { base64UrlToText, textToBase64Url } from '@vielzeug/arsenal';\n\nconst code = textToBase64Url(JSON.stringify({ seat: 4, version: 2 }));\n// eyJzZWF0Ijo0LCJ2ZXJzaW9uIjoyfQ: safe in a query string or QR\n\nconst payload = JSON.parse(base64UrlToText(code));\n```\n\nDecoding throws on characters outside the base64url alphabet, so corrupted input fails loudly instead of decoding to garbage.\n\n## Compress Share Payloads\n\nCompress UTF-8 bytes before Base64url encoding. Set a decoded-byte limit before accepting compressed input from another device.\n\n```ts\nimport { base64UrlToBytes, bytesToBase64Url, compressBytes, decompressBytes } from '@vielzeug/arsenal';\n\nconst payload = JSON.stringify({ name: 'Hunters', notes: 'An expedition through Alborea. '.repeat(20) });\nconst compressed = await compressBytes(new TextEncoder().encode(payload));\nconst code = bytesToBase64Url(compressed);\nconst bytes = await decompressBytes(base64UrlToBytes(code), { maxOutputBytes: 8192 });\nconst restored = new TextDecoder('utf-8', { fatal: true }).decode(bytes);\nconsole.log(restored === payload);\n```\n\nBoth helpers default to `deflate-raw`. You can explicitly select `gzip` or `deflate`; use the same format at both ends. The helpers reject when native streams do not support the selected format. They do not choose a fallback or define a wire protocol.\n\nFor a complete versioned envelope over these primitives — prefix, compression mode marker, byte budgets on both sides — see [`pack` / `unpack`](./api.md#pack).\n\nCompression can enlarge small inputs. Compare the final encoded strings, including any format marker, before choosing a representation. Bound input size before decoding Base64url and validate decompressed JSON against your schema. The decompression limit bounds retained output, not native stream working memory.\n\n## Working with Other Vielzeug Libraries\n\nUse Spell after `tryParseJson` for typed external data. Use Vault instead of `cache` when data must survive reloads or process restart.\n\n```ts\nimport { tryParseJson } from '@vielzeug/arsenal/object';\nimport { s } from '@vielzeug/spell';\n\nconst Settings = s.object({ theme: s.string() });\nconst parsed = tryParseJson(rawSettings);\nconst settings = parsed.ok ? Settings.parse(parsed.value) : { theme: 'system' };\n```\n\n## Best Practices\n\n- Import common utilities from package root.\n- Import the broader toolkit from category entry points.\n- Prefer Arsenal when a helper improves typing, intent, immutable updates, or edge-case behavior.\n- Use the platform directly for exact aliases such as `crypto.randomUUID()` and `Array.prototype.flat()`.\n- Pass `select` for every fuzzy search over objects.\n- Validate parsed JSON before using it as application data.\n- Use `parallel` for finite batches and `taskPool` for ongoing work.\n- Dispose task pools when their owner ends.\n- Use `cache` only for in-memory data.\n- Inject `RandomSource` in deterministic tests.\n",
4
+ "index": "---\ntitle: 'Arsenal: Utility library for TypeScript'\ndescription: Tree-shakeable TypeScript utilities with focused category entry points for arrays, async work, caching, objects, strings, math, and guards.\npackage: arsenal\ncategory: utilities\nkeywords: [utility, array, pagination, sorting, string, object, math, async, cache]\nexports: [paginate, sort, retry, taskPool, cache]\nrelated: [tempo, sourcerer, spell, coins]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"arsenal\" />\n\n## Why Arsenal?\n\nArsenal provides typed, zero-dependency utilities that reduce repeated application code: collection transforms, numeric operations, retry and cancellation, caching, safe-path traversal, and deterministic serialization. The root stays curated while category entry points expose the broader toolkit. Exact platform aliases remain excluded.\n\n```ts\n// Before\nconst users = JSON.parse(raw).filter((user) => user.name.includes(query));\n\n// After\nimport { fuzzyFilter } from '@vielzeug/arsenal/array';\nimport { tryParseJson } from '@vielzeug/arsenal/object';\n\nconst parsed = tryParseJson(raw);\nconst users = parsed.ok ? fuzzyFilter(parsed.value as User[], query, { select: (user) => user.name }) : [];\n```\n\n| Feature | Arsenal | lodash-es | Remeda |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"arsenal\" type=\"size\" /> | ~72 kB | ~18 kB |\n| Typed root utilities | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Partial | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Category entry points | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Partial | Partial |\n| Async task pool and cache | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Arsenal when** you need one typed utility dependency with focused subpaths for specialized behavior.\n\n**Consider narrower alternatives when** you need only platform APIs or a small functional subset.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/arsenal\n```\n\n```sh [npm]\nnpm install @vielzeug/arsenal\n```\n\n```sh [yarn]\nyarn add @vielzeug/arsenal\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { groupBy, retry } from '@vielzeug/arsenal';\nimport { cache } from '@vielzeug/arsenal/cache';\nimport { taskPool } from '@vielzeug/arsenal/async';\n\nconst byRole = groupBy([{ role: 'admin' }, { role: 'user' }], (user) => user.role);\n\nconst pool = taskPool({ concurrency: 2 });\nconst health = await pool.run((signal) => retry(() => fetch('/health', { signal }).then((response) => response.json())));\n\nconst responses = cache<string, unknown>({ ttlMs: 60_000 });\nconst profile = await responses.getOrLoad('/profile', () => fetch('/profile').then((response) => response.json()));\n\npool.dispose();\nconsole.log(byRole, health, profile);\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **`retry`**: retry async work with cancellation support from package root\n- **`taskPool`**: bounded, disposable concurrent work from `/async`\n- **`cache`**: identity-keyed TTL cache with async load deduplication from `/cache`\n- **`fuzzyFilter`**: explicit-field fuzzy filtering from `/array`\n- **`tryParseJson`**: preserve JSON syntax failures from `/object`\n- **`allocate`**: lossless proportional distribution from `/math`\n- **`chunk`, `zip`, `compact`**: typed collection transforms from `/array`\n- **`paginate`**: one page plus its totals from an in-memory list, in a one- or zero-based page base\n- **`sort`**: selector, `{ key: direction }`, or ordered grid criteria from one entry point\n- **`range`, `average`, `median`**: common numeric operations from `/math`\n- **`assert`, `tap`**: typed control-flow helpers from `/function`\n- **`isEqual`**: structural equality from package root\n- **`hash`**: deterministic serialization for stable cache keys from `/object`\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Spell](/spell/): validate `unknown` JSON data after `tryParseJson`.\n- [Vault](/vault/): persistent storage; Arsenal cache is in-memory only.\n- [Tempo](/tempo/): date/time utilities kept outside Arsenal.\n- [Coins](/coins/): money formatting and currency conversion.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
+ "api": "---\ntitle: 'Arsenal: API Reference'\ndescription: Reference for Arsenal root utilities and category entry points.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution | Common gotcha |\n| --- | --- | --- | --- |\n| `groupBy` | Group values by key (prototype-pollution guarded) | Sync | Root export |\n| `chunk` / `zip` / `compact` | Typed collection transforms | Sync | Available from `/array` |\n| `paginate` | Cut one page and its totals out of a list | Sync | Pages past the last return empty `items`, unclamped |\n| `sort` | Sort by a selector, keyed fields, or ordered path criteria | Sync | Criteria paths are resolved safely; mixed value types throw |\n| `range` / `average` / `median` | Numeric generation and aggregation | Sync | Available from `/math` |\n| `assert` | Assert and narrow a condition | Sync | Available from `/function` |\n| `retry` | Retry async work | Async | Rethrows final error |\n| `taskPool` | Bound concurrent tasks | Async | Available from `/async` |\n| `cache` | In-memory identity-keyed cache | Async | Available from `/cache` |\n| `fuzzyFilter` | Filter string or selected object fields | Sync | Object collections require `select` |\n| `fuzzyScore` | Rank string or selected object fields | Sync | Object collections require `select` |\n| `tryParseJson` | Preserve JSON syntax result | Sync | Returns `unknown` on success |\n| `getPath` | Optional object lookup (safe-path guarded) | Sync | Available from `/object` |\n| `allocate` | Lossless proportional distribution | Sync | Available from `/math` |\n| `isEqual` | Structural equality | Sync | Root export |\n| `hash` | Deterministic serialization for cache keys | Sync | Available from `/object` |\n| `compressBytes` / `decompressBytes` | Native byte compression with bounded decompression | Async | Requires native compression streams; decoding requires an output limit |\n| `pack` / `unpack` | Versioned, compressed, URL-safe payload envelope | Async | Both sides bound the payload with `maxBytes`; compression falls back to raw |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/arsenal` | Curated common utilities |\n| `@vielzeug/arsenal/array` | Immutable transforms, pagination, set operations, sorting, fuzzy search |\n| `@vielzeug/arsenal/async` | Retry, cancellation, task pool, timing |\n| `@vielzeug/arsenal/cache` | In-memory cache and memoization |\n| `@vielzeug/arsenal/encoding` | Base64url and native byte compression |\n| `@vielzeug/arsenal/function` | Assertions, composition, timing, teardown |\n| `@vielzeug/arsenal/guards` | Type guards, predicate combinators, equality guards |\n| `@vielzeug/arsenal/math` | Ranges, aggregation, statistics, interpolation, exact allocation |\n| `@vielzeug/arsenal/object` | Paths, transforms, hash, JSON parse result |\n| `@vielzeug/arsenal/random` | Cryptographic-entropy selection and shuffle |\n| `@vielzeug/arsenal/string` | Unicode-aware case transforms and similarity |\n\n## Array\n\n### Typed transforms\n\n```ts\nchunk<T>(input: readonly T[], size?: number): T[][]\ncompact<T>(array: readonly T[]): Array<Exclude<T, false | '' | 0 | 0n | null | undefined>>\nfirst<T>(array: readonly T[], fallback?: T): T | undefined\nlast<T>(array: readonly T[], fallback?: T): T | undefined\nreplace<T>(array: readonly T[], predicate: (value: T) => boolean, value: T): T[]\nrotate<T>(array: readonly T[], positions: number, options?: { wrap?: boolean }): T[]\nzip(...arrays): Array<tuple>\nunzip(rows): tupleOfArrays\n```\n\n`take`, `takeLast`, `drop`, and `dropLast` return new arrays and normalize negative counts to zero. Set operations accept optional selectors for object identity.\n\n### paginate\n\n```ts\npaginate<T>(items: readonly T[], options?: PaginateOptions): PageEnvelope<T>\n```\n\nCuts one page out of an in-memory list and returns the items with the arithmetic that produced them. `pageOffset` selects the caller's page base: `1` (default) for one-based paging, `0` for zero-based servers such as Spring. The default `page` is the first page in that base, and the default `pageSize` is `20`. `totalPages` is at least 1, even for an empty list, so `page < totalPages` never underflows. Pages past the last yield empty `items` and echo the requested page rather than clamping it. Throws `TypeError` for a non-array and `RangeError` when `page` is not an integer in the selected base or `pageSize` is not a positive integer.\n\n| Option | Type | Default | Description |\n| --- | --- | --- | --- |\n| `page` | `number` | First page in `pageOffset`'s base | Requested page; must be an integer greater than or equal to `pageOffset` |\n| `pageSize` | `number` | `20` | Number of items per page; must be a positive integer |\n| `pageOffset` | `0 \\| 1` | `1` | Page-number base: `0` for zero-based callers, `1` for one-based callers |\n\n```ts\nimport { paginate } from '@vielzeug/arsenal/array';\n\npaginate([1, 2, 3, 4, 5], { page: 2, pageSize: 2 });\n// { items: [3, 4], page: 2, pageSize: 2, totalItems: 5, totalPages: 3 }\n\nconst springPage = 0;\npaginate([1, 2, 3, 4, 5], { page: springPage, pageOffset: 0 });\n// { items: [1, 2, 3, 4, 5], page: 0, pageSize: 20, totalItems: 5, totalPages: 1 }\n```\n\nThe envelope is server-neutral: it models no HTTP dialect, so adapters map their response onto it and map it back out.\n\n### sort\n\n```ts\nsort<T>(array: readonly T[], selector: (item: T) => unknown, direction?: SortDirection): T[];\nsort<T>(array: readonly T[], selectors: SortSelectors<T>): T[];\nsort<T>(array: readonly T[], criteria: ReadonlyArray<SortCriterion<T>>): T[];\n```\n\nThree forms, one entry point. The selector form sorts by one extracted value; the selectors form accepts an object mapping property keys to directions; the criteria form sorts by an ordered list of paths, which is the shape a data grid hands over when its sortable columns are runtime strings.\n\n```ts\nimport { sort } from '@vielzeug/arsenal/array';\n\nconst users = [{ name: 'Ada', age: 41 }, { name: 'Grace', age: 30 }];\nsort(users, (user) => user.name);\nsort(users, { name: 'asc', age: 'desc' });\n\nconst rows = [\n { address: { city: 'Berlin' }, age: 41 },\n { address: { city: 'Aachen' }, age: 30 },\n];\nsort(rows, [{ key: 'address.city' }, { key: 'age', direction: 'desc' }]);\n```\n\nCriteria paths are resolved with `getPath`, so dotted and bracketed paths (`'address.city'`, `'tags[0]'`) work and prototype-access segments are refused. The first criterion that differs wins and earlier ties keep their input order. Ordering is delegated to `compare`, so a column that mixes value types (a number beside a string) throws a `TypeError` instead of guessing an order.\n\n### fuzzyFilter / fuzzyScore\n\n```ts\nfuzzyFilter(strings: readonly string[], query: string, options?: FuzzyOptions): string[]\nfuzzyFilter<T>(items: readonly T[], query: string, options: FuzzySelection<T>): T[]\nfuzzyScore(strings: readonly string[], query: string, options?: FuzzyOptions): ScoredResult<string>[]\nfuzzyScore<T>(items: readonly T[], query: string, options: FuzzySelection<T>): ScoredResult<T>[]\n```\n\n`fuzzyFilter` preserves input order. `fuzzyScore` orders results by descending score.\n\n```ts\nimport { fuzzyFilter } from '@vielzeug/arsenal/array';\n\nconst users = [{ email: 'alice@example.com', name: 'Alice' }];\nconst matches = fuzzyFilter(users, 'alice', { select: (user) => [user.name, user.email] });\n```\n\n---\n\n## Async\n\n### taskPool\n\n```ts\ninterface TaskPool {\n run<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>;\n idle(): Promise<void>;\n dispose(reason?: unknown): void;\n readonly active: number;\n readonly pending: number;\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n}\n\ntaskPool(options?: { concurrency?: number }): TaskPool\n```\n\n`dispose()` aborts running cooperative tasks and rejects pending tasks.\n\n```ts\nimport { taskPool } from '@vielzeug/arsenal/async';\n\nconst pool = taskPool({ concurrency: 2 });\nconst user = await pool.run((signal) => fetch('/user', { signal }).then((response) => response.json()));\npool.dispose();\n```\n\n---\n\n## Cache\n\n### cache\n\n```ts\ninterface Cache<K, T> {\n entries(): ReadonlyArray<readonly [K, T]>;\n get(key: K): T | undefined;\n set(key: K, value: T, options?: { ttlMs?: number }): void;\n getOrLoad(key: K, load: () => Promise<T>): Promise<T>;\n delete(key: K): boolean;\n clear(): void;\n readonly size: number;\n}\n\ncache<K, T>(options?: CacheOptions): Cache<K, T>\n```\n\nKeys use native `Map` identity. Expiry is lazy, evaluated by `get`, `getOrLoad`, `set`, and `entries`. `entries()` trims expired values and returns a new insertion-ordered snapshot without exposing internal storage. The `size` getter does not evict expired entries.\n\n```ts\nimport { cache } from '@vielzeug/arsenal/cache';\n\nconst profiles = cache<string, Profile>({ ttlMs: 60_000 });\nconst profile = await profiles.getOrLoad('me', loadProfile);\n```\n\n---\n\n## Encoding\n\nURL-safe base64 (`base64url`) codecs without `btoa`/`Buffer`, so the same code runs in browsers, workers, and Node.\n\n```ts\nimport { base64UrlToBytes, base64UrlToText, bytesToBase64Url, textToBase64Url, utf8Bytes } from '@vielzeug/arsenal';\n\ntextToBase64Url('{\"build\":\"hero\"}'); // never contains '+', '/', or '='\nbase64UrlToText(textToBase64Url('League of Legends')); // round-trips any UTF-8 text\nbytesToBase64Url(Uint8Array.from([0, 1, 250])); // byte-level codec for binary payloads\nbase64UrlToBytes(code); // throws on characters outside the alphabet\nutf8Bytes('Hunter äöü'); // 11: byte length of a UTF-8 string\n```\n\n### compressBytes\n\n```ts\ncompressBytes(bytes: Uint8Array, options?: CompressionOptions): Promise<Uint8Array>\n```\n\nCompresses bytes using the native `CompressionStream` API without modifying the input.\n\n| Option | Type | Default | Meaning |\n| --- | --- | --- | --- |\n| `format` | `CompressionFormat` | `'deflate-raw'` | Compression format; the receiver must use the same format |\n\n**Returns:** Compressed bytes. Rejects with `ArsenalSerializationError` when the format or capability is unavailable, or stream processing fails. Does not fall back to another format.\n\n**Example:** Encode compressed bytes for a URL.\n\n```ts\nimport { bytesToBase64Url, compressBytes } from '@vielzeug/arsenal';\n\nconst bytes = new TextEncoder().encode(JSON.stringify({ name: 'Hunters' }));\nconst code = bytesToBase64Url(await compressBytes(bytes));\n```\n\n---\n\n### decompressBytes\n\n```ts\ndecompressBytes(bytes: Uint8Array, options: DecompressionOptions): Promise<Uint8Array>\n```\n\nDecompresses bytes using the native `DecompressionStream` API, enforcing the output limit while reading chunks.\n\n| Option | Type | Default | Meaning |\n| --- | --- | --- | --- |\n| `format` | `CompressionFormat` | `'deflate-raw'` | Format used by the sender |\n| `maxOutputBytes` | `number` | Required | Non-negative safe integer; maximum decoded byte length |\n\n**Returns:** Decompressed bytes. Rejects with `ArsenalSerializationError` for invalid limits, corrupt or truncated data, unavailable formats/capabilities, or output exceeding the limit. A failed stream is canceled and its reader lock released. Bound encoded input separately before calling this function.\n\n**Example:** Decode text within an application-defined byte limit.\n\n```ts\nimport { compressBytes, decompressBytes } from '@vielzeug/arsenal';\n\nconst compressed = await compressBytes(new TextEncoder().encode('Hunters'));\nconst bytes = await decompressBytes(compressed, { maxOutputBytes: 8192 });\nconst text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);\n```\n\n---\n\n### pack\n\n```ts\npack(payload: unknown, options: ShareCodeOptions): Promise<string>\n```\n\nSerializes `payload` as JSON and encodes it into a compact share code: `prefix` + mode marker + base64url bytes, compressed with raw DEFLATE when the compressed form is shorter and the runtime supports the streams.\n\n| Option | Type | Meaning |\n| --- | --- | --- |\n| `maxBytes` | `number` | Positive safe integer; maximum serialized payload size |\n| `prefix` | `string` | 1–8 alphanumeric characters identifying the code family and version |\n\n**Returns:** The shorter of the raw (`prefix` + `r`) and compressed (`prefix` + `d`) representations. Throws `ShareCodeError` (`too-large`, `invalid`) for oversized payloads and misconfigured options.\n\n**Example:** Encode a shareable build as a code small enough for a URL.\n\n```ts\nimport { pack } from '@vielzeug/arsenal';\n\nconst code = await pack({ cards: ['slash', 'guard'] }, { maxBytes: 8192, prefix: 'ab1' });\n```\n\n---\n\n### unpack\n\n```ts\nunpack(code: string, options: ShareCodeOptions): Promise<unknown>\n```\n\nDecodes a share code produced by `pack` with the same options. Both sides are bounded: input longer than `maxBytes` could carry is refused up front, streamed decompression stops at `maxBytes`, and non-canonical base64url is rejected by round-trip.\n\n**Returns:** The parsed JSON payload. Throws `ShareCodeError` (`invalid` for foreign or malformed codes, `too-large` for budget overruns, `compression-unavailable` when a compressed code arrives on a runtime without `DecompressionStream`). Underlying compression failures surface as `ArsenalSerializationError`.\n\n**Example:** Decode untrusted input within the byte budget.\n\n```ts\nimport { unpack } from '@vielzeug/arsenal';\n\nconst payload = await unpack(code, { maxBytes: 8192, prefix: 'ab1' });\n```\n\n## Object\n\n### tryParseJson\n\n```ts\ntype JsonParseResult = { ok: true; value: unknown } | { error: SyntaxError; ok: false };\n\ntryParseJson(text: string): JsonParseResult\n```\n\nUse a schema validator after success to refine `unknown` data.\n\n```ts\nimport { tryParseJson } from '@vielzeug/arsenal/object';\n\nconst result = tryParseJson(raw);\nif (!result.ok) throw result.error;\n```\n\n### getPath\n\n```ts\ngetPath<T extends Record<string, unknown>, P extends string>(item: T, path: P): PathValue<T, P> | undefined\ngetPathOr<T extends Record<string, unknown>, P extends string, F>(item: T, path: P, fallback: F): PathValue<T, P> | F\nrequirePath<T extends Record<string, unknown>, P extends string>(item: T, path: P): Exclude<PathValue<T, P>, undefined>\n```\n\n### hash\n\n```ts\nhash(value: unknown, options?: HashOptions): string\n```\n\nProduces a deterministic, order-independent serialization. Handles `Date`, `RegExp`, `Set`, `Map`, and `bigint`. Circular references produce a sentinel. Useful for stable cache keys.\n\n---\n\n## Math\n\n### Numeric toolkit\n\n```ts\nrange(stop: number): number[]\nrange(start: number, stop: number, step?: number): number[]\nclamp(value: number, min?: number, max?: number): number\nsum<T>(values: readonly T[], select?: (value: T) => number): number\naverage<T>(values: readonly T[], select?: (value: T) => number): number | undefined\nmedian<T>(values: readonly T[], select?: (value: T) => number): number | undefined\nvariance<T>(values: readonly T[], select?: (value: T) => number): number\nstandardDeviation<T>(values: readonly T[], select?: (value: T) => number): number\n```\n\n`sum` and `average` reject non-finite values. `gcd` and `lcm` require safe integers. `linspace` requires finite bounds and a positive integer point count. `variance` and `standardDeviation` compute population statistics.\n\n### allocate\n\n```ts\nallocate(amount: number, ratios: number[] | number): number[]\nallocate(amount: bigint, ratios: number[] | number): bigint[]\n```\n\nDistributes an amount proportionally across ratios. The indivisible remainder is applied to the last bucket so the sum equals the original amount exactly: critical for financial operations.\n\n## Types\n\n```ts\ntype FuzzyOptions = {\n normalize?: boolean;\n threshold?: number;\n};\n\ntype FuzzySelection<T> = FuzzyOptions & {\n select: (item: T) => string | readonly string[];\n};\n\ntype ScoredResult<T> = { item: T; score: number };\n\ntype PageEnvelope<T> = Readonly<{\n items: readonly T[];\n page: number;\n pageSize: number;\n totalItems: number;\n totalPages: number;\n}>;\n\ntype PaginateOptions = Readonly<{\n page?: number;\n pageSize?: number;\n pageOffset?: 0 | 1;\n}>;\n\ntype SortDirection = 'asc' | 'desc';\n\ntype SortSelectors<T> = Partial<Record<keyof T, SortDirection>>;\n\ntype SortCriterion<T> = Readonly<{\n key: keyof T | (string & {});\n direction?: SortDirection;\n}>;\n\ntype CacheOptions = {\n capacity?: number;\n now?: () => number;\n ttlMs?: number;\n};\n\ninterface CompressionOptions {\n format?: CompressionFormat;\n}\n\ninterface DecompressionOptions extends CompressionOptions {\n maxOutputBytes: number;\n}\n```\n\n## Errors\n\n- `RangeError`: invalid numeric bounds, capacity, concurrency, or retry count.\n- `TypeError`: invalid value types, unsupported comparison, or required path missing.\n- `ArsenalSerializationError`: memo or hash cannot serialize supplied input; compression fails, is unavailable, receives an invalid output limit, or exceeds that limit. Stream failures retain the original error as `cause`.\n- `ShareCodeError`: a share code cannot be encoded or decoded; the `reason` field is `invalid`, `too-large`, or `compression-unavailable`.\n",
6
+ "usage": "---\ntitle: 'Arsenal: Usage Guide'\ndescription: Use Arsenal root utilities for common work and category entry points for specialized collection, async, cache, object, and string behavior.\n---\n\n[[toc]]\n\n## Basic Usage\n\nStart at package root for common utilities. Move to a category entry point for the broader typed toolkit. Arsenal keeps helpers that improve readability, narrowing, immutable updates, or edge-case consistency; exact platform aliases stay out.\n\n```ts\nimport { groupBy, retry } from '@vielzeug/arsenal';\n\nconst users = [\n { id: 'a1', role: 'admin' },\n { id: 'u1', role: 'user' },\n { id: 'u2', role: 'user' },\n];\n\nconst byRole = groupBy(users, (user) => user.role);\nconst health = await retry(() => fetch('/health').then((response) => response.json()));\n\nconsole.log(byRole, health);\n```\n\nUse category imports for APIs absent from root:\n\n```ts\nimport { fuzzyFilter } from '@vielzeug/arsenal/array';\nimport { taskPool } from '@vielzeug/arsenal/async';\nimport { cache } from '@vielzeug/arsenal/cache';\nimport { tryParseJson } from '@vielzeug/arsenal/object';\n```\n\n## Transform Collections\n\nUse `/array` for transforms that preserve input immutability. `filterMap` combines mapping and omission; `indexBy` and `groupBy` build lookup structures without mutation.\n\n```ts\nimport { filterMap, indexBy, sort } from '@vielzeug/arsenal/array';\n\nconst products = [\n { id: 'p1', price: 20, published: true },\n { id: 'p2', price: 10, published: false },\n { id: 'p3', price: 15, published: true },\n];\n\nconst publishedLabels = filterMap(products, (product) =>\n product.published ? `${product.id}: ${product.price}` : undefined,\n);\nconst byId = indexBy(products, (product) => product.id);\nconst byPrice = sort(products, (product) => product.price);\n\nconsole.log(publishedLabels, byId, byPrice);\n```\n\n## Page an In-Memory List\n\nUse `paginate` when the whole list is local and a view needs one page plus its totals.\n\n```ts\nimport { paginate } from '@vielzeug/arsenal/array';\n\nconst rows = Array.from({ length: 45 }, (_, index) => ({ id: index + 1 }));\n\nconst first = paginate(rows, { pageSize: 10 });\nconst past = paginate(rows, { page: 9, pageSize: 10 });\n\nconsole.log(first.totalPages, past.items); // 5 []\n```\n\n`page` is echoed rather than clamped, so a stale page number reads as an empty page instead of\nsilently jumping elsewhere. Pass `{ pageOffset: 0 }` for a zero-based caller and the envelope comes\nback in that same base.\n\n## Sort by Grid Criteria\n\n`sort` accepts the ordering a data grid already holds: an ordered list of column paths, each with a direction.\n\n```ts\nimport { sort } from '@vielzeug/arsenal/array';\n\nconst rows = [\n { address: { city: 'Berlin' }, age: 41 },\n { address: { city: 'Aachen' }, age: 30 },\n { address: { city: 'Berlin' }, age: 27 },\n];\n\nconst ordered = sort(rows, [{ key: 'address.city' }, { key: 'age', direction: 'desc' }]);\n```\n\nPaths are resolved safely, so nested and indexed columns work and prototype access is refused. The\nfirst criterion that differs decides the order and ties keep their input order. Keep each column\ntyped: a column mixing numbers and strings throws a `TypeError` rather than guessing an order.\n\n## Search Explicit Fields\n\nSearch string arrays directly. Object collections require `select`, so callers define exactly what can match.\n\n```ts\nimport { fuzzyFilter, fuzzyScore } from '@vielzeug/arsenal/array';\n\nconst users = [\n { email: 'alice@example.com', name: 'Alice' },\n { email: 'bob@example.com', name: 'Bob' },\n];\n\nconst matches = fuzzyFilter(users, 'alice', { select: (user) => [user.name, user.email] });\nconst ranked = fuzzyScore(users, 'ali', { select: (user) => user.name });\n```\n\n## Work with Object Data\n\nUse `/object` for paths, key selection, stable cache keys, and object transforms.\n\n```ts\nimport { getPathOr, hash, omit, pick } from '@vielzeug/arsenal/object';\n\nconst config = { api: { host: 'localhost', port: 3000 }, debug: true };\nconst port = getPathOr(config, 'api.port', 8080);\nconst publicConfig = pick(config, ['api']);\nconst productionConfig = omit(config, ['debug']);\nconst key = hash({ port, productionConfig });\n\nconsole.log(publicConfig, key);\n```\n\n## Parse and Validate JSON\n\n`tryParseJson` distinguishes syntax failure from schema failure. Treat successful values as `unknown`, then validate with Spell or application code.\n\n```ts\nimport { tryParseJson } from '@vielzeug/arsenal/object';\nimport { s } from '@vielzeug/spell';\n\nconst User = s.object({ id: s.string(), name: s.string() });\nconst parsed = tryParseJson(raw);\n\nif (!parsed.ok) throw parsed.error;\n\nconst user = User.parse(parsed.value);\n```\n\n## Bound Concurrent Work\n\nUse `parallel` for one finite collection. Use `taskPool` when tasks arrive over time or need disposal.\n\n```ts\nimport { parallel, taskPool } from '@vielzeug/arsenal/async';\n\nconst metadata = await parallel(urls, (url) => fetch(url).then((response) => response.json()), { limit: 4 });\n\nconst pool = taskPool({ concurrency: 2 });\nconst profile = await pool.run((signal) => fetch('/profile', { signal }).then((response) => response.json()));\n\nawait pool.idle();\npool.dispose();\n\nconsole.log(metadata, profile);\n```\n\n## Cache Loaded Values\n\nUse `cache` for process-local values. Keys retain native `Map` identity. `getOrLoad` deduplicates concurrent loads for one key.\n\n```ts\nimport { cache } from '@vielzeug/arsenal/cache';\n\ntype Profile = { id: string; name: string };\n\nconst profiles = cache<string, Profile>({ capacity: 100, ttlMs: 60_000 });\nconst profile = await profiles.getOrLoad('me', () => fetch('/profile').then((response) => response.json()));\n\nconsole.log(profiles.entries());\nprofiles.delete('me');\nconst freshProfile = await profiles.getOrLoad('me', () => fetch('/profile').then((response) => response.json()));\n\nconsole.log(profile, freshProfile);\n```\n\n## Test Deterministic Randomness\n\nRandom helpers use cryptographic entropy by default. Pass `RandomSource` in tests when output must be deterministic.\n\n```ts\nimport { random, randomFloat, type RandomSource } from '@vielzeug/arsenal/random';\n\nconst source: RandomSource = { next: () => 0.5 };\n\nrandomFloat(source); // 0.5\nrandom(1, 4, source); // 3\n```\n\n## Encode Text for URLs and QR Codes\n\nShare codes ride in URLs and QR payloads, where `+`, `/`, and padding `=` break consumers. The base64url codecs are environment-independent: no `btoa`, no `Buffer`, so the same encode/decode pair works in the browser, in workers, and in Node.\n\n```ts\nimport { base64UrlToText, textToBase64Url } from '@vielzeug/arsenal';\n\nconst code = textToBase64Url(JSON.stringify({ seat: 4, version: 2 }));\n// eyJzZWF0Ijo0LCJ2ZXJzaW9uIjoyfQ: safe in a query string or QR\n\nconst payload = JSON.parse(base64UrlToText(code));\n```\n\nDecoding throws on characters outside the base64url alphabet, so corrupted input fails loudly instead of decoding to garbage.\n\n## Compress Share Payloads\n\nCompress UTF-8 bytes before Base64url encoding. Set a decoded-byte limit before accepting compressed input from another device.\n\n```ts\nimport { base64UrlToBytes, bytesToBase64Url, compressBytes, decompressBytes } from '@vielzeug/arsenal';\n\nconst payload = JSON.stringify({ name: 'Hunters', notes: 'An expedition through Alborea. '.repeat(20) });\nconst compressed = await compressBytes(new TextEncoder().encode(payload));\nconst code = bytesToBase64Url(compressed);\nconst bytes = await decompressBytes(base64UrlToBytes(code), { maxOutputBytes: 8192 });\nconst restored = new TextDecoder('utf-8', { fatal: true }).decode(bytes);\nconsole.log(restored === payload);\n```\n\nBoth helpers default to `deflate-raw`. You can explicitly select `gzip` or `deflate`; use the same format at both ends. The helpers reject when native streams do not support the selected format. They do not choose a fallback or define a wire protocol.\n\nFor a complete versioned envelope over these primitives — prefix, compression mode marker, byte budgets on both sides — see [`pack` / `unpack`](./api.md#pack).\n\nCompression can enlarge small inputs. Compare the final encoded strings, including any format marker, before choosing a representation. Bound input size before decoding Base64url and validate decompressed JSON against your schema. The decompression limit bounds retained output, not native stream working memory.\n\n## Working with Other Vielzeug Libraries\n\nUse Spell after `tryParseJson` for typed external data. Use Vault instead of `cache` when data must survive reloads or process restart.\n\n```ts\nimport { tryParseJson } from '@vielzeug/arsenal/object';\nimport { s } from '@vielzeug/spell';\n\nconst Settings = s.object({ theme: s.string() });\nconst parsed = tryParseJson(rawSettings);\nconst settings = parsed.ok ? Settings.parse(parsed.value) : { theme: 'system' };\n```\n\n## Best Practices\n\n- Import common utilities from package root.\n- Import the broader toolkit from category entry points.\n- Prefer Arsenal when a helper improves typing, intent, immutable updates, or edge-case behavior.\n- Use the platform directly for exact aliases such as `crypto.randomUUID()` and `Array.prototype.flat()`.\n- Pass `select` for every fuzzy search over objects.\n- Validate parsed JSON before using it as application data.\n- Use `parallel` for finite batches and `taskPool` for ongoing work.\n- Dispose task pools when their owner ends.\n- Use `cache` only for in-memory data.\n- Inject `RandomSource` in deterministic tests.\n",
7
7
  "examples": "---\ntitle: 'Arsenal: Examples'\ndescription: Practical examples and recipes for arsenal.\n---\n\n## Examples\n\n- [Array utilities](./examples/array.md)\n- [Async utilities](./examples/async.md)\n- [Cache utilities](./examples/cache.md)\n- [Encoding utilities](./examples/encoding.md)\n- [Function utilities](./examples/function.md)\n- [Guards / Typed predicates](./examples/typed.md)\n- [Math utilities](./examples/math.md)\n- [Object utilities](./examples/object.md): includes `getPath`, `tryParseJson`, `hash`, `diff`, `deepMerge`\n- [Random utilities](./examples/random.md)\n- [String utilities](./examples/string.md)\n"
8
8
  },
9
9
  "examples": [
@@ -27,6 +27,11 @@
27
27
  "code": "import { filterMap } from '@vielzeug/arsenal'\n\nconst numbers = [1, 2, 3, 4, 5]\n\nconst doubled = filterMap(numbers, number => number * 2)\nconsole.log('Doubled:', doubled)\n\nconst strings = filterMap(numbers, number => `Number: ${number}`)\nconsole.log('Formatted:', strings)\n\nconst evenDoubled = filterMap(numbers, number =>\n number % 2 === 0 ? number * 2 : undefined\n)\nconsole.log('Even numbers doubled:', evenDoubled)",
28
28
  "name": "filterMap - Transform array elements"
29
29
  },
30
+ {
31
+ "id": "array-paginate",
32
+ "code": "import { paginate } from '@vielzeug/arsenal'\n\nconst rows = Array.from({ length: 45 }, (_, index) => ({ id: index + 1 }))\n\nconst first = paginate(rows, { pageSize: 10 })\nconsole.log('Page:', first.page, 'of', first.totalPages)\nconsole.log('Items:', first.items.map((row) => row.id))\n\n// A zero-based caller (Spring-style) gets its own base back\nconst zeroBased = paginate(rows, { page: 0, pageSize: 10, pageOffset: 0 })\nconsole.log('Zero-based page:', zeroBased.page, zeroBased.items.length)\n\n// Pages past the last echo the request instead of clamping it\nconst past = paginate(rows, { page: 9, pageSize: 10 })\nconsole.log('Past the end:', past.items, 'of', past.totalItems)",
33
+ "name": "paginate - Page an array"
34
+ },
30
35
  {
31
36
  "id": "array-search",
32
37
  "code": "import { fuzzyFilter, fuzzyScore } from '@vielzeug/arsenal/array'\n\nconst users = [\n { name: 'Alice Johnson', role: 'admin' },\n { name: 'Bob Smith', role: 'user' },\n { name: 'Charlie Brown', role: 'user' },\n]\n\nconst byName = fuzzyFilter(users, 'alice', { select: user => user.name })\nconsole.log('Filtered:', byName)\n\nconst ranked = fuzzyScore(users, 'smith', { select: user => [user.name, user.role] })\nconsole.log('Ranked:', ranked)",
@@ -37,6 +42,11 @@
37
42
  "code": "import { fuzzyFilter, fuzzyScore } from '@vielzeug/arsenal/array'\n\nconst names = ['José', 'Élise', 'café', 'naïve', 'resume']\n\nconst noNorm = fuzzyFilter(names, 'jose', { threshold: 0.9 })\nconsole.log('normalize:false:', noNorm)\n\nconst withNorm = fuzzyFilter(names, 'jose', { normalize: true, threshold: 0.9 })\nconsole.log('normalize:true:', withNorm)\n\nconst scored = fuzzyScore(names, 'elise', { normalize: true, threshold: 0.5 })\nconsole.log('Scored:', scored)",
38
43
  "name": "fuzzyFilter - Unicode normalization"
39
44
  },
45
+ {
46
+ "id": "array-sort-criteria",
47
+ "code": "import { sort } from '@vielzeug/arsenal'\n\nconst rows = [\n { address: { city: 'Berlin' }, age: 41, name: 'Ada' },\n { address: { city: 'Aachen' }, age: 30, name: 'Grace' },\n { address: { city: 'Berlin' }, age: 27, name: 'Linus' },\n]\n\n// A grid hands over ordered column paths, each with a direction\nconst ordered = sort(rows, [{ key: 'address.city' }, { key: 'age', direction: 'desc' }])\nconsole.log('City asc, then age desc:', ordered.map((row) => `${row.name} (${row.address.city})`))\n\n// Selector form for one extracted value\nconsole.log('By name:', sort(rows, (row) => row.name).map((row) => row.name))\n\n// Selectors form for a keyed object\nconsole.log('By age desc:', sort(rows, { age: 'desc' }).map((row) => row.age))",
48
+ "name": "sort - Selector, selectors, or grid criteria"
49
+ },
40
50
  {
41
51
  "id": "array-uniq",
42
52
  "code": "import { uniq } from '@vielzeug/arsenal'\n\nconst numbers = [1, 2, 2, 3, 3, 3, 4, 5, 5]\nconsole.log('Unique numbers:', uniq(numbers))\n\nconst tags = ['javascript', 'react', 'vue', 'react', 'angular', 'vue']\nconsole.log('Unique tags:', uniq(tags))\n\n// Works with objects too (by reference)\nconst obj1 = { id: 1 }\nconst obj2 = { id: 2 }\nconst objects = [obj1, obj2, obj1, obj2]\nconsole.log('Unique objects:', uniq(objects))",
@@ -168,10 +178,14 @@
168
178
  "filterMap": "export function filterMap<T, R>(\n array: readonly T[],\n callback: (item: T, index: number, array: readonly T[]) => R | undefined,\n): R[] {\n const result: R[] = [];\n\n for (let index = 0; index < array.length; index++) {\n const value = callback(array[index], index, array);\n\n if (value !== undefined) {\n result.push(value);\n }\n }\n\n return result;\n}",
169
179
  "groupBy": "export function groupBy<T>(array: readonly T[], selector: (item: T) => Primitive): Record<string, T[]> {\n const result: Record<string, T[]> = {};\n\n for (const item of array) {\n const rawKey = selector(item);\n const key = rawKey === undefined || rawKey === null ? '_' : String(rawKey);\n\n if (isUnsafeKey(key)) continue;\n\n if (!result[key]) {\n result[key] = [];\n }\n\n result[key].push(item);\n }\n\n return result;\n}",
170
180
  "indexBy": "export function indexBy<T>(array: readonly T[], selector: (item: T) => Primitive): Record<string, T> {\n const result: Record<string, T> = {};\n\n for (const item of array) {\n const key = String(selector(item));\n\n if (isUnsafeKey(key)) continue;\n\n result[key] = item;\n }\n\n return result;\n}",
181
+ "PageEnvelope": "export type PageEnvelope<T> = Readonly<{\n /** The page's items; empty when `page` is past the last page. */\n items: readonly T[];\n /** The requested page, echoed in the caller's base. */\n page: number;\n /** Items per page. */\n pageSize: number;\n /** Size of the full list the page was cut from. */\n totalItems: number;\n /** At least 1, even for an empty list, so `page < totalPages` never underflows. */\n totalPages: number;\n}>;",
182
+ "PaginateOptions": "export type PaginateOptions = Readonly<{\n /** Page to cut, in the caller's base. Defaults to the first page of that base. */\n page?: number;\n /** Items per page. Default 20. */\n pageSize?: number;\n /** Base of `page`: 0 for zero-based servers (Spring), 1 for one-based (default). */\n pageOffset?: 0 | 1;\n}>;",
183
+ "paginate": "export function paginate<T>(items: readonly T[], options: PaginateOptions = {}): PageEnvelope<T> {\n if (!Array.isArray(items)) throw new TypeError('paginate: first argument must be an array');\n\n const { pageOffset = 1, pageSize = 20 } = options;\n const page = options.page ?? pageOffset;\n\n if (!Number.isInteger(pageSize) || pageSize < 1)\n throw new RangeError('paginate: pageSize must be a positive integer');\n if (!Number.isInteger(page) || page < pageOffset)\n throw new RangeError(`paginate: page must be an integer >= ${pageOffset}`);\n\n const totalPages = Math.max(1, Math.ceil(items.length / pageSize));\n const start = (page - pageOffset) * pageSize;\n\n return {\n items: items.slice(start, start + pageSize),\n page,\n pageSize,\n totalItems: items.length,\n totalPages,\n };\n}",
171
184
  "partition": "export function partition<T>(\n array: readonly T[],\n predicate: (item: T, index: number, array: readonly T[]) => boolean,\n): [T[], T[]] {\n const accepted: T[] = [];\n const rejected: T[] = [];\n\n for (let index = 0; index < array.length; index++) {\n if (predicate(array[index], index, array)) {\n accepted.push(array[index]);\n } else {\n rejected.push(array[index]);\n }\n }\n\n return [accepted, rejected];\n}",
172
185
  "SortDirection": "export type SortDirection = 'asc' | 'desc';",
173
186
  "SortSelectors": "export type SortSelectors<T> = Partial<Record<keyof T, SortDirection>>;",
174
- "sort": "export function sort<T>(array: readonly T[], selector: (item: T) => unknown, direction?: SortDirection): T[];\n\nexport function sort<T>(array: readonly T[], selectors: SortSelectors<T>): T[];\n\nexport function sort<T>(\n array: readonly T[],\n selectorOrSelectors: ((item: T) => unknown) | SortSelectors<T>,\n direction: SortDirection = 'asc',\n): T[] {\n if (!Array.isArray(array)) throw new TypeError('sort: first argument must be an array');\n\n if (typeof selectorOrSelectors === 'function') {\n const multiplier = direction === 'desc' ? -1 : 1;\n\n return [...array].sort((a, b) => compare(selectorOrSelectors(a), selectorOrSelectors(b)) * multiplier);\n }\n\n return [...array].sort(compareBy(selectorOrSelectors));\n}",
187
+ "SortCriterion": "export type SortCriterion<T> = Readonly<{\n key: keyof T | (string & {});\n direction?: SortDirection;\n}>;",
188
+ "sort": "export function sort<T>(array: readonly T[], selector: (item: T) => unknown, direction?: SortDirection): T[];\n\nexport function sort<T>(array: readonly T[], selectors: SortSelectors<T>): T[];\n\nexport function sort<T>(array: readonly T[], criteria: ReadonlyArray<SortCriterion<T>>): T[];\n\nexport function sort<T>(\n array: readonly T[],\n selectorOrSelectors: ((item: T) => unknown) | ReadonlyArray<SortCriterion<T>> | SortSelectors<T>,\n direction: SortDirection = 'asc',\n): T[] {\n if (!Array.isArray(array)) throw new TypeError('sort: first argument must be an array');\n\n if (typeof selectorOrSelectors === 'function') {\n const multiplier = direction === 'desc' ? -1 : 1;\n\n return [...array].sort((a, b) => compare(selectorOrSelectors(a), selectorOrSelectors(b)) * multiplier);\n }\n\n if (isCriteriaForm(selectorOrSelectors)) {\n const criteria = selectorOrSelectors.map(({ direction: dir = 'asc', key }) => ({\n multiplier: dir === 'desc' ? -1 : 1,\n path: String(key),\n }));\n\n return [...array].sort((a, b) => {\n for (const { multiplier, path } of criteria) {\n const result = compare(getPath(a, path), getPath(b, path)) * multiplier;\n\n if (result !== 0) return result;\n }\n\n return 0;\n });\n }\n\n return [...array].sort(compareBy(selectorOrSelectors));\n}",
175
189
  "uniq": "export function uniq<T>(array: readonly T[], selector?: (item: T) => Primitive): T[] {\n if (array.length <= 1) {\n return [...array];\n }\n\n if (!selector) {\n return [...new Set(array)];\n }\n\n return dedupeBySelector(array, selector);\n}",
176
190
  "abortable": "export function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {\n if (signal.aborted) {\n return Promise.reject(abortError(signal));\n }\n\n return new Promise<T>((resolve, reject) => {\n const onAbort = () => reject(abortError(signal));\n\n signal.addEventListener('abort', onAbort, { once: true });\n\n promise\n .then((value) => resolve(value))\n .catch((error) => reject(error))\n .finally(() => signal.removeEventListener('abort', onAbort));\n });\n}",
177
191
  "abortError": "export function abortError(signal?: AbortSignal, message = 'Aborted'): unknown {\n return signal?.reason ?? new DOMException(message, 'AbortError');\n}",
@@ -1,13 +1,14 @@
1
1
  {
2
- "apiSource": "export { AssayError, AssayQueryError, AssayTimeoutError } from './errors';\n\nexport {\n dispatch,\n fireBlur,\n fireChange,\n fireClick,\n fireCustom,\n fireFocus,\n fireInput,\n fireKeyDown,\n fireKeyUp,\n fireSubmit,\n} from './events';\nexport { getSlotted, type QueryScope, queryAllInShadow, queryInShadow, queryPart, within } from './query';\nexport {\n type DelayOptions,\n delay,\n type EventuallyOptions,\n eventually,\n type WaitOptions,\n waitForEvent,\n waitUntil,\n} from './wait';\n",
2
+ "apiSource": "export { walkFlatTree } from './dom';\nexport { AssayError, AssayQueryError, AssayTimeoutError } from './errors';\nexport {\n dispatch,\n fireBlur,\n fireChange,\n fireClick,\n fireCustom,\n fireFocus,\n fireInput,\n fireKeyDown,\n fireKeyUp,\n fireSubmit,\n} from './events';\nexport { installFormInternalsPolyfill } from './form-internals';\nexport { getSlotted, type QueryScope, queryAllInShadow, queryInShadow, queryPart, within } from './query';\nexport {\n type DelayOptions,\n delay,\n type EventuallyOptions,\n eventually,\n type WaitOptions,\n waitForEvent,\n waitUntil,\n} from './wait';\n",
3
3
  "docs": {
4
- "index": "---\ntitle: 'Assay: Framework-agnostic DOM testing primitives'\ndescription: Scoped DOM queries, exact event dispatch, and cancellable async waiting for browser tests.\npackage: assay\ncategory: testing\nkeywords: [testing, dom, events, queries, custom-elements]\nrelated: [ore, refine]\nexports:\n [\n within,\n queryInShadow,\n queryAllInShadow,\n queryPart,\n getSlotted,\n dispatch,\n fireBlur,\n fireChange,\n fireClick,\n fireCustom,\n fireFocus,\n fireInput,\n fireKeyDown,\n fireKeyUp,\n fireSubmit,\n waitUntil,\n eventually,\n waitForEvent,\n delay,\n AssayError,\n AssayQueryError,\n AssayTimeoutError,\n ]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"assay\" />\n\n## Why Assay?\n\nAssay provides focused DOM test primitives that work with vanilla elements, custom elements, and framework-rendered output. It scopes queries, dispatches exact browser event classes, and waits on explicit conditions without imposing a renderer or browser automation stack.\n\n```ts\n// Before\nbutton.dispatchEvent(new MouseEvent('click', { bubbles: true }));\nawait new Promise((resolve) => setTimeout(resolve, 100));\n\n// After\nfireClick(view.get('button.submit'));\nawait waitUntil(() => view.queryByText('Saved') !== null);\n```\n\n| Feature | Assay | Testing Library DOM | Browser automation |\n| ------------------- | -------------------------------------------- | ---------------------------------------- | ---------------------------------------- |\n| Bundle size | <PackageInfo package=\"assay\" type=\"size\" /> | Larger query layer | Browser runtime required |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Scoped DOM queries | `within()` and shadow helpers | Renderer-oriented queries | Manual selectors |\n| Deterministic waits | `waitUntil()` and `waitForEvent()` | Framework-dependent | Full browser timing |\n\n<div class=\"decision-callout\">\n\n**Use Assay when** a DOM unit test needs readable queries, dispatched events, or a bounded async wait without adopting a rendering framework.\n\n**Consider browser integration tests when** correctness depends on browser default actions, focus behavior, pointer capture, layout, or accessibility-tree behavior.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add -D @vielzeug/assay\n```\n\n```sh [npm]\nnpm install -D @vielzeug/assay\n```\n\n```sh [yarn]\nyarn add -D @vielzeug/assay\n```\n\n:::\n\n## Quick Start\n\nScope a test fixture, dispatch an event, and wait for resulting DOM state.\n\n```ts\nimport { fireClick, waitUntil, within } from '@vielzeug/assay';\n\nconst panel = document.createElement('section');\npanel.innerHTML = '<button>Save</button><output></output>';\npanel.querySelector('button')!.addEventListener('click', () => {\n panel.querySelector('output')!.textContent = 'Saved';\n});\n\nconst view = within(panel);\nfireClick(view.get('button'));\nawait waitUntil(() => view.queryByText('Saved') !== null);\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `within(root)` scopes nullable and required DOM queries.\n- `queryInShadow`, `queryPart`, and `getSlotted` cross custom-element boundaries explicitly.\n- `fireClick`, `fireInput`, `fireKeyDown`, and peers dispatch exact synchronous events.\n- `waitUntil`, `eventually`, and `waitForEvent` provide bounded, abortable async waiting.\n- `delay` models explicit cancellable timer scheduling.\n- `AssayError`, `AssayQueryError`, and `AssayTimeoutError` provide typed failures.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Ore](/ore/): component authoring and test fixtures that pair with Assay DOM helpers.\n- [Refine](/refine/): accessible components with component-specific test assertions.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: 'Assay: API Reference'\ndescription: API reference for @vielzeug/assay queries, event dispatch, and async waiting.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| -------------------------------------------- | ------------------------------------------- | -------------- | ------------------------------------------------ |\n| `within` | Creates scoped query API | Sync | Required `get*` methods throw `AssayQueryError` |\n| `queryInShadow` / `queryPart` / `getSlotted` | Crosses custom-element boundaries | Sync | Open shadow roots are required |\n\n| `fire*` / `dispatch` | Dispatches platform event instances | Sync | Does not reproduce browser default behavior |\n| `waitUntil` / `eventually` / `waitForEvent` | Waits for conditions, assertions, or events | Async | Use a signal or timeout for bounded waits |\n| `delay` | Schedules a cancellable timer | Async | Pass a signal to cancel a pending wait |\n\n## Package Entry Point\n\n| Import | Purpose |\n| ----------------- | ---------------------------------------------------- |\n| `@vielzeug/assay` | DOM queries, events, wait helpers, errors, and types |\n\n## Queries\n\n### `within(root)`\n\nCreates a `QueryScope` for an `Element`, `ShadowRoot`, `Document`, or `DocumentFragment`.\n\n| Method | Returns | Use |\n| --------------------------------- | ----------------- | -------------------------------------------- |\n| `get(selector)` | `Element` | Required CSS match; throws `AssayQueryError` |\n| `query(selector)` | `Element \\| null` | Optional CSS match |\n| `queryAll(selector)` | `Element[]` | All CSS matches |\n| `getByText(text, selector?)` | `Element` | Required exact trimmed-text match |\n| `queryByText(text, selector?)` | `Element \\| null` | Optional exact trimmed-text match |\n| `queryAllByText(text, selector?)` | `Element[]` | All exact trimmed-text matches |\n| `getByTestId(id)` | `Element` | Required `data-testid` match |\n| `queryByTestId(id)` | `Element \\| null` | Optional `data-testid` match |\n| `queryAllByTestId(id)` | `Element[]` | All `data-testid` matches |\n\nText selectors default to `'*'`. Required-query failures include the lookup and a bounded view of the scoped DOM.\n\n### Shadow and slot helpers\n\n| Function | Returns | Description |\n| ---------------------------------- | ----------------- | ---------------------------------------------------- |\n| `queryInShadow(host, selector)` | `Element \\| null` | First match in an open shadow root |\n| `queryAllInShadow(host, selector)` | `Element[]` | All matches in an open shadow root |\n| `queryPart(host, part)` | `Element \\| null` | First shadow element whose `part` token matches |\n| `getSlotted(host, slotName?)` | `Element[]` | Direct light-DOM children in a named or default slot |\n\nThese helpers return `null` or `[]` when there is no shadow root. Dynamic test IDs, parts, and slot names are matched\nas attribute values rather than interpolated into CSS selectors.\n\n## Event dispatch\n\nAll event helpers synchronously return `dispatchEvent()`'s boolean result.\n\n```ts\nimport {\n dispatch,\n fireBlur,\n fireChange,\n fireClick,\n fireCustom,\n fireFocus,\n fireInput,\n fireKeyDown,\n fireKeyUp,\n fireSubmit,\n} from '@vielzeug/assay';\n\nfireClick(button, { clientX: 20 });\nfireInput(input);\nfireKeyDown(input, { key: 'Enter' });\nfireCustom(element, 'item-added', { detail: { id: '42' } });\ndispatch(element, new Event('ready'));\n```\n\n| Function | Event class | Defaults |\n| --------------------------- | --------------- | ------------------------------------------------------ |\n| `fireBlur` / `fireFocus` | `FocusEvent` | Platform defaults (`bubbles: false`) |\n| `fireChange` | `Event` | `bubbles: true` |\n| `fireInput` | `InputEvent` | `bubbles: true` |\n| `fireClick` | `MouseEvent` | `bubbles: true`, `cancelable: true` |\n| `fireKeyDown` / `fireKeyUp` | `KeyboardEvent` | `bubbles: true`, `cancelable: true` |\n| `fireSubmit` | `SubmitEvent` | `bubbles: true`, `cancelable: true` |\n| `fireCustom` | `CustomEvent` | `bubbles: true`, `cancelable: true`, `composed: false` |\n\n`fireCustom(target, type, init?)` dispatches a `CustomEvent` with the given type. Assay intentionally does not\nprovide browser-default or fallback pointer/touch simulation.\n\n## Async waiting\n\n```ts\nawait waitUntil(() => ready, { interval: 20, signal, timeout: 1000 });\nawait eventually(() => expect(spy).toHaveBeenCalled(), { signal, timeout: 1000 });\nawait waitForEvent(target, 'ready', { signal, timeout: 1000 });\nawait delay(100, { signal });\n```\n\n| Function | Success condition | Options |\n| -------------------------------------- | ------------------------ | ------------------------------------------ |\n| `waitUntil(predicate, options?)` | Predicate returns `true` | `timeout`, `interval`, `signal` |\n| `eventually(assertion, options?)` | Assertion stops throwing | `timeout`, `interval`, `signal`, `message` |\n| `waitForEvent(target, type, options?)` | Target emits `type` | `timeout`, `signal` |\n| `delay(ms?, options?)` | Timer elapses | `signal` |\n\n\n`waitUntil()`, `eventually()`, and `waitForEvent()` reject with `AssayTimeoutError` when their shared deadline expires. `waitUntil()` and `eventually()` remain bounded while an asynchronous callback is pending. Abort rejects with the signal reason and removes Assay-owned timers and listeners. Callback work itself must cooperate with application cancellation.\n\n`timeout` and delay milliseconds must be finite, non-negative, and no greater than 2,147,483,647 ms; `interval` must be finite, positive, and within the same timer limit. Invalid durations throw `RangeError`. `eventually()` also accepts `message` for diagnostic timeout context.\n\n## Types\n\n```ts\nexport interface QueryScope {\n get<E extends Element = Element>(selector: string): E;\n getByTestId<E extends Element = Element>(testId: string): E;\n getByText<E extends Element = Element>(text: string, selector?: string): E;\n query<E extends Element = Element>(selector: string): E | null;\n queryAll<E extends Element = Element>(selector: string): E[];\n queryAllByTestId<E extends Element = Element>(testId: string): E[];\n queryAllByText<E extends Element = Element>(text: string, selector?: string): E[];\n queryByTestId<E extends Element = Element>(testId: string): E | null;\n queryByText<E extends Element = Element>(text: string, selector?: string): E | null;\n}\n```\n\nScoped query helpers returned by `within(root)`.\n\n```ts\nexport interface WaitOptions {\n /** Polling interval in ms (default: 50). */\n interval?: number;\n /** Cancel the pending wait. */\n signal?: AbortSignal;\n /** Maximum wait time in ms (default: 1000). */\n timeout?: number;\n}\n\nexport interface EventuallyOptions extends WaitOptions {\n /** Context included in the timeout error. */\n message?: string;\n}\n\nexport interface DelayOptions {\n /** Cancel the pending delay. */\n signal?: AbortSignal;\n}\n```\n\n`WaitOptions` configures `waitUntil()`. `EventuallyOptions` configures `eventually()`. `DelayOptions` configures `delay()`.\n\n## Errors\n\n| Error | Meaning |\n| ------------------- | -------------------------------------- |\n| `AssayError` | Base class for Assay-originated errors |\n| `AssayQueryError` | A required `get*` query had no match |\n| `AssayTimeoutError` | A wait operation reached its timeout |\n\nUse `instanceof AssayError` to narrow any value to the Assay error hierarchy.\n",
6
- "usage": "---\ntitle: 'Assay: Usage Guide'\ndescription: Scoped DOM queries, exact event dispatch, and cancellable waiting with @vielzeug/assay.\n---\n\n[[toc]]\n\n## Basic Usage\n\n`within(root)` is Assay's query API. It accepts an element, document fragment, or shadow root and keeps every lookup\nin that scope. Use `get*` when a match is required and nullable `query*` methods when absence is part of the assertion.\n\n```ts\nimport { within } from '@vielzeug/assay';\n\nconst view = within(panel.shadowRoot!);\n\nconst save = view.get<HTMLButtonElement>('button.save');\nconst status = view.queryByText('Saved');\n\nexpect(view.query('.error')).toBeNull();\nexpect(view.getByTestId('summary').textContent).toContain('Complete');\n```\n\n`get()`, `getByText()`, and `getByTestId()` throw `AssayQueryError` with the lookup and a bounded rendering of the\nscoped DOM. This keeps a failed required lookup diagnosable without non-null assertions.\n\nUse the cross-boundary helpers for custom elements:\n\n```ts\nimport { getSlotted, queryAllInShadow, queryPart } from '@vielzeug/assay';\n\nconst trigger = queryPart(menu, 'trigger');\nconst options = queryAllInShadow(menu, '[role=\"option\"]');\nconst footerActions = getSlotted(dialog, 'footer');\n```\n\n## Exact event dispatch\n\nAssay dispatches the platform event class named by each helper. It does not model browser activation, focus\nmanagement, or form defaults. `fireFocus()` and `fireBlur()` use their non-bubbling platform defaults; use\n`focusin`/`focusout` events when testing delegated focus listeners. Use `element.click()` when native activation is\nthe behavior under test; use Assay when testing an event listener or a controlled state transition.\n\n```ts\nimport { fireClick, fireCustom, fireInput, fireKeyDown } from '@vielzeug/assay';\n\ninput.value = 'Ada';\nfireInput(input);\n\nfireKeyDown(input, { key: 'Enter' });\nfireClick(saveButton);\nfireCustom(panel, 'value-change', { detail: { value: 42 } });\n```\n\nEvery helper returns `dispatchEvent()`'s boolean result. `dispatch(target, event)` is available when an existing\nevent instance is the clearest expression of the test.\n\n## Waiting\n\nChoose the waiting primitive by the test's assertion shape:\n\n```ts\nimport { delay, eventually, waitForEvent, waitUntil } from '@vielzeug/assay';\n\nawait waitUntil(() => panel.querySelector('.status')?.textContent === 'Ready');\n\nawait eventually(() => {\n expect(onSave).toHaveBeenCalledOnce();\n}, { message: 'save callback did not run' });\n\nconst completed = waitForEvent<CustomEvent<{ id: string }>>(panel, 'save-complete', {\n signal: AbortSignal.timeout(1000),\n});\nfireClick(saveButton);\nexpect((await completed).detail.id).toBeDefined();\n\nawait delay(100); // real timer dependency, such as a debounce\n```\n\n`waitUntil()` polls a boolean predicate. `eventually()` retries a throwing assertion and can include a diagnostic `message`. Both enforce one hard deadline while asynchronous callbacks are pending. Abort rejects immediately with the signal reason, but cannot stop callback-owned work; forward an application signal into that work when needed.\n\nTimeouts reject with `AssayTimeoutError`. Timeout and delay values must be finite and non-negative; polling intervals must be finite and positive. All durations must fit the platform timer limit of 2,147,483,647 ms.\n\n## Testing custom elements\n\nUse Ore for component mounting and Assay for generic DOM concerns:\n\n```ts\nimport { fireClick } from '@vielzeug/assay';\nimport { html } from '@vielzeug/ore';\nimport { mount } from '@vielzeug/ore/testing';\n\nconst fixture = await mount(\n () => html`\n <button @click=${onSave}>Save</button>\n `,\n);\n\nfireClick(fixture.get('button'));\nawait fixture.flush();\n\nexpect(onSave).toHaveBeenCalledOnce();\n```\n\nRefine's testing entry point contains only Refine-specific assertions and typed mount wrappers. Import Assay helpers\ndirectly instead of routing generic DOM operations through another package.\n\n## Best Practices\n\n- Scope multiple assertions with `within()` rather than repeatedly querying the document.\n- Prefer `get*` for required controls and `query*` for intentional absence checks.\n- Make form state and event boundaries explicit: assign `.value`, then call `fireInput()` or `fireChange()`.\n- Use browser integration tests for focus, disabled activation, pointer capture, and other browser-default behavior.\n- Use `waitForEvent()` for an emitted event, `waitUntil()` for a condition, and `eventually()` for assertions.\n- Add a diagnostic `message` to waits whose final assertion does not identify the tested workflow.\n",
4
+ "index": "---\ntitle: 'Assay: Framework-agnostic DOM testing primitives'\ndescription: Scoped DOM queries, exact event dispatch, and cancellable async waiting for browser tests.\npackage: assay\ncategory: testing\nkeywords: [testing, dom, events, queries, custom-elements]\nrelated: [ore, refine]\nexports:\n [\n within,\n queryInShadow,\n queryAllInShadow,\n queryPart,\n getSlotted,\n dispatch,\n fireBlur,\n fireChange,\n fireClick,\n fireCustom,\n fireFocus,\n fireInput,\n fireKeyDown,\n fireKeyUp,\n fireSubmit,\n waitUntil,\n eventually,\n waitForEvent,\n delay,\n walkFlatTree,\n installFormInternalsPolyfill,\n AssayError,\n AssayQueryError,\n AssayTimeoutError,\n ]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"assay\" />\n\n## Why Assay?\n\nAssay provides focused DOM test primitives that work with vanilla elements, custom elements, and framework-rendered output. It scopes queries, dispatches exact browser event classes, and waits on explicit conditions without imposing a renderer or browser automation stack.\n\n```ts\n// Before\nbutton.dispatchEvent(new MouseEvent('click', { bubbles: true }));\nawait new Promise((resolve) => setTimeout(resolve, 100));\n\n// After\nfireClick(view.get('button.submit'));\nawait waitUntil(() => view.queryByText('Saved') !== null);\n```\n\n| Feature | Assay | Testing Library DOM | Browser automation |\n| ------------------- | -------------------------------------------- | ---------------------------------------- | ---------------------------------------- |\n| Bundle size | <PackageInfo package=\"assay\" type=\"size\" /> | Larger query layer | Browser runtime required |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Scoped DOM queries | `within()` and shadow helpers | Renderer-oriented queries | Manual selectors |\n| Deterministic waits | `waitUntil()` and `waitForEvent()` | Framework-dependent | Full browser timing |\n\n<div class=\"decision-callout\">\n\n**Use Assay when** a DOM unit test needs readable queries, dispatched events, or a bounded async wait without adopting a rendering framework.\n\n**Consider browser integration tests when** correctness depends on browser default actions, focus behavior, pointer capture, layout, or accessibility-tree behavior.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add -D @vielzeug/assay\n```\n\n```sh [npm]\nnpm install -D @vielzeug/assay\n```\n\n```sh [yarn]\nyarn add -D @vielzeug/assay\n```\n\n:::\n\n## Quick Start\n\nScope a test fixture, dispatch an event, and wait for resulting DOM state.\n\n```ts\nimport { fireClick, waitUntil, within } from '@vielzeug/assay';\n\nconst panel = document.createElement('section');\npanel.innerHTML = '<button>Save</button><output></output>';\npanel.querySelector('button')!.addEventListener('click', () => {\n panel.querySelector('output')!.textContent = 'Saved';\n});\n\nconst view = within(panel);\nfireClick(view.get('button'));\nawait waitUntil(() => view.queryByText('Saved') !== null);\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `within(root)` scopes nullable and required DOM queries.\n- `queryInShadow`, `queryPart`, and `getSlotted` cross custom-element boundaries explicitly.\n- `fireClick`, `fireInput`, `fireKeyDown`, and peers dispatch exact synchronous events.\n- `waitUntil`, `eventually`, and `waitForEvent` provide bounded, abortable async waiting.\n- `delay` models explicit cancellable timer scheduling.\n- `walkFlatTree` traverses the flat tree across slot boundaries jsdom's light-DOM queries miss.\n- `installFormInternalsPolyfill` gives jsdom the `ElementInternals` form-association API.\n- `AssayError`, `AssayQueryError`, and `AssayTimeoutError` provide typed failures.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Ore](/ore/): component authoring and test fixtures that pair with Assay DOM helpers.\n- [Refine](/refine/): accessible components with component-specific test assertions.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
+ "api": "---\ntitle: 'Assay: API Reference'\ndescription: API reference for @vielzeug/assay queries, event dispatch, async waiting, and test-environment helpers.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| -------------------------------------------- | ------------------------------------------- | -------------- | ------------------------------------------------ |\n| `within` | Creates scoped query API | Sync | Required `get*` methods throw `AssayQueryError` |\n| `queryInShadow` / `queryPart` / `getSlotted` | Crosses custom-element boundaries | Sync | Open shadow roots are required |\n\n| `fire*` / `dispatch` | Dispatches platform event instances | Sync | Does not reproduce browser default behavior |\n| `waitUntil` / `eventually` / `waitForEvent` | Waits for conditions, assertions, or events | Async | Use a signal or timeout for bounded waits |\n| `delay` | Schedules a cancellable timer | Async | Pass a signal to cancel a pending wait |\n| `walkFlatTree` | Walks the flat tree across slot boundaries | Sync | jsdom-only need: browsers already use the flat tree |\n| `installFormInternalsPolyfill` | Polyfills `ElementInternals` form association for jsdom | Sync | Global patches: only install in suites that need them |\n\n## Package Entry Point\n\n| Import | Purpose |\n| ----------------- | ---------------------------------------------------- |\n| `@vielzeug/assay` | DOM queries, events, wait helpers, test-environment helpers, errors, and types |\n\n## Queries\n\n### `within(root)`\n\nCreates a `QueryScope` for an `Element`, `ShadowRoot`, `Document`, or `DocumentFragment`.\n\n| Method | Returns | Use |\n| --------------------------------- | ----------------- | -------------------------------------------- |\n| `get(selector)` | `Element` | Required CSS match; throws `AssayQueryError` |\n| `query(selector)` | `Element \\| null` | Optional CSS match |\n| `queryAll(selector)` | `Element[]` | All CSS matches |\n| `getByText(text, selector?)` | `Element` | Required exact trimmed-text match |\n| `queryByText(text, selector?)` | `Element \\| null` | Optional exact trimmed-text match |\n| `queryAllByText(text, selector?)` | `Element[]` | All exact trimmed-text matches |\n| `getByTestId(id)` | `Element` | Required `data-testid` match |\n| `queryByTestId(id)` | `Element \\| null` | Optional `data-testid` match |\n| `queryAllByTestId(id)` | `Element[]` | All `data-testid` matches |\n\nText selectors default to `'*'`. Required-query failures include the lookup and a bounded view of the scoped DOM.\n\n### Shadow and slot helpers\n\n| Function | Returns | Description |\n| ---------------------------------- | ----------------- | ---------------------------------------------------- |\n| `queryInShadow(host, selector)` | `Element \\| null` | First match in an open shadow root |\n| `queryAllInShadow(host, selector)` | `Element[]` | All matches in an open shadow root |\n| `queryPart(host, part)` | `Element \\| null` | First shadow element whose `part` token matches |\n| `getSlotted(host, slotName?)` | `Element[]` | Direct light-DOM children in a named or default slot |\n\nThese helpers return `null` or `[]` when there is no shadow root. Dynamic test IDs, parts, and slot names are matched\nas attribute values rather than interpolated into CSS selectors.\n\n## Event dispatch\n\nAll event helpers synchronously return `dispatchEvent()`'s boolean result.\n\n```ts\nimport {\n dispatch,\n fireBlur,\n fireChange,\n fireClick,\n fireCustom,\n fireFocus,\n fireInput,\n fireKeyDown,\n fireKeyUp,\n fireSubmit,\n} from '@vielzeug/assay';\n\nfireClick(button, { clientX: 20 });\nfireInput(input);\nfireKeyDown(input, { key: 'Enter' });\nfireCustom(element, 'item-added', { detail: { id: '42' } });\ndispatch(element, new Event('ready'));\n```\n\n| Function | Event class | Defaults |\n| --------------------------- | --------------- | ------------------------------------------------------ |\n| `fireBlur` / `fireFocus` | `FocusEvent` | Platform defaults (`bubbles: false`) |\n| `fireChange` | `Event` | `bubbles: true` |\n| `fireInput` | `InputEvent` | `bubbles: true` |\n| `fireClick` | `MouseEvent` | `bubbles: true`, `cancelable: true` |\n| `fireKeyDown` / `fireKeyUp` | `KeyboardEvent` | `bubbles: true`, `cancelable: true` |\n| `fireSubmit` | `SubmitEvent` | `bubbles: true`, `cancelable: true` |\n| `fireCustom` | `CustomEvent` | `bubbles: true`, `cancelable: true`, `composed: false` |\n\n`fireCustom(target, type, init?)` dispatches a `CustomEvent` with the given type. Assay intentionally does not\nprovide browser-default or fallback pointer/touch simulation.\n\n## Async waiting\n\n```ts\nawait waitUntil(() => ready, { interval: 20, signal, timeout: 1000 });\nawait eventually(() => expect(spy).toHaveBeenCalled(), { signal, timeout: 1000 });\nawait waitForEvent(target, 'ready', { signal, timeout: 1000 });\nawait delay(100, { signal });\n```\n\n| Function | Success condition | Options |\n| -------------------------------------- | ------------------------ | ------------------------------------------ |\n| `waitUntil(predicate, options?)` | Predicate returns `true` | `timeout`, `interval`, `signal` |\n| `eventually(assertion, options?)` | Assertion stops throwing | `timeout`, `interval`, `signal`, `message` |\n| `waitForEvent(target, type, options?)` | Target emits `type` | `timeout`, `signal` |\n| `delay(ms?, options?)` | Timer elapses | `signal` |\n\n\n`waitUntil()`, `eventually()`, and `waitForEvent()` reject with `AssayTimeoutError` when their shared deadline expires. `waitUntil()` and `eventually()` remain bounded while an asynchronous callback is pending. Abort rejects with the signal reason and removes Assay-owned timers and listeners. Callback work itself must cooperate with application cancellation.\n\n`timeout` and delay milliseconds must be finite, non-negative, and no greater than 2,147,483,647 ms; `interval` must be finite, positive, and within the same timer limit. Invalid durations throw `RangeError`. `eventually()` also accepts `message` for diagnostic timeout context.\n\n## Test-environment helpers\n\n### `walkFlatTree(root, visit)`\n\n```ts\nfunction walkFlatTree(root: Element, visit: (element: HTMLElement) => void): void;\n```\n\nWalks the flat tree below `root`, calling `visit` for each assigned or ordinary descendant (not for `root` itself).\nAt each `<slot>` it expands `assignedElements()` instead of descending into its (always empty, in jsdom) literal\nchildren. jsdom only\nimplements the light DOM tree, so `querySelectorAll()` never crosses a slot into its assigned elements; real\nbrowsers use the flat tree for that. This helper closes the gap for tests that must find form-associated (or\notherwise slotted) elements through shadow boundaries.\n\n```ts\nconst named: HTMLElement[] = [];\n\nwalkFlatTree(form, (el) => {\n if (el.hasAttribute('name')) named.push(el);\n});\n```\n\n### `installFormInternalsPolyfill()`\n\n```ts\nfunction installFormInternalsPolyfill(): () => void;\n```\n\njsdom implements none of the `ElementInternals` form-association API: not `setFormValue`, not `setValidity`, not\nthe `checkValidity`/`reportValidity`/`validity`/`validationMessage` mixin real browsers put on every\n`formAssociated: true` element, not even `FormData` collecting a form-associated element's set value, or\n`<form>.reset()` invoking `formResetCallback()`. Every one of those gaps is exercised by `useField()` from\n`@vielzeug/ore`, so any package testing a form-associated component needs all of them.\n\nCall it from a test setup when the suite tests form-associated components; it returns an `uninstall()` that\nrestores every patched global. The patches are global monkey-patches and skip APIs already implemented by the\nenvironment, so suites without form-associated components shouldn't install them. If `ElementInternals` is absent,\nthe installer is a no-op. A downstream package should\nrely on this instead of hand-rolling its own copy.\n\n```ts\n// vitest.setup.ts\nimport { installFormInternalsPolyfill } from '@vielzeug/assay';\nimport { install } from '@vielzeug/ore/testing';\nimport { afterAll, afterEach } from 'vitest';\n\ninstall(afterEach);\nafterAll(installFormInternalsPolyfill());\n```\n\n## Types\n\n```ts\nexport interface QueryScope {\n get<E extends Element = Element>(selector: string): E;\n getByTestId<E extends Element = Element>(testId: string): E;\n getByText<E extends Element = Element>(text: string, selector?: string): E;\n query<E extends Element = Element>(selector: string): E | null;\n queryAll<E extends Element = Element>(selector: string): E[];\n queryAllByTestId<E extends Element = Element>(testId: string): E[];\n queryAllByText<E extends Element = Element>(text: string, selector?: string): E[];\n queryByTestId<E extends Element = Element>(testId: string): E | null;\n queryByText<E extends Element = Element>(text: string, selector?: string): E | null;\n}\n```\n\nScoped query helpers returned by `within(root)`.\n\n```ts\nexport interface WaitOptions {\n /** Polling interval in ms (default: 50). */\n interval?: number;\n /** Cancel the pending wait. */\n signal?: AbortSignal;\n /** Maximum wait time in ms (default: 1000). */\n timeout?: number;\n}\n\nexport interface EventuallyOptions extends WaitOptions {\n /** Context included in the timeout error. */\n message?: string;\n}\n\nexport interface DelayOptions {\n /** Cancel the pending delay. */\n signal?: AbortSignal;\n}\n```\n\n`WaitOptions` configures `waitUntil()`. `EventuallyOptions` configures `eventually()`. `DelayOptions` configures `delay()`.\n\n## Errors\n\n| Error | Meaning |\n| ------------------- | -------------------------------------- |\n| `AssayError` | Base class for Assay-originated errors |\n| `AssayQueryError` | A required `get*` query had no match |\n| `AssayTimeoutError` | A wait operation reached its timeout |\n\nUse `instanceof AssayError` to narrow any value to the Assay error hierarchy.\n",
6
+ "usage": "---\ntitle: 'Assay: Usage Guide'\ndescription: Scoped DOM queries, exact event dispatch, and cancellable waiting with @vielzeug/assay.\n---\n\n[[toc]]\n\n## Basic Usage\n\n`within(root)` is Assay's query API. It accepts an element, document fragment, or shadow root and keeps every lookup\nin that scope. Use `get*` when a match is required and nullable `query*` methods when absence is part of the assertion.\n\n```ts\nimport { within } from '@vielzeug/assay';\n\nconst view = within(panel.shadowRoot!);\n\nconst save = view.get<HTMLButtonElement>('button.save');\nconst status = view.queryByText('Saved');\n\nexpect(view.query('.error')).toBeNull();\nexpect(view.getByTestId('summary').textContent).toContain('Complete');\n```\n\n`get()`, `getByText()`, and `getByTestId()` throw `AssayQueryError` with the lookup and a bounded rendering of the\nscoped DOM. This keeps a failed required lookup diagnosable without non-null assertions.\n\nUse the cross-boundary helpers for custom elements:\n\n```ts\nimport { getSlotted, queryAllInShadow, queryPart } from '@vielzeug/assay';\n\nconst trigger = queryPart(menu, 'trigger');\nconst options = queryAllInShadow(menu, '[role=\"option\"]');\nconst footerActions = getSlotted(dialog, 'footer');\n```\n\n## Exact event dispatch\n\nAssay dispatches the platform event class named by each helper. It does not model browser activation, focus\nmanagement, or form defaults. `fireFocus()` and `fireBlur()` use their non-bubbling platform defaults; use\n`focusin`/`focusout` events when testing delegated focus listeners. Use `element.click()` when native activation is\nthe behavior under test; use Assay when testing an event listener or a controlled state transition.\n\n```ts\nimport { fireClick, fireCustom, fireInput, fireKeyDown } from '@vielzeug/assay';\n\ninput.value = 'Ada';\nfireInput(input);\n\nfireKeyDown(input, { key: 'Enter' });\nfireClick(saveButton);\nfireCustom(panel, 'value-change', { detail: { value: 42 } });\n```\n\nEvery helper returns `dispatchEvent()`'s boolean result. `dispatch(target, event)` is available when an existing\nevent instance is the clearest expression of the test.\n\n## Waiting\n\nChoose the waiting primitive by the test's assertion shape:\n\n```ts\nimport { delay, eventually, waitForEvent, waitUntil } from '@vielzeug/assay';\n\nawait waitUntil(() => panel.querySelector('.status')?.textContent === 'Ready');\n\nawait eventually(() => {\n expect(onSave).toHaveBeenCalledOnce();\n}, { message: 'save callback did not run' });\n\nconst completed = waitForEvent<CustomEvent<{ id: string }>>(panel, 'save-complete', {\n signal: AbortSignal.timeout(1000),\n});\nfireClick(saveButton);\nexpect((await completed).detail.id).toBeDefined();\n\nawait delay(100); // real timer dependency, such as a debounce\n```\n\n`waitUntil()` polls a boolean predicate. `eventually()` retries a throwing assertion and can include a diagnostic `message`. Both enforce one hard deadline while asynchronous callbacks are pending. Abort rejects immediately with the signal reason, but cannot stop callback-owned work; forward an application signal into that work when needed.\n\nTimeouts reject with `AssayTimeoutError`. Timeout and delay values must be finite and non-negative; polling intervals must be finite and positive. All durations must fit the platform timer limit of 2,147,483,647 ms.\n\n## Testing custom elements\n\nUse Ore for component mounting and Assay for generic DOM concerns:\n\n```ts\nimport { fireClick } from '@vielzeug/assay';\nimport { html } from '@vielzeug/ore';\nimport { mount } from '@vielzeug/ore/testing';\n\nconst fixture = await mount(\n () => html`\n <button @click=${onSave}>Save</button>\n `,\n);\n\nfireClick(fixture.get('button'));\nawait fixture.flush();\n\nexpect(onSave).toHaveBeenCalledOnce();\n```\n\nRefine's testing entry point contains only Refine-specific assertions and typed mount wrappers. Import Assay helpers\ndirectly instead of routing generic DOM operations through another package.\n\nWhen a suite tests a form-associated custom element (one built on `useField()` from `@vielzeug/ore`), install the\nform-internals polyfill in the setup file so jsdom gains the `ElementInternals` API it lacks:\n\n```ts\n// vitest.setup.ts\nimport { installFormInternalsPolyfill } from '@vielzeug/assay';\nimport { install } from '@vielzeug/ore/testing';\nimport { afterAll, afterEach } from 'vitest';\n\ninstall(afterEach);\nafterAll(installFormInternalsPolyfill());\n```\n\n## Traverse slotted content in jsdom\n\nUse `walkFlatTree()` when a test must visit descendants assigned through slots. It expands each slot into its assigned\nelements, including nested slots, instead of relying on jsdom's light-DOM-only `querySelectorAll()`.\n\n```ts\nimport { walkFlatTree } from '@vielzeug/assay';\n\nconst named: HTMLElement[] = [];\nwalkFlatTree(form, (element) => {\n if (element.hasAttribute('name')) named.push(element);\n});\n```\n\nThe callback receives descendants in the flat tree, not the root element or slot elements themselves. Browsers already\nuse the flat tree for DOM algorithms; this helper is for test code that needs equivalent traversal in jsdom.\n\n## Best Practices\n\n- Scope multiple assertions with `within()` rather than repeatedly querying the document.\n- Prefer `get*` for required controls and `query*` for intentional absence checks.\n- Make form state and event boundaries explicit: assign `.value`, then call `fireInput()` or `fireChange()`.\n- Use browser integration tests for focus, disabled activation, pointer capture, and other browser-default behavior.\n- Use `waitForEvent()` for an emitted event, `waitUntil()` for a condition, and `eventually()` for assertions.\n- Add a diagnostic `message` to waits whose final assertion does not identify the tested workflow.\n",
7
7
  "examples": "---\ntitle: 'Assay: Examples'\ndescription: Practical examples and recipes for assay.\n---\n\n## Examples\n\n- [Custom Element Interaction](./examples/custom-element-interaction.md)\n- [Waiting for Async Updates](./examples/waiting-for-async-updates.md)\n"
8
8
  },
9
9
  "examples": [],
10
10
  "typeSignatures": {
11
+ "walkFlatTree": "export { walkFlatTree } from './dom';",
11
12
  "AssayError": "export { AssayError, AssayQueryError, AssayTimeoutError } from './errors';",
12
13
  "AssayQueryError": "export { AssayError, AssayQueryError, AssayTimeoutError } from './errors';",
13
14
  "AssayTimeoutError": "export { AssayError, AssayQueryError, AssayTimeoutError } from './errors';",
@@ -21,6 +22,7 @@
21
22
  "fireKeyDown": "export {\n dispatch,\n fireBlur,\n fireChange,\n fireClick,\n fireCustom,\n fireFocus,\n fireInput,\n fireKeyDown,\n fireKeyUp,\n fireSubmit,\n} from './events';",
22
23
  "fireKeyUp": "export {\n dispatch,\n fireBlur,\n fireChange,\n fireClick,\n fireCustom,\n fireFocus,\n fireInput,\n fireKeyDown,\n fireKeyUp,\n fireSubmit,\n} from './events';",
23
24
  "fireSubmit": "export {\n dispatch,\n fireBlur,\n fireChange,\n fireClick,\n fireCustom,\n fireFocus,\n fireInput,\n fireKeyDown,\n fireKeyUp,\n fireSubmit,\n} from './events';",
25
+ "installFormInternalsPolyfill": "export { installFormInternalsPolyfill } from './form-internals';",
24
26
  "getSlotted": "export { getSlotted, type QueryScope, queryAllInShadow, queryInShadow, queryPart, within } from './query';",
25
27
  "QueryScope": "export { getSlotted, type QueryScope, queryAllInShadow, queryInShadow, queryPart, within } from './query';",
26
28
  "queryAllInShadow": "export { getSlotted, type QueryScope, queryAllInShadow, queryInShadow, queryPart, within } from './query';",