1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-20 10:54:44 +00:00

2 Commits

377 changed files with 6328 additions and 28585 deletions

View File

@@ -1,409 +0,0 @@
---
name: monorepo
description: "Manage the @robonen/tools monorepo. Use when: installing dependencies, creating new packages, linting, building, testing, publishing, or scaffolding workspace packages. Covers pnpm catalogs, tsdown builds, oxlint presets, vitest projects, JSR/NPM publishing."
---
# Monorepo Management
## Overview
This is a pnpm workspace monorepo (`@robonen/tools`) with shared configs, strict dependency management via catalogs, and automated publishing.
## Workspace Layout
| Directory | Purpose | Examples |
|-----------|---------|---------|
| `configs/*` | Shared tooling configs | `oxlint`, `tsconfig`, `tsdown` |
| `core/*` | Platform-agnostic TS libraries | `stdlib`, `platform`, `encoding` |
| `vue/*` | Vue 3 packages | `primitives`, `toolkit` |
| `docs` | Nuxt 4 documentation site | — |
| `infra/*` | Infrastructure configs | `renovate` |
## Installing Dependencies
**Always use pnpm. Never use npm or yarn.**
### Add a dependency to a specific package
```bash
# Runtime dependency
pnpm -C <package-path> add <dep-name>
# Dev dependency
pnpm -C <package-path> add -D <dep-name>
```
Examples:
```bash
pnpm -C core/stdlib add -D oxlint
pnpm -C vue/primitives add vue
```
### Use catalogs for shared versions
Versions shared across multiple packages MUST use the pnpm catalog system. The catalog is defined in `pnpm-workspace.yaml`:
```yaml
catalog:
vitest: ^4.0.18
tsdown: ^0.21.0
oxlint: ^1.2.0
vue: ^3.5.28
# ... etc
```
In `package.json`, reference catalog versions with the `catalog:` protocol:
```json
{
"devDependencies": {
"vitest": "catalog:",
"oxlint": "catalog:"
}
}
```
**When to add to catalog:** If the dependency is used in 2+ packages, add it to `pnpm-workspace.yaml` under `catalog:` and use `catalog:` in each `package.json`.
**When NOT to use catalog:** Package-specific dependencies used in only one package (e.g., `citty` in root).
### Internal workspace dependencies
Reference sibling packages with the workspace protocol:
```json
{
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*"
}
}
```
Nearly every package depends on these three shared config packages.
### After installing
Always run `pnpm install` at the root after editing `pnpm-workspace.yaml` or any `package.json` manually.
## Creating a New Package
> **Note:** The existing `bin/cli.ts` (`pnpm create`) is outdated — it generates Vite configs instead of tsdown and lacks oxlint/vitest setup. Follow the manual steps below instead.
### 1. Create the directory
Choose the correct parent based on package type:
- `core/<name>` — Platform-agnostic TypeScript library
- `vue/<name>` — Vue 3 library (needs jsdom, vue deps)
- `configs/<name>` — Shared configuration package
### 2. Create `package.json`
```json
{
"name": "@robonen/<name>",
"version": "0.0.1",
"license": "Apache-2.0",
"description": "",
"packageManager": "pnpm@10.29.3",
"engines": { "node": ">=24.13.1" },
"type": "module",
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"lint:check": "oxlint -c oxlint.config.ts",
"lint:fix": "oxlint -c oxlint.config.ts --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:",
"oxlint": "catalog:",
"tsdown": "catalog:"
}
}
```
For Vue packages, also add:
```json
{
"dependencies": {
"vue": "catalog:",
"@vue/shared": "catalog:"
"@stylistic/eslint-plugin": "catalog:",
},
"devDependencies": {
"@vue/test-utils": "catalog:"
}
}
```
For packages with sub-path exports (like `core/platform`):
```json
{
"exports": {
"./browsers": {
"types": "./dist/browsers.d.ts",
"import": "./dist/browsers.js",
"require": "./dist/browsers.cjs"
}
}
}
```
### 3. Create `tsconfig.json`
Node/core package:
```json
{
"extends": "@robonen/tsconfig/tsconfig.json"
}
```
Vue package (needs DOM types and path aliases):
```json
{
"extends": "@robonen/tsconfig/tsconfig.json",
"compilerOptions": {
"lib": ["DOM"],
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
}
}
```
### 4. Create `tsdown.config.ts`
Standard:
```typescript
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
entry: ['src/index.ts'],
});
```
Vue package (externalize vue, bundle internal deps):
```typescript
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
entry: ['src/index.ts'],
deps: {
neverBundle: ['vue'],
alwaysBundle: [/^@robonen\//, '@vue/shared'],
},
inputOptions: {
resolve: {
alias: { '@vue/shared': '@vue/shared/dist/shared.esm-bundler.js' },
},
},
define: { __DEV__: 'false' },
});
```
### 5. Create `oxlint.config.ts`
Standard (node packages):
```typescript
import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, imports, stylistic));
```
### 6. Create `vitest.config.ts`
Node package:
```typescript
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});
```
Vue package:
```typescript
import { defineConfig } from 'vitest/config';
import { resolve } from 'node:path';
export default defineConfig({
define: { __DEV__: 'true' },
resolve: {
alias: { '@': resolve(__dirname, './src') },
},
test: {
environment: 'jsdom',
},
});
```
### 7. Create `jsr.json` (for publishable packages)
```json
{
"$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@robonen/<name>",
"version": "0.0.1",
"exports": "./src/index.ts"
}
```
### 8. Create source files
```bash
mkdir -p src
touch src/index.ts
```
### 9. Register with vitest projects
Add the new `vitest.config.ts` path to the root `vitest.config.ts` `projects` array.
### 10. Install dependencies
```bash
pnpm install
```
### 11. Verify
```bash
pnpm -C <package-path> build
pnpm -C <package-path> lint
pnpm -C <package-path> test
```
## Linting
Uses **oxlint** (not ESLint) with composable presets from `@robonen/oxlint`.
### Run linting
```bash
# Check lint errors (no auto-fix)
pnpm -C <package-path> lint:check
# Auto-fix lint errors
pnpm -C <package-path> lint:fix
# Check all packages
pnpm lint:check
# Fix all packages
pnpm lint:fix
```
### Available presets
| Preset | Purpose |
|--------|---------|
| `base` | ESLint core + Oxc + Unicorn rules |
| `typescript` | TypeScript rules (via overrides on `*.ts` files) |
| `imports` | Import ordering, cycles, duplicates |
| `stylistic` | Code style via `@stylistic/eslint-plugin` |
| `vue` | Vue 3 Composition API rules |
| `vitest` | Test file rules |
| `node` | Node.js-specific rules |
Compose presets in `oxlint.config.ts`:
```typescript
import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, imports));
**Recommended:** Include `stylistic` preset for code formatting:
```typescript
import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, imports, stylistic));
```
When using `stylistic`, add `@stylistic/eslint-plugin` to devDependencies:
```json
{
"devDependencies": {
"@stylistic/eslint-plugin": "catalog:"
}
}
```
```
## Building
```bash
# Build a specific package
pnpm -C <package-path> build
# Build all packages
pnpm build
```
All packages use `tsdown` with shared config from `@robonen/tsdown`. Output: ESM (`.js`/`.mjs`) + CJS (`.cjs`) + type declarations (`.d.ts`). Every bundle includes an Apache-2.0 license banner.
## Testing
```bash
# Run tests in a specific package
pnpm -C <package-path> test
# Run all tests (via vitest projects)
pnpm test
# Interactive test UI
pnpm test:ui
# Watch mode in a package
pnpm -C <package-path> dev
```
Uses **vitest** with project-based configuration. Root `vitest.config.ts` lists all package vitest configs as projects.
## Publishing
Publishing is **automated** via GitHub Actions on push to `master`:
1. CI builds and tests all packages
2. Publish workflow compares each package's `version` in `package.json` against npm registry
3. If version changed → `pnpm publish --access public`
**To publish:** Bump the `version` in `package.json` (and `jsr.json` if present), then merge to `master`.
**NPM scope:** All packages publish under `@robonen/`.
## Documentation
```bash
pnpm docs:dev # Start Nuxt dev server
pnpm docs:generate # Generate static site
pnpm docs:preview # Preview generated site
pnpm docs:extract # Extract API docs
```
## Key Conventions
- **ESM-first:** All packages use `"type": "module"`
- **Strict TypeScript:** `strict: true`, `noUncheckedIndexedAccess: true`, `verbatimModuleSyntax: true`
- **License:** Apache-2.0 for all published packages
- **Node version:** ≥24.13.1 (set in `engines` and CI)
- **pnpm version:** Pinned in `packageManager` field
- **No barrel re-exports of entire modules** — export explicitly
- **`__DEV__` global:** `false` in builds, `true` in tests (Vue packages only)

View File

@@ -1,9 +1,7 @@
name: CI name: CI
on: on:
pull_request: - pull_request
branches:
- master
env: env:
NODE_VERSION: 22.x NODE_VERSION: 22.x
@@ -16,14 +14,14 @@ jobs:
contents: read contents: read
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:
run_install: false run_install: false
- uses: actions/setup-node@v6 - uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: pnpm cache: pnpm
@@ -31,11 +29,5 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Lint
run: pnpm lint:check
- name: Test - name: Test
run: pnpm test run: pnpm all:build && pnpm all:test

View File

@@ -1,78 +0,0 @@
name: Publish to NPM
on:
push:
branches:
- master
env:
NODE_VERSION: 22.x
jobs:
check-and-publish:
name: Check version changes and publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build & Test
run: pnpm build && pnpm test
- name: Check for version changes and publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
# Find all package.json files (excluding node_modules)
PACKAGE_FILES=$(find . -path "*/package.json" -not -path "*/node_modules/*")
for file in $PACKAGE_FILES; do
PACKAGE_DIR=$(dirname $file)
echo "Checking $PACKAGE_DIR for version changes..."
# Get package details
PACKAGE_NAME=$(node -p "require('$file').name")
CURRENT_VERSION=$(node -p "require('$file').version")
IS_PRIVATE=$(node -p "require('$file').private || false")
# Skip private packages
if [ "$IS_PRIVATE" == "true" ]; then
echo "Skipping private package $PACKAGE_NAME"
continue
fi
# Skip root package
if [ "$PACKAGE_DIR" == "." ]; then
echo "Skipping root package"
continue
fi
# Check if package exists on npm
NPM_VERSION=$(npm view $PACKAGE_NAME version 2>/dev/null || echo "0.0.0")
# Compare versions
if [ "$CURRENT_VERSION" != "$NPM_VERSION" ]; then
echo "Version changed for $PACKAGE_NAME: $NPM_VERSION → $CURRENT_VERSION"
echo "Publishing $PACKAGE_NAME@$CURRENT_VERSION"
cd $PACKAGE_DIR
pnpm publish --access public --no-git-checks
cd -
else
echo "No version change detected for $PACKAGE_NAME"
fi
done

57
CHANGELOG.md Normal file
View File

@@ -0,0 +1,57 @@
# Changelog
## v0.0.1
### 🚀 Enhancements
- **repo:** Cli tool, base tscofig ([3fcc42e](https://github.com/robonen/tools/commit/3fcc42e))
- **repo:** Drop node_modules ([7dba5ac](https://github.com/robonen/tools/commit/7dba5ac))
- **repo:** Global gitignore ([00c2736](https://github.com/robonen/tools/commit/00c2736))
- **packages/tsconfig:** Readme ([afa15cd](https://github.com/robonen/tools/commit/afa15cd))
- **docs:** Add auto generated doc based on readme ([3960f86](https://github.com/robonen/tools/commit/3960f86))
- **packages/stdlib:** Create stdlib ([c985b95](https://github.com/robonen/tools/commit/c985b95))
- **packages/stdlib:** Base vite config ([0434725](https://github.com/robonen/tools/commit/0434725))
- **packages/stdlib:** Math/clamp util ([8515bff](https://github.com/robonen/tools/commit/8515bff))
- **packages/stdlib:** MapRange util ([d8a9a62](https://github.com/robonen/tools/commit/d8a9a62))
- **packages/stdlib:** Levenshtein distance util ([0022153](https://github.com/robonen/tools/commit/0022153))
- **packages/stdlib:** Add trigram distance utill ([5045852](https://github.com/robonen/tools/commit/5045852))
### 🩹 Fixes
- **repo:** Workspaces -> workspace ([80b87d7](https://github.com/robonen/tools/commit/80b87d7))
### 💅 Refactors
- **repo:** Cleanup ([bc2ebfc](https://github.com/robonen/tools/commit/bc2ebfc))
- **packages/tsconfig:** Readme remove extra spaces ([565e7d8](https://github.com/robonen/tools/commit/565e7d8))
- **docs:** Drop docs cache and dist ([03f755d](https://github.com/robonen/tools/commit/03f755d))
- **repo:** Add vitepress to gitignore ([cf71b8e](https://github.com/robonen/tools/commit/cf71b8e))
- **repo:** Add pathe lib to cli tool ([d7a2d15](https://github.com/robonen/tools/commit/d7a2d15))
- **packages/tsconfig:** Add description and publishConfig ([37d25bf](https://github.com/robonen/tools/commit/37d25bf))
- **repo:** Change cli generated exports in package.json ([a5d33ea](https://github.com/robonen/tools/commit/a5d33ea))
- **packages/tsconfig:** Disable declaration and source maps ([3f1d16b](https://github.com/robonen/tools/commit/3f1d16b))
- **packages/stdlib:** Add doc, update tests ([5280ace](https://github.com/robonen/tools/commit/5280ace))
- **packages/stdlib:** Add comments for math utils ([65ba312](https://github.com/robonen/tools/commit/65ba312))
- **packages/stdlib:** Levensthein fn replate to module export ([92721b3](https://github.com/robonen/tools/commit/92721b3))
- **packages/stdlib:** Rename arguments to left and right ([7d8f5be](https://github.com/robonen/tools/commit/7d8f5be))
- **packages/stdlib:** Reformat test files ([9031430](https://github.com/robonen/tools/commit/9031430))
- **packages/tsconfig:** Add exclude for .output and coverage folders ([769476d](https://github.com/robonen/tools/commit/769476d))
- **packages/stdlib:** Remove private from package.json ([5dadb50](https://github.com/robonen/tools/commit/5dadb50))
### 🏡 Chore
- **packages/stdlib:** Add bench script, add vscode workspace ([e9b8b0c](https://github.com/robonen/tools/commit/e9b8b0c))
- **release:** V0.0.1 ([725b73d](https://github.com/robonen/tools/commit/725b73d))
- **packages/stdlib:** Set 0.0.1 version ([c65113e](https://github.com/robonen/tools/commit/c65113e))
- **release:** V0.0.1 ([f77716a](https://github.com/robonen/tools/commit/f77716a))
### ✅ Tests
- **packages/stdlib:** Trigram distance tests ([4c10d38](https://github.com/robonen/tools/commit/4c10d38))
### ❤️ Contributors
- Robonen ([@robonen](http://github.com/robonen))

2
apps/vhs/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
bin/**
!bin/.gitkeep

1
apps/vhs/README.md Normal file
View File

@@ -0,0 +1 @@
# @robonen/vhs

0
apps/vhs/bin/.gitkeep Normal file
View File

5
apps/vhs/jsr.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "@robonen/vhs",
"version": "0.0.0",
"exports": "./src/index.ts"
}

26
apps/vhs/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "@robonen/vhs",
"private": true,
"version": "0.0.1",
"license": "UNLICENSED",
"description": "",
"keywords": [],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "./apps/vhs"
},
"packageManager": "pnpm@9.15.4",
"engines": {
"bun": ">=1.1.27"
},
"type": "module",
"scripts": {
"start": "bun run src/index.ts"
},
"devDependencies": {
"@robonen/tsconfig": "workspace:*",
"@types/bun": "^1.1.17"
}
}

36
apps/vhs/src/index.ts Normal file
View File

@@ -0,0 +1,36 @@
import { version } from '../package.json';
import { resolve } from 'path';
import { $, Glob } from 'bun';
async function ffmpegMergeAndTranscodeAvi(files: Set<string>) {
const ffmpeg = resolve('bin/ffmpeg');
const output = resolve('output.mp4');
const input = Array.from(files).toSorted((a, b) => a.localeCompare(b)).join('|');
const shell = $`${ffmpeg} -i "concat:${input}" -stats -c:v libx264 -crf 23 -preset veryfast -c:a aac ${output}`;
for await (const line of shell.lines()) {
console.log(line);
}
}
const path = Bun.argv[2];
if (!path) {
console.error('Please provide a path to a file or directory');
process.exit(1);
}
console.info(`Welcome to VHS v${version} 📼`);
console.info(`Scanning ${path}...`);
const glob = new Glob(resolve(path));
const files = new Set<string>();
for await (const file of glob.scan({ followSymlinks: false })) {
files.add(file);
}
console.info(`Found ${files.size} files`);
console.info(await ffmpegMergeAndTranscodeAvi(files));

View File

@@ -1,53 +1,16 @@
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import { defineCommand, runMain } from 'citty'; import { defineCommand, runMain } from 'citty';
import { resolve } from 'node:path'; import { resolve } from 'pathe';
import { splitByCase } from 'scule'; import { splitByCase } from 'scule';
async function getLatestPackageVersion(packageName: string) { const PACKAGE_MANAGER = 'pnpm@9.11.0';
try { const NODE_VERSION = '>=20.17.0';
const response = await fetch(`https://registry.npmjs.org/${packageName}`); const VITE_VERSION = '^5.4.8';
const data = await response.json(); const VITE_DTS_VERSION = '^4.2.2';
const PATHE_VERSION = '^1.1.2'
if (!response.ok) {
console.warn(`Failed to fetch latest version for ${packageName}, using fallback`);
return null;
}
const latestVersion = data['dist-tags']?.latest as string | undefined;
if (!latestVersion)
return null;
return {
version: latestVersion,
versionRange: `^${latestVersion}`,
};
} catch (error) {
console.warn(`Error fetching version for ${packageName}: ${error.message}`);
return null;
}
}
const PACKAGE_MANAGER_DEFAULT = 'pnpm@10.10.0';
const NODE_VERSION = '>=22.15.0';
const VITE_VERSION_DEFAULT = '^5.4.8';
const VITE_DTS_VERSION_DEFAULT = '^4.2.2';
const PATHE_VERSION_DEFAULT = '^1.1.2';
const DEFAULT_DIR = 'packages'; const DEFAULT_DIR = 'packages';
const generatePackageJson = async (name: string, path: string, hasVite: boolean) => { const generatePackageJson = (name: string, path: string, hasVite: boolean) => {
const [
packageManagerVersion,
viteVersion,
viteDtsVersion,
patheVersion,
] = await Promise.all([
getLatestPackageVersion('pnpm').then(v => v?.version || PACKAGE_MANAGER_DEFAULT),
hasVite ? getLatestPackageVersion('vite').then(v => v?.versionRange || VITE_VERSION_DEFAULT) : VITE_VERSION_DEFAULT,
hasVite ? getLatestPackageVersion('vite-plugin-dts').then(v => v?.versionRange || VITE_DTS_VERSION_DEFAULT) : VITE_DTS_VERSION_DEFAULT,
hasVite ? getLatestPackageVersion('pathe').then(v => v?.versionRange || PATHE_VERSION_DEFAULT) : PATHE_VERSION_DEFAULT,
]);
const data = { const data = {
name, name,
private: true, private: true,
@@ -61,17 +24,20 @@ const generatePackageJson = async (name: string, path: string, hasVite: boolean)
url: 'git+https://github.com/robonen/tools.git', url: 'git+https://github.com/robonen/tools.git',
directory: path, directory: path,
}, },
packageManager: `pnpm@${packageManagerVersion}`, packageManager: PACKAGE_MANAGER,
engines: { engines: {
node: NODE_VERSION, node: NODE_VERSION,
}, },
type: 'module', type: 'module',
files: ['dist'], files: ['dist'],
main: './dist/index.umd.js',
module: './dist/index.js',
types: './dist/index.d.ts',
exports: { exports: {
'.': { '.': {
types: './dist/index.d.ts',
import: './dist/index.js', import: './dist/index.js',
require: './dist/index.umd.js', require: './dist/index.umd.js',
types: './dist/index.d.ts',
}, },
}, },
scripts: { scripts: {
@@ -85,9 +51,9 @@ const generatePackageJson = async (name: string, path: string, hasVite: boolean)
devDependencies: { devDependencies: {
'@robonen/tsconfig': 'workspace:*', '@robonen/tsconfig': 'workspace:*',
...(hasVite && { ...(hasVite && {
vite: viteVersion, vite: VITE_VERSION,
'vite-plugin-dts': viteDtsVersion, 'vite-plugin-dts': VITE_DTS_VERSION,
pathe: patheVersion, pathe: PATHE_VERSION,
}), }),
}, },
}; };
@@ -166,15 +132,14 @@ const createCommand = defineCommand({
await mkdir(resolvedPath, { recursive: true }); await mkdir(resolvedPath, { recursive: true });
const packageJson = await generatePackageJson(args.name, path, hasVite); writeFile(`${resolvedPath}/package.json`, generatePackageJson(args.name, path, hasVite));
await writeFile(`${resolvedPath}/package.json`, packageJson); writeFile(`${resolvedPath}/jsr.json`, generateJsrJson(args.name));
await writeFile(`${resolvedPath}/jsr.json`, generateJsrJson(args.name)); writeFile(`${resolvedPath}/tsconfig.json`, generateTsConfig());
await writeFile(`${resolvedPath}/tsconfig.json`, generateTsConfig()); writeFile(`${resolvedPath}/README.md`, generateReadme(args.name));
await writeFile(`${resolvedPath}/README.md`, generateReadme(args.name));
if (hasVite) { if (hasVite) {
await mkdir(`${resolvedPath}/src`, { recursive: true }); mkdir(`${resolvedPath}/src`, { recursive: true });
await writeFile(`${resolvedPath}/vite.config.ts`, generateViteConfig()); writeFile(`${resolvedPath}/vite.config.ts`, generateViteConfig());
} }
console.log(`Project created successfully`); console.log(`Project created successfully`);

View File

@@ -1,67 +0,0 @@
# @robonen/oxlint
Composable [oxlint](https://oxc.rs/docs/guide/usage/linter.html) configuration presets.
## Install
```bash
pnpm install -D @robonen/oxlint oxlint
```
## Usage
Create `oxlint.config.ts` in your project root:
```ts
import { defineConfig } from 'oxlint';
import { compose, base, typescript, vue, vitest, imports } from '@robonen/oxlint';
export default defineConfig(
compose(base, typescript, vue, vitest, imports),
);
```
Append custom rules after presets to override them:
```ts
compose(base, typescript, {
rules: { 'eslint/no-console': 'off' },
ignorePatterns: ['dist'],
});
```
## Presets
| Preset | Description |
| ------------ | -------------------------------------------------- |
| `base` | Core eslint, oxc, unicorn rules |
| `typescript` | TypeScript-specific rules (via overrides) |
| `vue` | Vue 3 Composition API / `<script setup>` rules |
| `vitest` | Test file rules (via overrides) |
| `imports` | Import rules (cycles, duplicates, ordering) |
| `node` | Node.js-specific rules |
## Rules Documentation
Подробные описания правил и `good/bad` примеры вынесены в отдельную директорию:
- `rules/README.md`
- `rules/base.md`
- `rules/typescript.md`
- `rules/vue.md`
- `rules/vitest.md`
- `rules/imports.md`
- `rules/node.md`
- `rules/stylistic.md`
## API
### `compose(...configs: OxlintConfig[]): OxlintConfig`
Merges multiple configs into one:
- **plugins** — union (deduplicated)
- **rules / categories** — last wins
- **overrides / ignorePatterns** — concatenated
- **env / globals** — shallow merge
- **settings** — deep merge

View File

@@ -1,4 +0,0 @@
import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, imports, stylistic));

View File

@@ -1,61 +0,0 @@
{
"name": "@robonen/oxlint",
"version": "0.0.2",
"license": "Apache-2.0",
"description": "Composable oxlint configuration presets",
"keywords": [
"oxlint",
"oxc",
"linter",
"config",
"presets"
],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "configs/oxlint"
},
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"scripts": {
"lint:check": "oxlint -c oxlint.config.ts",
"lint:fix": "oxlint -c oxlint.config.ts --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:",
"oxlint": "catalog:",
"tsdown": "catalog:"
},
"peerDependencies": {
"oxlint": ">=1.0.0",
"@stylistic/eslint-plugin": ">=4.0.0"
},
"peerDependenciesMeta": {
"@stylistic/eslint-plugin": {
"optional": true
}
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -1,21 +0,0 @@
# Rules Reference
Документация по preset-ам `@robonen/oxlint`: что включает каждый preset и какие правила чаще всего влияют на код.
## Presets
- [base](./base.md)
- [typescript](./typescript.md)
- [vue](./vue.md)
- [vitest](./vitest.md)
- [imports](./imports.md)
- [node](./node.md)
- [stylistic](./stylistic.md)
## Как читать
- `Purpose` — зачем preset подключать.
- `Key Rules` — ключевые правила из preset-а (не полный dump).
- `Examples` — минимальные `good/bad` примеры.
Для точного источника правил см. файлы в `configs/oxlint/src/presets/*.ts`.

View File

@@ -1,34 +0,0 @@
# base preset
## Purpose
Базовый quality-профиль для JS/TS-проектов: корректность, анти-паттерны, безопасные дефолты.
## Key Rules
- `eslint/eqeqeq`: запрещает `==`, требует `===`.
- `eslint/no-unused-vars`: запрещает неиспользуемые переменные (кроме `_name`).
- `eslint/no-eval`, `eslint/no-var`, `eslint/prefer-const`.
- `unicorn/prefer-node-protocol`: требует `node:` для built-in модулей.
- `unicorn/no-thenable`: запрещает thenable-объекты.
- `oxc/*` correctness правила (`bad-comparison-sequence`, `missing-throw` и др.).
## Examples
```ts
// ✅ Good
import { readFile } from 'node:fs/promises';
const id = 42;
if (id === 42) {
throw new Error('unexpected');
}
// ❌ Bad
import { readFile } from 'fs/promises';
var id = 42;
if (id == '42') {
throw 'unexpected';
}
```

View File

@@ -1,27 +0,0 @@
# imports preset
## Purpose
Чистые границы модулей и предсказуемые импорты.
## Key Rules
- `import/no-duplicates`.
- `import/no-self-import`.
- `import/no-cycle` (warn).
- `import/no-mutable-exports`.
- `import/consistent-type-specifier-style`: `prefer-top-level`.
## Examples
```ts
// ✅ Good
import type { User } from './types';
import { getUser } from './service';
// ❌ Bad
import { getUser } from './service';
import { getUser as getUser2 } from './service';
export let state = 0;
```

View File

@@ -1,22 +0,0 @@
# node preset
## Purpose
Node.js-правила и окружение `env.node = true`.
## Key Rules
- `node/no-exports-assign`: запрещает перезапись `exports`.
- `node/no-new-require`: запрещает `new require(...)`.
## Examples
```ts
// ✅ Good
module.exports = { run };
const mod = require('./mod');
// ❌ Bad
exports = { run };
const bad = new require('./mod');
```

View File

@@ -1,51 +0,0 @@
# stylistic preset
## Purpose
Форматирование через `@stylistic/eslint-plugin` (отступы, пробелы, скобки, переносы, TS/JSX-стиль).
## Defaults
- `indent: 2`
- `quotes: single`
- `semi: always`
- `braceStyle: stroustrup`
- `commaDangle: always-multiline`
- `arrowParens: as-needed`
## Key Rules
- `@stylistic/indent`, `@stylistic/no-tabs`.
- `@stylistic/quotes`, `@stylistic/semi`.
- `@stylistic/object-curly-spacing`, `@stylistic/comma-spacing`.
- `@stylistic/arrow-spacing`, `@stylistic/space-before-function-paren`.
- `@stylistic/max-statements-per-line`.
- `@stylistic/no-mixed-operators`.
- `@stylistic/member-delimiter-style` (TS).
## Examples
```ts
// ✅ Good
type User = {
id: string;
role: 'admin' | 'user';
};
const value = condition
? 'yes'
: 'no';
const sum = (a: number, b: number) => a + b;
// ❌ Bad
type User = {
id: string
role: 'admin' | 'user'
}
const value = condition ? 'yes' : 'no'; const x = 1;
const sum=(a:number,b:number)=>{ return a+b };
```
Полный список правил и их настройки см. в `src/presets/stylistic.ts`.

View File

@@ -1,33 +0,0 @@
# typescript preset
## Purpose
TypeScript-правила для `.ts/.tsx/.mts/.cts` через `overrides`.
## Key Rules
- `typescript/consistent-type-imports`: выносит типы в `import type`.
- `typescript/no-import-type-side-effects`: запрещает сайд-эффекты в type import.
- `typescript/prefer-as-const`.
- `typescript/no-namespace`, `typescript/triple-slash-reference`.
- `typescript/no-wrapper-object-types`: запрещает `String`, `Number`, `Boolean`.
## Examples
```ts
// ✅ Good
import type { User } from './types';
const status = 'ok' as const;
interface Payload {
value: string;
}
// ❌ Bad
import { User } from './types';
type Boxed = String;
namespace Legacy {
export const x = 1;
}
```

View File

@@ -1,34 +0,0 @@
# vitest preset
## Purpose
Правила для тестов (`*.test.*`, `*.spec.*`, `test/**`, `__tests__/**`).
## Key Rules
- `vitest/no-conditional-tests`.
- `vitest/no-import-node-test`.
- `vitest/prefer-to-be-truthy`, `vitest/prefer-to-be-falsy`.
- `vitest/prefer-to-have-length`.
- Relaxations: `eslint/no-unused-vars` и `typescript/no-explicit-any` выключены для тестов.
## Examples
```ts
// ✅ Good
import { describe, it, expect } from 'vitest';
describe('list', () => {
it('has items', () => {
expect([1, 2, 3]).toHaveLength(3);
expect(true).toBeTruthy();
});
});
// ❌ Bad
if (process.env.CI) {
it('conditionally runs', () => {
expect(true).toBe(true);
});
}
```

View File

@@ -1,30 +0,0 @@
# vue preset
## Purpose
Правила для Vue 3 с упором на Composition API и `<script setup>`.
## Key Rules
- `vue/no-export-in-script-setup`.
- `vue/no-import-compiler-macros`.
- `vue/define-props-declaration`: type-based.
- `vue/define-emits-declaration`: type-based.
- `vue/valid-define-props`, `vue/valid-define-emits`.
- `vue/no-lifecycle-after-await`.
## Examples
```vue
<script setup lang="ts">
const props = defineProps<{ id: string }>();
const emit = defineEmits<{ change: [value: string] }>();
</script>
<!-- Bad -->
<script setup lang="ts">
import { defineProps } from 'vue';
export const x = 1;
const props = defineProps({ id: String });
</script>
```

View File

@@ -1,120 +0,0 @@
import type { OxlintConfig } from './types';
/**
* Deep merge two objects. Arrays are concatenated, objects are recursively merged.
*/
function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
const result = { ...target };
for (const key of Object.keys(source)) {
const targetValue = target[key];
const sourceValue = source[key];
if (
typeof targetValue === 'object' && targetValue !== null && !Array.isArray(targetValue)
&& typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue)
) {
result[key] = deepMerge(
targetValue as Record<string, unknown>,
sourceValue as Record<string, unknown>,
);
}
else {
result[key] = sourceValue;
}
}
return result;
}
/**
* Compose multiple oxlint configurations into a single config.
*
* - `plugins` — union (deduplicated)
* - `jsPlugins` — union (deduplicated by specifier)
* - `categories` — later configs override earlier
* - `rules` — later configs override earlier
* - `overrides` — concatenated
* - `env` — merged (later overrides earlier)
* - `globals` — merged (later overrides earlier)
* - `settings` — deep-merged
* - `ignorePatterns` — concatenated
*
* @example
* ```ts
* import { compose, base, typescript, vue } from '@robonen/oxlint';
* import { defineConfig } from 'oxlint';
*
* export default defineConfig(
* compose(base, typescript, vue, {
* rules: { 'eslint/no-console': 'off' },
* }),
* );
* ```
*/
export function compose(...configs: OxlintConfig[]): OxlintConfig {
const result: OxlintConfig = {};
for (const config of configs) {
// Plugins — union with dedup
if (config.plugins?.length) {
result.plugins = Array.from(new Set([...(result.plugins ?? []), ...config.plugins]));
}
// JS Plugins — union with dedup by specifier
if (config.jsPlugins?.length) {
const existing = result.jsPlugins ?? [];
const seen = new Set(existing.map(e => typeof e === 'string' ? e : e.specifier));
for (const entry of config.jsPlugins) {
const specifier = typeof entry === 'string' ? entry : entry.specifier;
if (!seen.has(specifier)) {
seen.add(specifier);
existing.push(entry);
}
}
result.jsPlugins = existing;
}
// Categories — shallow merge
if (config.categories) {
result.categories = { ...result.categories, ...config.categories };
}
// Rules — shallow merge (later overrides earlier)
if (config.rules) {
result.rules = { ...result.rules, ...config.rules };
}
// Overrides — concatenate
if (config.overrides?.length) {
result.overrides = [...(result.overrides ?? []), ...config.overrides];
}
// Env — shallow merge
if (config.env) {
result.env = { ...result.env, ...config.env };
}
// Globals — shallow merge
if (config.globals) {
result.globals = { ...result.globals, ...config.globals };
}
// Settings — deep merge
if (config.settings) {
result.settings = deepMerge(
(result.settings ?? {}) as Record<string, unknown>,
config.settings as Record<string, unknown>,
);
}
// Ignore patterns — concatenate
if (config.ignorePatterns?.length) {
result.ignorePatterns = [...(result.ignorePatterns ?? []), ...config.ignorePatterns];
}
}
return result;
}

View File

@@ -1,18 +0,0 @@
/* Compose */
export { compose } from './compose';
/* Presets */
export { base, typescript, vue, vitest, imports, node, stylistic } from './presets';
/* Types */
export type {
OxlintConfig,
OxlintOverride,
OxlintEnv,
OxlintGlobals,
ExternalPluginEntry,
AllowWarnDeny,
DummyRule,
DummyRuleMap,
RuleCategories,
} from './types';

View File

@@ -1,73 +0,0 @@
import type { OxlintConfig } from '../types';
/**
* Base configuration for any JavaScript/TypeScript project.
*
* Enables `correctness` category and opinionated rules from
* `eslint`, `oxc`, and `unicorn` plugins.
*/
export const base: OxlintConfig = {
plugins: ['eslint', 'oxc', 'unicorn'],
categories: {
correctness: 'error',
},
rules: {
/* ── eslint core ──────────────────────────────────────── */
'eslint/eqeqeq': 'error',
'eslint/no-console': 'warn',
'eslint/no-debugger': 'error',
'eslint/no-eval': 'error',
'eslint/no-var': 'error',
'eslint/prefer-const': 'error',
'eslint/prefer-template': 'warn',
'eslint/no-useless-constructor': 'warn',
'eslint/no-useless-rename': 'warn',
'eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'eslint/no-self-compare': 'error',
'eslint/no-template-curly-in-string': 'warn',
'eslint/no-throw-literal': 'error',
'eslint/no-return-assign': 'warn',
'eslint/no-else-return': 'warn',
'eslint/no-lonely-if': 'warn',
'eslint/no-unneeded-ternary': 'warn',
'eslint/prefer-object-spread': 'warn',
'eslint/prefer-exponentiation-operator': 'warn',
'eslint/no-useless-computed-key': 'warn',
'eslint/no-useless-concat': 'warn',
'eslint/curly': 'off',
/* ── unicorn ──────────────────────────────────────────── */
'unicorn/prefer-node-protocol': 'error',
'unicorn/no-instanceof-array': 'error',
'unicorn/no-new-array': 'error',
'unicorn/prefer-array-flat-map': 'warn',
'unicorn/prefer-array-flat': 'warn',
'unicorn/prefer-includes': 'warn',
'unicorn/prefer-string-slice': 'warn',
'unicorn/prefer-string-starts-ends-with': 'warn',
'unicorn/throw-new-error': 'error',
'unicorn/error-message': 'warn',
'unicorn/no-useless-spread': 'warn',
'unicorn/no-useless-undefined': 'off',
'unicorn/prefer-optional-catch-binding': 'warn',
'unicorn/prefer-type-error': 'warn',
'unicorn/no-thenable': 'error',
'unicorn/prefer-number-properties': 'warn',
'unicorn/prefer-global-this': 'warn',
/* ── oxc ──────────────────────────────────────────────── */
'oxc/no-accumulating-spread': 'warn',
'oxc/bad-comparison-sequence': 'error',
'oxc/bad-min-max-func': 'error',
'oxc/bad-object-literal-comparison': 'error',
'oxc/const-comparisons': 'error',
'oxc/double-comparisons': 'error',
'oxc/erasing-op': 'error',
'oxc/missing-throw': 'error',
'oxc/bad-bitwise-operator': 'error',
'oxc/bad-char-at-comparison': 'error',
'oxc/bad-replace-all-arg': 'error',
},
};

View File

@@ -1,22 +0,0 @@
import type { OxlintConfig } from '../types';
/**
* Import plugin rules for clean module boundaries.
*/
export const imports: OxlintConfig = {
plugins: ['import'],
rules: {
'import/no-duplicates': 'error',
'import/no-self-import': 'error',
'import/no-cycle': 'warn',
'import/first': 'warn',
'import/no-mutable-exports': 'error',
'import/no-amd': 'error',
'import/no-commonjs': 'warn',
'import/no-empty-named-blocks': 'warn',
'import/consistent-type-specifier-style': ['warn', 'prefer-top-level'],
'sort-imports': ['warn', { ignoreDeclarationSort: false, ignoreMemberSort: false, ignoreCase: true, allowSeparatedGroups: true }],
},
};

View File

@@ -1,7 +0,0 @@
export { base } from './base';
export { typescript } from './typescript';
export { vue } from './vue';
export { vitest } from './vitest';
export { imports } from './imports';
export { node } from './node';
export { stylistic } from './stylistic';

View File

@@ -1,17 +0,0 @@
import type { OxlintConfig } from '../types';
/**
* Node.js-specific rules.
*/
export const node: OxlintConfig = {
plugins: ['node'],
env: {
node: true,
},
rules: {
'node/no-exports-assign': 'error',
'node/no-new-require': 'error',
},
};

View File

@@ -1,162 +0,0 @@
import type { OxlintConfig } from '../types';
/**
* Stylistic formatting rules via `@stylistic/eslint-plugin`.
*
* Uses the plugin's `customize()` defaults:
* - indent: 2
* - quotes: single
* - semi: true
* - braceStyle: stroustrup
* - commaDangle: always-multiline
* - arrowParens: false (as-needed)
* - blockSpacing: true
* - quoteProps: consistent-as-needed
* - jsx: true
*
* Requires `@stylistic/eslint-plugin` to be installed.
*
* @see https://eslint.style/guide/config-presets
*/
export const stylistic: OxlintConfig = {
jsPlugins: ['@stylistic/eslint-plugin'],
rules: {
/* ── spacing & layout ─────────────────────────────────── */
'@stylistic/array-bracket-spacing': ['error', 'never'],
'@stylistic/arrow-spacing': ['error', { after: true, before: true }],
'@stylistic/block-spacing': ['error', 'always'],
'@stylistic/comma-spacing': ['error', { after: true, before: false }],
'@stylistic/computed-property-spacing': ['error', 'never', { enforceForClassMembers: true }],
'@stylistic/dot-location': ['error', 'property'],
'@stylistic/key-spacing': ['error', { afterColon: true, beforeColon: false }],
'@stylistic/keyword-spacing': ['error', { after: true, before: true }],
'@stylistic/no-mixed-spaces-and-tabs': 'error',
'@stylistic/no-multi-spaces': 'error',
'@stylistic/no-trailing-spaces': 'error',
'@stylistic/no-whitespace-before-property': 'error',
'@stylistic/rest-spread-spacing': ['error', 'never'],
'@stylistic/semi-spacing': ['error', { after: true, before: false }],
'@stylistic/space-before-blocks': ['error', 'always'],
'@stylistic/space-before-function-paren': ['error', { anonymous: 'always', asyncArrow: 'always', named: 'never' }],
'@stylistic/space-in-parens': ['error', 'never'],
'@stylistic/space-infix-ops': 'error',
'@stylistic/space-unary-ops': ['error', { nonwords: false, words: true }],
'@stylistic/template-curly-spacing': 'error',
'@stylistic/template-tag-spacing': ['error', 'never'],
/* ── braces & blocks ──────────────────────────────────── */
'@stylistic/brace-style': ['error', 'stroustrup', { allowSingleLine: true }],
'@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }],
'@stylistic/no-extra-parens': ['error', 'functions'],
'@stylistic/no-floating-decimal': 'error',
'@stylistic/wrap-iife': ['error', 'any', { functionPrototypeMethods: true }],
'@stylistic/new-parens': 'error',
'@stylistic/padded-blocks': ['error', { blocks: 'never', classes: 'never', switches: 'never' }],
/* ── punctuation ──────────────────────────────────────── */
'@stylistic/comma-dangle': ['error', 'always-multiline'],
'@stylistic/comma-style': ['error', 'last'],
'@stylistic/semi': ['error', 'always'],
'@stylistic/quotes': ['error', 'single', { allowTemplateLiterals: 'always', avoidEscape: false }],
'@stylistic/quote-props': ['error', 'consistent-as-needed'],
/* ── indentation ──────────────────────────────────────── */
'@stylistic/indent': ['error', 2, {
ArrayExpression: 1,
CallExpression: { arguments: 1 },
flatTernaryExpressions: false,
FunctionDeclaration: { body: 1, parameters: 1, returnType: 1 },
FunctionExpression: { body: 1, parameters: 1, returnType: 1 },
ignoreComments: false,
ignoredNodes: [
'TSUnionType',
'TSIntersectionType',
],
ImportDeclaration: 1,
MemberExpression: 1,
ObjectExpression: 1,
offsetTernaryExpressions: true,
outerIIFEBody: 1,
SwitchCase: 1,
tabLength: 2,
VariableDeclarator: 1,
}],
'@stylistic/indent-binary-ops': ['error', 2],
'@stylistic/no-tabs': 'error',
/* ── line breaks ──────────────────────────────────────── */
'@stylistic/eol-last': 'error',
'@stylistic/no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 0 }],
'@stylistic/lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }],
'@stylistic/max-statements-per-line': ['error', { max: 1 }],
'@stylistic/multiline-ternary': ['error', 'always-multiline'],
'@stylistic/operator-linebreak': ['error', 'before'],
'@stylistic/object-curly-spacing': ['error', 'always'],
/* ── generators ───────────────────────────────────────── */
'@stylistic/generator-star-spacing': ['error', { after: true, before: false }],
'@stylistic/yield-star-spacing': ['error', { after: true, before: false }],
/* ── operators & mixed ────────────────────────────────── */
'@stylistic/no-mixed-operators': ['error', {
allowSamePrecedence: true,
groups: [
['==', '!=', '===', '!==', '>', '>=', '<', '<='],
['&&', '||'],
['in', 'instanceof'],
],
}],
/* ── typescript styling ───────────────────────────────── */
'@stylistic/member-delimiter-style': ['error', {
multiline: { delimiter: 'semi', requireLast: true },
multilineDetection: 'brackets',
overrides: {
interface: {
multiline: { delimiter: 'semi', requireLast: true },
},
},
singleline: { delimiter: 'semi' },
}],
'@stylistic/type-annotation-spacing': ['error', {}],
'@stylistic/type-generic-spacing': 'error',
'@stylistic/type-named-tuple-spacing': 'error',
/* ── comments ─────────────────────────────────────────── */
'@stylistic/spaced-comment': ['error', 'always', {
block: { balanced: true, exceptions: ['*'], markers: ['!'] },
line: { exceptions: ['/', '#'], markers: ['/'] },
}],
/* ── jsx ───────────────────────────────────────────────── */
'@stylistic/jsx-closing-bracket-location': 'error',
'@stylistic/jsx-closing-tag-location': 'error',
'@stylistic/jsx-curly-brace-presence': ['error', { propElementValues: 'always' }],
'@stylistic/jsx-curly-newline': 'error',
'@stylistic/jsx-curly-spacing': ['error', 'never'],
'@stylistic/jsx-equals-spacing': 'error',
'@stylistic/jsx-first-prop-new-line': 'error',
'@stylistic/jsx-function-call-newline': ['error', 'multiline'],
'@stylistic/jsx-indent-props': ['error', 2],
'@stylistic/jsx-max-props-per-line': ['error', { maximum: 1, when: 'multiline' }],
'@stylistic/jsx-one-expression-per-line': ['error', { allow: 'single-child' }],
'@stylistic/jsx-quotes': 'error',
'@stylistic/jsx-tag-spacing': ['error', {
afterOpening: 'never',
beforeClosing: 'never',
beforeSelfClosing: 'always',
closingSlash: 'never',
}],
'@stylistic/jsx-wrap-multilines': ['error', {
arrow: 'parens-new-line',
assignment: 'parens-new-line',
condition: 'parens-new-line',
declaration: 'parens-new-line',
logical: 'parens-new-line',
prop: 'parens-new-line',
propertyValue: 'parens-new-line',
return: 'parens-new-line',
}],
},
};

View File

@@ -1,39 +0,0 @@
import type { OxlintConfig } from '../types';
/**
* TypeScript-specific rules.
*
* Applied via `overrides` for `*.ts`, `*.tsx`, `*.mts`, `*.cts` files.
*/
export const typescript: OxlintConfig = {
plugins: ['typescript'],
overrides: [
{
files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'],
rules: {
'typescript/consistent-type-imports': 'error',
'typescript/no-explicit-any': 'off',
'typescript/no-non-null-assertion': 'off',
'typescript/prefer-as-const': 'error',
'typescript/no-empty-object-type': 'warn',
'typescript/no-wrapper-object-types': 'error',
'typescript/no-duplicate-enum-values': 'error',
'typescript/no-unsafe-declaration-merging': 'error',
'typescript/no-import-type-side-effects': 'error',
'typescript/no-useless-empty-export': 'warn',
'typescript/no-inferrable-types': 'warn',
'typescript/prefer-function-type': 'warn',
'typescript/ban-tslint-comment': 'error',
'typescript/consistent-type-definitions': ['warn', 'interface'],
'typescript/prefer-for-of': 'warn',
'typescript/no-unnecessary-type-constraint': 'warn',
'typescript/adjacent-overload-signatures': 'warn',
'typescript/array-type': ['warn', { default: 'array-simple' }],
'typescript/no-this-alias': 'error',
'typescript/triple-slash-reference': 'error',
'typescript/no-namespace': 'error',
},
},
],
};

View File

@@ -1,35 +0,0 @@
import type { OxlintConfig } from '../types';
/**
* Vitest rules for test files.
*
* Applied via `overrides` for common test file patterns.
*/
export const vitest: OxlintConfig = {
plugins: ['vitest'],
overrides: [
{
files: [
'**/*.test.{ts,tsx,js,jsx}',
'**/*.spec.{ts,tsx,js,jsx}',
'**/test/**/*.{ts,tsx,js,jsx}',
'**/__tests__/**/*.{ts,tsx,js,jsx}',
],
rules: {
'vitest/no-conditional-tests': 'warn',
'vitest/no-import-node-test': 'error',
'vitest/prefer-to-be-truthy': 'warn',
'vitest/prefer-to-be-falsy': 'warn',
'vitest/prefer-to-be-object': 'warn',
'vitest/prefer-to-have-length': 'warn',
'vitest/consistent-test-filename': 'warn',
'vitest/prefer-describe-function-title': 'warn',
/* relax strict rules in tests */
'eslint/no-unused-vars': 'off',
'typescript/no-explicit-any': 'off',
},
},
],
};

View File

@@ -1,26 +0,0 @@
import type { OxlintConfig } from '../types';
/**
* Vue.js-specific rules.
*
* Enforces Composition API with `<script setup>` and type-based declarations.
*/
export const vue: OxlintConfig = {
plugins: ['vue'],
rules: {
'vue/no-arrow-functions-in-watch': 'error',
'vue/no-deprecated-destroyed-lifecycle': 'error',
'vue/no-export-in-script-setup': 'error',
'vue/no-lifecycle-after-await': 'error',
'vue/no-multiple-slot-args': 'error',
'vue/no-import-compiler-macros': 'error',
'vue/define-emits-declaration': ['error', 'type-based'],
'vue/define-props-declaration': ['error', 'type-based'],
'vue/prefer-import-from-vue': 'error',
'vue/no-required-prop-with-default': 'warn',
'vue/valid-define-emits': 'error',
'vue/valid-define-props': 'error',
'vue/require-typed-ref': 'warn',
},
};

View File

@@ -1,19 +0,0 @@
/**
* Re-exported configuration types from `oxlint`.
*
* Keeps the preset API in sync with the oxlint CLI without
* maintaining a separate copy of the types.
*
* @see https://oxc.rs/docs/guide/usage/linter/config-file-reference.html
*/
export type {
OxlintConfig,
OxlintOverride,
OxlintEnv,
OxlintGlobals,
ExternalPluginEntry,
AllowWarnDeny,
DummyRule,
DummyRuleMap,
RuleCategories,
} from 'oxlint';

View File

@@ -1,171 +0,0 @@
import { describe, expect, it } from 'vitest';
import { compose } from '../src/compose';
import type { OxlintConfig } from '../src/types';
describe('compose', () => {
it('should return empty config when no configs provided', () => {
expect(compose()).toEqual({});
});
it('should return the same config when one config provided', () => {
const config: OxlintConfig = {
plugins: ['eslint'],
rules: { 'eslint/no-console': 'warn' },
};
const result = compose(config);
expect(result.plugins).toEqual(['eslint']);
expect(result.rules).toEqual({ 'eslint/no-console': 'warn' });
});
it('should merge plugins with dedup', () => {
const a: OxlintConfig = { plugins: ['eslint', 'oxc'] };
const b: OxlintConfig = { plugins: ['oxc', 'typescript'] };
const result = compose(a, b);
expect(result.plugins).toEqual(['eslint', 'oxc', 'typescript']);
});
it('should override rules from later configs', () => {
const a: OxlintConfig = { rules: { 'eslint/no-console': 'error', 'eslint/eqeqeq': 'warn' } };
const b: OxlintConfig = { rules: { 'eslint/no-console': 'off' } };
const result = compose(a, b);
expect(result.rules).toEqual({
'eslint/no-console': 'off',
'eslint/eqeqeq': 'warn',
});
});
it('should override categories from later configs', () => {
const a: OxlintConfig = { categories: { correctness: 'error', suspicious: 'warn' } };
const b: OxlintConfig = { categories: { suspicious: 'off' } };
const result = compose(a, b);
expect(result.categories).toEqual({
correctness: 'error',
suspicious: 'off',
});
});
it('should concatenate overrides', () => {
const a: OxlintConfig = {
overrides: [{ files: ['**/*.ts'], rules: { 'typescript/no-explicit-any': 'warn' } }],
};
const b: OxlintConfig = {
overrides: [{ files: ['**/*.test.ts'], rules: { 'eslint/no-unused-vars': 'off' } }],
};
const result = compose(a, b);
expect(result.overrides).toHaveLength(2);
expect(result.overrides?.[0]?.files).toEqual(['**/*.ts']);
expect(result.overrides?.[1]?.files).toEqual(['**/*.test.ts']);
});
it('should merge env', () => {
const a: OxlintConfig = { env: { browser: true } };
const b: OxlintConfig = { env: { node: true } };
const result = compose(a, b);
expect(result.env).toEqual({ browser: true, node: true });
});
it('should merge globals', () => {
const a: OxlintConfig = { globals: { MY_VAR: 'readonly' } };
const b: OxlintConfig = { globals: { ANOTHER: 'writable' } };
const result = compose(a, b);
expect(result.globals).toEqual({ MY_VAR: 'readonly', ANOTHER: 'writable' });
});
it('should deep merge settings', () => {
const a: OxlintConfig = {
settings: {
react: { version: '18.2.0' },
next: { rootDir: 'apps/' },
},
};
const b: OxlintConfig = {
settings: {
react: { linkComponents: [{ name: 'Link', linkAttribute: 'to', attributes: ['to'] }] },
},
};
const result = compose(a, b);
expect(result.settings).toEqual({
react: {
version: '18.2.0',
linkComponents: [{ name: 'Link', linkAttribute: 'to', attributes: ['to'] }],
},
next: { rootDir: 'apps/' },
});
});
it('should concatenate ignorePatterns', () => {
const a: OxlintConfig = { ignorePatterns: ['dist'] };
const b: OxlintConfig = { ignorePatterns: ['node_modules', 'coverage'] };
const result = compose(a, b);
expect(result.ignorePatterns).toEqual(['dist', 'node_modules', 'coverage']);
});
it('should handle composing all presets together', () => {
const base: OxlintConfig = {
plugins: ['eslint', 'oxc'],
categories: { correctness: 'error' },
rules: { 'eslint/no-console': 'warn' },
};
const ts: OxlintConfig = {
plugins: ['typescript'],
overrides: [{ files: ['**/*.ts'], rules: { 'typescript/no-explicit-any': 'warn' } }],
};
const custom: OxlintConfig = {
rules: { 'eslint/no-console': 'off' },
ignorePatterns: ['dist'],
};
const result = compose(base, ts, custom);
expect(result.plugins).toEqual(['eslint', 'oxc', 'typescript']);
expect(result.categories).toEqual({ correctness: 'error' });
expect(result.rules).toEqual({ 'eslint/no-console': 'off' });
expect(result.overrides).toHaveLength(1);
expect(result.ignorePatterns).toEqual(['dist']);
});
it('should skip undefined/empty fields', () => {
const a: OxlintConfig = { plugins: ['eslint'] };
const b: OxlintConfig = { rules: { 'eslint/no-console': 'warn' } };
const result = compose(a, b);
expect(result.plugins).toEqual(['eslint']);
expect(result.rules).toEqual({ 'eslint/no-console': 'warn' });
expect(result.overrides).toBeUndefined();
expect(result.env).toBeUndefined();
expect(result.settings).toBeUndefined();
});
it('should concatenate jsPlugins with dedup by specifier', () => {
const a: OxlintConfig = { jsPlugins: ['eslint-plugin-foo'] };
const b: OxlintConfig = { jsPlugins: ['eslint-plugin-foo', 'eslint-plugin-bar'] };
const result = compose(a, b);
expect(result.jsPlugins).toEqual(['eslint-plugin-foo', 'eslint-plugin-bar']);
});
it('should dedup jsPlugins with mixed string and object entries', () => {
const a: OxlintConfig = { jsPlugins: ['eslint-plugin-foo'] };
const b: OxlintConfig = { jsPlugins: [{ name: 'foo', specifier: 'eslint-plugin-foo' }] };
const result = compose(a, b);
expect(result.jsPlugins).toEqual(['eslint-plugin-foo']);
});
it('should keep jsPlugins and plugins independent', () => {
const a: OxlintConfig = { plugins: ['eslint'], jsPlugins: ['eslint-plugin-foo'] };
const b: OxlintConfig = { plugins: ['typescript'], jsPlugins: ['eslint-plugin-bar'] };
const result = compose(a, b);
expect(result.plugins).toEqual(['eslint', 'typescript']);
expect(result.jsPlugins).toEqual(['eslint-plugin-foo', 'eslint-plugin-bar']);
});
});

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
entry: ['src/index.ts'],
});

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});

View File

@@ -1,27 +0,0 @@
# @robonen/tsconfig
Shared base TypeScript configuration.
## Install
```bash
pnpm install -D @robonen/tsconfig
```
## Usage
Extend from it in your `tsconfig.json`:
```json
{
"extends": "@robonen/tsconfig/tsconfig.json"
}
```
## What's Included
- **Target / Module**: ESNext with Bundler resolution
- **Strict mode**: `strict`, `noUncheckedIndexedAccess`
- **Module safety**: `verbatimModuleSyntax`, `isolatedModules`
- **Declarations**: `declaration` enabled
- **Interop**: `esModuleInterop`, `allowJs`, `resolveJsonModule`

View File

@@ -1,30 +0,0 @@
{
"name": "@robonen/tsdown",
"version": "0.0.1",
"private": true,
"license": "Apache-2.0",
"description": "Shared tsdown configuration for @robonen packages",
"keywords": [
"tsdown",
"config",
"build"
],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "configs/tsdown"
},
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},
"type": "module",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@robonen/tsconfig": "workspace:*",
"tsdown": "catalog:"
}
}

View File

@@ -1,13 +0,0 @@
import type { Options } from 'tsdown';
const BANNER = '/*! @robonen/tools | (c) 2026 Robonen Andrew | Apache-2.0 */';
export const sharedConfig = {
format: ['esm', 'cjs'],
dts: true,
clean: true,
hash: false,
outputOptions: {
banner: BANNER,
},
} satisfies Options;

View File

@@ -1,6 +0,0 @@
{
"$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@robonen/encoding",
"version": "0.0.1",
"exports": "./src/index.ts"
}

View File

@@ -1,14 +0,0 @@
import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, imports, stylistic, {
overrides: [
{
files: ['src/qr/qr-code.ts'],
rules: {
'@stylistic/max-statements-per-line': 'off',
'@stylistic/no-mixed-operators': 'off',
},
},
],
}));

View File

@@ -1,47 +0,0 @@
{
"name": "@robonen/encoding",
"version": "0.0.1",
"license": "Apache-2.0",
"description": "Encoding utilities for TypeScript",
"keywords": [
"encoding",
"tools"
],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "core/encoding"
},
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"lint:check": "oxlint -c oxlint.config.ts",
"lint:fix": "oxlint -c oxlint.config.ts --fix",
"test": "vitest run",
"dev": "vitest dev",
"bench": "vitest bench",
"build": "tsdown"
},
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:",
"oxlint": "catalog:",
"tsdown": "catalog:"
}
}

View File

@@ -1,2 +0,0 @@
export * from './reed-solomon';
export * from './qr';

View File

@@ -1,67 +0,0 @@
import type { QrCodeEcc, QrSegmentMode } from './types';
/* -- ECC Levels -- */
export const LOW: QrCodeEcc = [0, 1]; // ~7% recovery
export const MEDIUM: QrCodeEcc = [1, 0]; // ~15% recovery
export const QUARTILE: QrCodeEcc = [2, 3]; // ~25% recovery
export const HIGH: QrCodeEcc = [3, 2]; // ~30% recovery
export const EccMap = {
L: LOW,
M: MEDIUM,
Q: QUARTILE,
H: HIGH,
} as const;
/* -- Segment Modes -- */
export const MODE_NUMERIC: QrSegmentMode = [0x1, 10, 12, 14];
export const MODE_ALPHANUMERIC: QrSegmentMode = [0x2, 9, 11, 13];
export const MODE_BYTE: QrSegmentMode = [0x4, 8, 16, 16];
/* -- Version Limits -- */
export const MIN_VERSION = 1;
export const MAX_VERSION = 40;
/* -- Penalty Constants -- */
export const PENALTY_N1 = 3;
export const PENALTY_N2 = 3;
export const PENALTY_N3 = 40;
export const PENALTY_N4 = 10;
/* -- Character Sets & Patterns -- */
export const NUMERIC_REGEX = /^[0-9]*$/;
export const ALPHANUMERIC_REGEX = /^[A-Z0-9 $%*+./:_-]*$/;
export const ALPHANUMERIC_CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:' as const;
/** Pre-computed charCode → alphanumeric index lookup (0xFF = invalid). O(1) instead of O(45) indexOf. */
export const ALPHANUMERIC_MAP = /* @__PURE__ */ (() => {
const map = new Uint8Array(128).fill(0xFF);
for (let i = 0; i < ALPHANUMERIC_CHARSET.length; i++)
map[ALPHANUMERIC_CHARSET.charCodeAt(i)] = i;
return map;
})();
/* -- ECC Lookup Tables -- */
// prettier-ignore
export const ECC_CODEWORDS_PER_BLOCK: number[][] = [
// 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40
[-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Low
[-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], // Medium
[-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Quartile
[-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // High
];
// prettier-ignore
export const NUM_ERROR_CORRECTION_BLOCKS: number[][] = [
// 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40
[-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], // Low
[-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], // Medium
[-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], // Quartile
[-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], // High
];

View File

@@ -1,94 +0,0 @@
import type { QrCodeEcc } from './types';
import { HIGH, MAX_VERSION, MEDIUM, MIN_VERSION, QUARTILE } from './constants';
import { QrCode } from './qr-code';
import { makeBytes, makeSegments } from './segment';
import type { QrSegment } from './segment';
import { appendBits, assert, getNumDataCodewords, getTotalBits, numCharCountBits } from './utils';
/**
* Returns a QR Code representing the given Unicode text string at the given error correction level.
* As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer
* Unicode code points (not UTF-16 code units) if the low error correction level is used.
* The smallest possible QR Code version is automatically chosen for the output.
*/
export function encodeText(text: string, ecl: QrCodeEcc): QrCode {
const segs = makeSegments(text);
return encodeSegments(segs, ecl);
}
/**
* Returns a QR Code representing the given binary data at the given error correction level.
* This function always encodes using the binary segment mode, not any text mode.
* The maximum number of bytes allowed is 2953.
*/
export function encodeBinary(data: Readonly<number[]>, ecl: QrCodeEcc): QrCode {
const seg = makeBytes(data);
return encodeSegments([seg], ecl);
}
/**
* Returns a QR Code representing the given segments with the given encoding parameters.
* The smallest possible QR Code version within the given range is automatically chosen for the output.
* This is a mid-level API; the high-level API is encodeText() and encodeBinary().
*/
export function encodeSegments(
segs: Readonly<QrSegment[]>,
ecl: QrCodeEcc,
minVersion = 1,
maxVersion = 40,
mask = -1,
boostEcl = true,
): QrCode {
if (!(MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= MAX_VERSION)
|| mask < -1 || mask > 7)
throw new RangeError('Invalid value');
// Find the minimal version number to use
let version: number;
let dataUsedBits: number;
for (version = minVersion; ; version++) {
const dataCapacityBits = getNumDataCodewords(version, ecl) * 8;
const usedBits = getTotalBits(segs, version);
if (usedBits <= dataCapacityBits) {
dataUsedBits = usedBits;
break;
}
if (version >= maxVersion)
throw new RangeError('Data too long');
}
// Increase the error correction level while the data still fits in the current version number
for (const newEcl of [MEDIUM, QUARTILE, HIGH]) {
if (boostEcl && dataUsedBits! <= getNumDataCodewords(version, newEcl) * 8)
ecl = newEcl;
}
// Concatenate all segments to create the data bit string
const bb: number[] = [];
for (const seg of segs) {
appendBits(seg.mode[0], 4, bb);
appendBits(seg.numChars, numCharCountBits(seg.mode, version), bb);
for (const b of seg.bitData)
bb.push(b);
}
assert(bb.length === dataUsedBits!);
// Add terminator and pad up to a byte if applicable
const dataCapacityBits = getNumDataCodewords(version, ecl) * 8;
assert(bb.length <= dataCapacityBits);
appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb);
appendBits(0, (8 - bb.length % 8) % 8, bb);
assert(bb.length % 8 === 0);
// Pad with alternating bytes until data capacity is reached
for (let padByte = 0xEC; bb.length < dataCapacityBits; padByte ^= 0xEC ^ 0x11)
appendBits(padByte, 8, bb);
// Pack bits into bytes in big endian
const dataCodewords = Array.from({ length: Math.ceil(bb.length / 8) }, () => 0);
for (let i = 0; i < bb.length; i++)
dataCodewords[i >>> 3]! |= bb[i]! << (7 - (i & 7));
// Create the QR Code object
return new QrCode(version, ecl, dataCodewords, mask);
}

View File

@@ -1,96 +0,0 @@
import { bench, describe } from 'vitest';
import { encodeBinary, encodeSegments, encodeText, makeSegments, LOW, EccMap } from '.';
/* -- Test data -- */
const SHORT_TEXT = 'Hello';
const MEDIUM_TEXT = 'https://example.com/path?query=value&foo=bar';
const LONG_TEXT = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit.';
const NUMERIC_TEXT = '314159265358979323846264338327950288419716939937510';
const ALPHANUMERIC_TEXT = 'HELLO WORLD 12345';
const SMALL_BINARY = Array.from({ length: 32 }, (_, i) => i);
const MEDIUM_BINARY = Array.from({ length: 256 }, (_, i) => i % 256);
/* -- Precomputed segments for isolated encodeSegments benchmark -- */
const precomputedSegs = makeSegments(MEDIUM_TEXT);
/* -- encodeText benchmarks -- */
describe('encodeText', () => {
bench('short text (5 chars)', () => {
encodeText(SHORT_TEXT, LOW);
});
bench('medium text (URL ~44 chars)', () => {
encodeText(MEDIUM_TEXT, LOW);
});
bench('long text (~270 chars)', () => {
encodeText(LONG_TEXT, LOW);
});
bench('numeric text (50 digits)', () => {
encodeText(NUMERIC_TEXT, LOW);
});
bench('alphanumeric text (17 chars)', () => {
encodeText(ALPHANUMERIC_TEXT, LOW);
});
});
/* -- ECC level impact -- */
describe('encodeText — ECC levels', () => {
bench('LOW (L)', () => {
encodeText(MEDIUM_TEXT, EccMap.L);
});
bench('MEDIUM (M)', () => {
encodeText(MEDIUM_TEXT, EccMap.M);
});
bench('QUARTILE (Q)', () => {
encodeText(MEDIUM_TEXT, EccMap.Q);
});
bench('HIGH (H)', () => {
encodeText(MEDIUM_TEXT, EccMap.H);
});
});
/* -- encodeBinary benchmarks -- */
describe('encodeBinary', () => {
bench('small binary (32 bytes)', () => {
encodeBinary(SMALL_BINARY, LOW);
});
bench('medium binary (256 bytes)', () => {
encodeBinary(MEDIUM_BINARY, LOW);
});
});
/* -- makeSegments benchmarks -- */
describe('makeSegments', () => {
bench('numeric classification', () => {
makeSegments(NUMERIC_TEXT);
});
bench('alphanumeric classification', () => {
makeSegments(ALPHANUMERIC_TEXT);
});
bench('byte mode classification', () => {
makeSegments(MEDIUM_TEXT);
});
});
/* -- encodeSegments (pre-built segments) -- */
describe('encodeSegments', () => {
bench('from pre-built segments', () => {
encodeSegments(precomputedSegs, LOW);
});
});

View File

@@ -1,182 +0,0 @@
import { describe, expect, it } from 'vitest';
import { encodeText, encodeBinary, makeSegments, isNumeric, isAlphanumeric, QrCode, EccMap, LOW, MEDIUM, HIGH } from '.';
describe('isNumeric', () => {
it('accepts pure digit strings', () => {
expect(isNumeric('0123456789')).toBe(true);
expect(isNumeric('0')).toBe(true);
expect(isNumeric('')).toBe(true);
});
it('rejects non-digit characters', () => {
expect(isNumeric('12a3')).toBe(false);
expect(isNumeric('HELLO')).toBe(false);
expect(isNumeric('12 34')).toBe(false);
});
});
describe('isAlphanumeric', () => {
it('accepts valid alphanumeric strings', () => {
expect(isAlphanumeric('HELLO WORLD')).toBe(true);
expect(isAlphanumeric('0123456789')).toBe(true);
expect(isAlphanumeric('ABC123')).toBe(true);
expect(isAlphanumeric('')).toBe(true);
});
it('rejects lowercase and special characters', () => {
expect(isAlphanumeric('hello')).toBe(false);
expect(isAlphanumeric('Hello')).toBe(false);
expect(isAlphanumeric('test@email')).toBe(false);
});
});
describe('makeSegments', () => {
it('returns empty array for empty string', () => {
expect(makeSegments('')).toEqual([]);
});
it('selects numeric mode for digit strings', () => {
const segs = makeSegments('12345');
expect(segs).toHaveLength(1);
expect(segs[0]!.mode[0]).toBe(0x1); // MODE_NUMERIC
});
it('selects alphanumeric mode for uppercase strings', () => {
const segs = makeSegments('HELLO WORLD');
expect(segs).toHaveLength(1);
expect(segs[0]!.mode[0]).toBe(0x2); // MODE_ALPHANUMERIC
});
it('selects byte mode for general text', () => {
const segs = makeSegments('Hello, World!');
expect(segs).toHaveLength(1);
expect(segs[0]!.mode[0]).toBe(0x4); // MODE_BYTE
});
});
describe('encodeText', () => {
it('encodes short text at LOW ECC', () => {
const qr = encodeText('Hello', LOW);
expect(qr).toBeInstanceOf(QrCode);
expect(qr.version).toBeGreaterThanOrEqual(1);
expect(qr.size).toBe(qr.version * 4 + 17);
expect(qr.mask).toBeGreaterThanOrEqual(0);
expect(qr.mask).toBeLessThanOrEqual(7);
});
it('encodes text at different ECC levels', () => {
const qrL = encodeText('Test', LOW);
const qrM = encodeText('Test', MEDIUM);
const qrH = encodeText('Test', HIGH);
// Higher ECC needs same or higher version
expect(qrH.version).toBeGreaterThanOrEqual(qrL.version);
// All produce valid sizes
for (const qr of [qrL, qrM, qrH]) {
expect(qr.size).toBe(qr.version * 4 + 17);
}
});
it('encodes numeric-only text', () => {
const qr = encodeText('123456789012345', LOW);
expect(qr.version).toBe(1); // Numeric mode is compact
});
it('encodes a URL', () => {
const qr = encodeText('https://example.com/path?query=value', LOW);
expect(qr).toBeInstanceOf(QrCode);
expect(qr.size).toBeGreaterThanOrEqual(21);
});
it('encodes long text', () => {
const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.';
const qr = encodeText(longText, LOW);
expect(qr).toBeInstanceOf(QrCode);
});
it('throws for data too long', () => {
const tooLong = 'A'.repeat(10000);
expect(() => encodeText(tooLong, HIGH)).toThrow(RangeError);
});
});
describe('encodeBinary', () => {
it('encodes binary data', () => {
const data = [0x00, 0xFF, 0x48, 0x65, 0x6C, 0x6C, 0x6F];
const qr = encodeBinary(data, LOW);
expect(qr).toBeInstanceOf(QrCode);
});
});
describe('QrCode', () => {
it('modules grid has correct dimensions', () => {
const qr = encodeText('Test', LOW);
// Flat Uint8Array grid, verify via getModule
expect(qr.size).toBeGreaterThanOrEqual(21);
for (let y = 0; y < qr.size; y++) {
for (let x = 0; x < qr.size; x++) {
const mod = qr.getModule(x, y);
expect(typeof mod).toBe('boolean');
}
}
});
it('types grid has correct dimensions', () => {
const qr = encodeText('Test', LOW);
// Flat Int8Array grid, verify via getType
for (let y = 0; y < qr.size; y++) {
for (let x = 0; x < qr.size; x++) {
const t = qr.getType(x, y);
expect(typeof t).toBe('number');
}
}
});
it('getModule returns false for out of bounds', () => {
const qr = encodeText('Test', LOW);
expect(qr.getModule(-1, 0)).toBe(false);
expect(qr.getModule(0, -1)).toBe(false);
expect(qr.getModule(qr.size, 0)).toBe(false);
expect(qr.getModule(0, qr.size)).toBe(false);
});
it('produces deterministic output', () => {
const qr1 = encodeText('Hello', LOW);
const qr2 = encodeText('Hello', LOW);
expect(qr1.version).toBe(qr2.version);
expect(qr1.mask).toBe(qr2.mask);
for (let y = 0; y < qr1.size; y++) {
for (let x = 0; x < qr1.size; x++) {
expect(qr1.getModule(x, y)).toBe(qr2.getModule(x, y));
}
}
});
it('different inputs produce different outputs', () => {
const qr1 = encodeText('Hello', LOW);
const qr2 = encodeText('World', LOW);
// They might have the same version/size but different modules
let hasDiff = false;
for (let y = 0; y < qr1.size && !hasDiff; y++) {
for (let x = 0; x < qr1.size && !hasDiff; x++) {
if (qr1.getModule(x, y) !== qr2.getModule(x, y))
hasDiff = true;
}
}
expect(hasDiff).toBe(true);
});
});
describe('EccMap', () => {
it('has all four levels', () => {
expect(EccMap.L).toBeDefined();
expect(EccMap.M).toBeDefined();
expect(EccMap.Q).toBeDefined();
expect(EccMap.H).toBeDefined();
});
it('works with encodeText', () => {
const qr = encodeText('Test', EccMap.L);
expect(qr).toBeInstanceOf(QrCode);
});
});

View File

@@ -1,8 +0,0 @@
export { QrCodeDataType } from './types';
export type { QrCodeEcc, QrSegmentMode } from './types';
export { EccMap, LOW, MEDIUM, QUARTILE, HIGH } from './constants';
export { QrCode } from './qr-code';
export { QrSegment, makeBytes, makeSegments, isNumeric, isAlphanumeric } from './segment';
export { encodeText, encodeBinary, encodeSegments } from './encode';

View File

@@ -1,436 +0,0 @@
/*
* QR Code generator — core QrCode class
*
* Based on Project Nayuki's QR Code generator library (MIT License)
* https://www.nayuki.io/page/qr-code-generator-library
*/
import type { QrCodeEcc } from './types';
import { QrCodeDataType } from './types';
import { ECC_CODEWORDS_PER_BLOCK, MAX_VERSION, MIN_VERSION, NUM_ERROR_CORRECTION_BLOCKS } from './constants';
import { assert, getBit, getNumDataCodewords, getNumRawDataModules } from './utils';
import { computeDivisor, computeRemainder } from '../reed-solomon';
const PENALTY_N1 = 3;
const PENALTY_N2 = 3;
const PENALTY_N3 = 40;
const PENALTY_N4 = 10;
/**
* A QR Code symbol, which is a type of two-dimension barcode.
* Invented by Denso Wave and described in the ISO/IEC 18004 standard.
* Instances of this class represent an immutable square grid of dark and light cells.
*/
export class QrCode {
/** The width and height of this QR Code, measured in modules, between 21 and 177 (inclusive). */
public readonly size: number;
/** The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). */
public readonly mask: number;
/** The modules of this QR Code (0 = light, 1 = dark). Flat row-major Uint8Array. */
private readonly modules: Uint8Array;
/** Data type of each module. Flat row-major Int8Array. */
private readonly types: Int8Array;
/**
* Creates a new QR Code with the given version number, error correction level, data codeword bytes, and mask number.
* This is a low-level API that most users should not use directly.
*/
public constructor(
/** The version number of this QR Code, which is between 1 and 40 (inclusive). */
public readonly version: number,
/** The error correction level used in this QR Code. */
public readonly ecc: QrCodeEcc,
dataCodewords: Readonly<number[]>,
msk: number,
) {
if (version < MIN_VERSION || version > MAX_VERSION)
throw new RangeError('Version value out of range');
if (msk < -1 || msk > 7)
throw new RangeError('Mask value out of range');
this.size = version * 4 + 17;
const totalModules = this.size * this.size;
this.modules = new Uint8Array(totalModules);
this.types = new Int8Array(totalModules); // 0 = QrCodeDataType.Data
// Compute ECC, draw modules
this.drawFunctionPatterns();
const allCodewords = this.addEccAndInterleave(dataCodewords);
this.drawCodewords(allCodewords);
// Do masking
if (msk === -1) {
let minPenalty = 1_000_000_000;
for (let i = 0; i < 8; i++) {
this.applyMask(i);
this.drawFormatBits(i);
const penalty = this.getPenaltyScore();
if (penalty < minPenalty) {
msk = i;
minPenalty = penalty;
}
this.applyMask(i); // Undoes the mask due to XOR
}
}
assert(msk >= 0 && msk <= 7);
this.mask = msk;
this.applyMask(msk);
this.drawFormatBits(msk);
}
/**
* Returns the color of the module (pixel) at the given coordinates.
* false for light, true for dark. Out of bounds returns false (light).
*/
public getModule(x: number, y: number): boolean {
return x >= 0 && x < this.size && y >= 0 && y < this.size
&& this.modules[y * this.size + x] === 1;
}
/** Returns the data type of the module at the given coordinates. */
public getType(x: number, y: number): QrCodeDataType {
return this.types[y * this.size + x] as QrCodeDataType;
}
/* -- Private helper methods for constructor: Drawing function modules -- */
private drawFunctionPatterns(): void {
const size = this.size;
// Draw horizontal and vertical timing patterns
for (let i = 0; i < size; i++) {
const dark = i % 2 === 0 ? 1 : 0;
this.setFunctionModule(6, i, dark, QrCodeDataType.Timing);
this.setFunctionModule(i, 6, dark, QrCodeDataType.Timing);
}
// Draw 3 finder patterns (all corners except bottom right)
this.drawFinderPattern(3, 3);
this.drawFinderPattern(size - 4, 3);
this.drawFinderPattern(3, size - 4);
// Draw numerous alignment patterns
const alignPatPos = this.getAlignmentPatternPositions();
const numAlign = alignPatPos.length;
for (let i = 0; i < numAlign; i++) {
for (let j = 0; j < numAlign; j++) {
if (!(i === 0 && j === 0 || i === 0 && j === numAlign - 1 || i === numAlign - 1 && j === 0))
this.drawAlignmentPattern(alignPatPos[i]!, alignPatPos[j]!);
}
}
// Draw configuration data
this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor
this.drawVersion();
}
private drawFormatBits(mask: number): void {
const data = this.ecc[1] << 3 | mask;
let rem = data;
for (let i = 0; i < 10; i++)
rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const bits = (data << 10 | rem) ^ 0x5412;
assert(bits >>> 15 === 0);
const size = this.size;
// Draw first copy
for (let i = 0; i <= 5; i++)
this.setFunctionModule(8, i, getBit(bits, i) ? 1 : 0);
this.setFunctionModule(8, 7, getBit(bits, 6) ? 1 : 0);
this.setFunctionModule(8, 8, getBit(bits, 7) ? 1 : 0);
this.setFunctionModule(7, 8, getBit(bits, 8) ? 1 : 0);
for (let i = 9; i < 15; i++)
this.setFunctionModule(14 - i, 8, getBit(bits, i) ? 1 : 0);
// Draw second copy
for (let i = 0; i < 8; i++)
this.setFunctionModule(size - 1 - i, 8, getBit(bits, i) ? 1 : 0);
for (let i = 8; i < 15; i++)
this.setFunctionModule(8, size - 15 + i, getBit(bits, i) ? 1 : 0);
this.setFunctionModule(8, size - 8, 1);
}
private drawVersion(): void {
if (this.version < 7)
return;
let rem = this.version;
for (let i = 0; i < 12; i++)
rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25);
const bits = this.version << 12 | rem;
assert(bits >>> 18 === 0);
const size = this.size;
for (let i = 0; i < 18; i++) {
const color = getBit(bits, i) ? 1 : 0;
const a = size - 11 + i % 3;
const b = (i / 3) | 0;
this.setFunctionModule(a, b, color);
this.setFunctionModule(b, a, color);
}
}
private drawFinderPattern(x: number, y: number): void {
const size = this.size;
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const dist = Math.max(Math.abs(dx), Math.abs(dy));
const xx = x + dx;
const yy = y + dy;
if (xx >= 0 && xx < size && yy >= 0 && yy < size)
this.setFunctionModule(xx, yy, dist !== 2 && dist !== 4 ? 1 : 0, QrCodeDataType.Position);
}
}
}
private drawAlignmentPattern(x: number, y: number): void {
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
this.setFunctionModule(
x + dx,
y + dy,
Math.max(Math.abs(dx), Math.abs(dy)) !== 1 ? 1 : 0,
QrCodeDataType.Alignment,
);
}
}
}
private setFunctionModule(x: number, y: number, isDark: number, type: QrCodeDataType = QrCodeDataType.Function): void {
const idx = y * this.size + x;
this.modules[idx] = isDark;
this.types[idx] = type;
}
/* -- Private helper methods for constructor: Codewords and masking -- */
private addEccAndInterleave(data: Readonly<number[]>): number[] {
const ver = this.version;
const ecl = this.ecc;
if (data.length !== getNumDataCodewords(ver, ecl))
throw new RangeError('Invalid argument');
const numBlocks = NUM_ERROR_CORRECTION_BLOCKS[ecl[0]]![ver]!;
const blockEccLen = ECC_CODEWORDS_PER_BLOCK[ecl[0]]![ver]!;
const rawCodewords = (getNumRawDataModules(ver) / 8) | 0;
const numShortBlocks = numBlocks - rawCodewords % numBlocks;
const shortBlockLen = (rawCodewords / numBlocks) | 0;
// Split data into blocks and append ECC to each block
const blocks: number[][] = [];
const rsDiv = computeDivisor(blockEccLen);
for (let i = 0, k = 0; i < numBlocks; i++) {
const dat: number[] = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1)) as number[];
k += dat.length;
const ecc = computeRemainder(dat, rsDiv);
if (i < numShortBlocks)
dat.push(0);
blocks.push([...dat, ...ecc]);
}
// Interleave (not concatenate) the bytes from every block into a single sequence
const result: number[] = [];
const blockLen = blocks[0]!.length;
for (let i = 0; i < blockLen; i++) {
for (let j = 0; j < blocks.length; j++) {
if (i !== shortBlockLen - blockEccLen || j >= numShortBlocks)
result.push(blocks[j]![i]!);
}
}
assert(result.length === rawCodewords);
return result;
}
private drawCodewords(data: Readonly<number[]>): void {
if (data.length !== ((getNumRawDataModules(this.version) / 8) | 0))
throw new RangeError('Invalid argument');
const size = this.size;
const modules = this.modules;
const types = this.types;
let i = 0;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6)
right = 5;
for (let vert = 0; vert < size; vert++) {
for (let j = 0; j < 2; j++) {
const x = right - j;
const upward = ((right + 1) & 2) === 0;
const y = upward ? size - 1 - vert : vert;
const idx = y * size + x;
if (types[idx] === QrCodeDataType.Data && i < data.length * 8) {
modules[idx] = (data[i >>> 3]! >>> (7 - (i & 7))) & 1;
i++;
}
}
}
}
assert(i === data.length * 8);
}
private applyMask(mask: number): void {
if (mask < 0 || mask > 7)
throw new RangeError('Mask value out of range');
const size = this.size;
const modules = this.modules;
const types = this.types;
for (let y = 0; y < size; y++) {
const yOffset = y * size;
for (let x = 0; x < size; x++) {
const idx = yOffset + x;
if (types[idx] !== QrCodeDataType.Data)
continue;
let invert: boolean;
switch (mask) {
case 0: invert = (x + y) % 2 === 0; break;
case 1: invert = y % 2 === 0; break;
case 2: invert = x % 3 === 0; break;
case 3: invert = (x + y) % 3 === 0; break;
case 4: invert = (((x / 3) | 0) + ((y / 2) | 0)) % 2 === 0; break;
case 5: invert = x * y % 2 + x * y % 3 === 0; break;
case 6: invert = (x * y % 2 + x * y % 3) % 2 === 0; break;
case 7: invert = ((x + y) % 2 + x * y % 3) % 2 === 0; break;
default: throw new Error('Unreachable');
}
if (invert)
modules[idx]! ^= 1;
}
}
}
private getPenaltyScore(): number {
const size = this.size;
const modules = this.modules;
let result = 0;
// Adjacent modules in row having same color, and finder-like patterns
for (let y = 0; y < size; y++) {
const yOffset = y * size;
let runColor = 0;
let runX = 0;
let h0 = 0, h1 = 0, h2 = 0, h3 = 0, h4 = 0, h5 = 0, h6 = 0;
for (let x = 0; x < size; x++) {
const mod = modules[yOffset + x]!;
if (mod === runColor) {
runX++;
if (runX === 5)
result += PENALTY_N1;
else if (runX > 5)
result++;
}
else {
// finderPenaltyAddHistory inlined
if (h0 === 0) runX += size;
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = runX; h0 = runX;
// finderPenaltyCountPatterns inlined (only when runColor is light = 0)
if (runColor === 0) {
const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1;
if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3;
if (core && h6 >= h1 * 4 && h0 >= h1) result += PENALTY_N3;
}
runColor = mod;
runX = 1;
}
}
// finderPenaltyTerminateAndCount inlined
{
let currentRunLength = runX;
if (runColor === 1) {
if (h0 === 0) currentRunLength += size;
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength;
currentRunLength = 0;
}
currentRunLength += size;
if (h0 === 0) currentRunLength += size;
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength;
const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1;
if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3;
if (core && h6 >= h1 * 4 && h0 >= h1) result += PENALTY_N3;
}
}
// Adjacent modules in column having same color, and finder-like patterns
for (let x = 0; x < size; x++) {
let runColor = 0;
let runY = 0;
let h0 = 0, h1 = 0, h2 = 0, h3 = 0, h4 = 0, h5 = 0, h6 = 0;
for (let y = 0; y < size; y++) {
const mod = modules[y * size + x]!;
if (mod === runColor) {
runY++;
if (runY === 5)
result += PENALTY_N1;
else if (runY > 5)
result++;
}
else {
if (h0 === 0) runY += size;
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = runY; h0 = runY;
if (runColor === 0) {
const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1;
if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3;
if (core && h6 >= h1 * 4 && h0 >= h1) result += PENALTY_N3;
}
runColor = mod;
runY = 1;
}
}
{
let currentRunLength = runY;
if (runColor === 1) {
if (h0 === 0) currentRunLength += size;
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength;
currentRunLength = 0;
}
currentRunLength += size;
if (h0 === 0) currentRunLength += size;
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = currentRunLength; h0 = currentRunLength;
const core = h1 > 0 && h2 === h1 && h3 === h1 * 3 && h4 === h1 && h5 === h1;
if (core && h0 >= h1 * 4 && h6 >= h1) result += PENALTY_N3;
if (core && h6 >= h1 * 4 && h0 >= h1) result += PENALTY_N3;
}
}
// 2*2 blocks of modules having same color
for (let y = 0; y < size - 1; y++) {
const yOffset = y * size;
const nextYOffset = yOffset + size;
for (let x = 0; x < size - 1; x++) {
const color = modules[yOffset + x]!;
if (color === modules[yOffset + x + 1]
&& color === modules[nextYOffset + x]
&& color === modules[nextYOffset + x + 1])
result += PENALTY_N2;
}
}
// Balance of dark and light modules
let dark = 0;
const total = size * size;
for (let i = 0; i < total; i++)
dark += modules[i]!;
const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
assert(k >= 0 && k <= 9);
result += k * PENALTY_N4;
assert(result >= 0 && result <= 2568888);
return result;
}
private getAlignmentPatternPositions(): number[] {
if (this.version === 1)
return [];
const numAlign = ((this.version / 7) | 0)
+ 2;
const step = (this.version === 32)
? 26
: Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2;
const result = [6];
for (let pos = this.size - 7; result.length < numAlign; pos -= step)
result.splice(1, 0, pos);
return result;
}
}

View File

@@ -1,82 +0,0 @@
import type { QrSegmentMode } from './types';
import { ALPHANUMERIC_MAP, ALPHANUMERIC_REGEX, MODE_ALPHANUMERIC, MODE_BYTE, MODE_NUMERIC, NUMERIC_REGEX } from './constants';
import { appendBits, toUtf8ByteArray } from './utils';
/**
* A segment of character/binary/control data in a QR Code symbol.
* Instances of this class are immutable.
*/
export class QrSegment {
public constructor(
/** The mode indicator of this segment. */
public readonly mode: QrSegmentMode,
/** The length of this segment's unencoded data. */
public readonly numChars: number,
/** The data bits of this segment. */
public readonly bitData: readonly number[],
) {
if (numChars < 0)
throw new RangeError('Invalid argument');
}
}
/** Returns a segment representing the given binary data encoded in byte mode. */
export function makeBytes(data: ArrayLike<number>): QrSegment {
const bb: number[] = [];
for (let i = 0, len = data.length; i < len; i++)
appendBits(data[i]!, 8, bb);
return new QrSegment(MODE_BYTE, data.length, bb);
}
/** Returns a segment representing the given string of decimal digits encoded in numeric mode. */
export function makeNumeric(digits: string): QrSegment {
if (!isNumeric(digits))
throw new RangeError('String contains non-numeric characters');
const bb: number[] = [];
for (let i = 0; i < digits.length;) {
const n = Math.min(digits.length - i, 3);
appendBits(Number.parseInt(digits.slice(i, i + n), 10), n * 3 + 1, bb);
i += n;
}
return new QrSegment(MODE_NUMERIC, digits.length, bb);
}
/** Returns a segment representing the given text string encoded in alphanumeric mode. */
export function makeAlphanumeric(text: string): QrSegment {
if (!isAlphanumeric(text))
throw new RangeError('String contains unencodable characters in alphanumeric mode');
const bb: number[] = [];
let i: number;
for (i = 0; i + 2 <= text.length; i += 2) {
let temp = ALPHANUMERIC_MAP[text.charCodeAt(i)]! * 45;
temp += ALPHANUMERIC_MAP[text.charCodeAt(i + 1)]!;
appendBits(temp, 11, bb);
}
if (i < text.length)
appendBits(ALPHANUMERIC_MAP[text.charCodeAt(i)]!, 6, bb);
return new QrSegment(MODE_ALPHANUMERIC, text.length, bb);
}
/**
* Returns a new mutable list of zero or more segments to represent the given Unicode text string.
* The result may use various segment modes and switch modes to optimize the length of the bit stream.
*/
export function makeSegments(text: string): QrSegment[] {
if (text === '')
return [];
if (isNumeric(text))
return [makeNumeric(text)];
if (isAlphanumeric(text))
return [makeAlphanumeric(text)];
return [makeBytes(toUtf8ByteArray(text))];
}
/** Tests whether the given string can be encoded as a segment in numeric mode. */
export function isNumeric(text: string): boolean {
return NUMERIC_REGEX.test(text);
}
/** Tests whether the given string can be encoded as a segment in alphanumeric mode. */
export function isAlphanumeric(text: string): boolean {
return ALPHANUMERIC_REGEX.test(text);
}

View File

@@ -1,17 +0,0 @@
export type QrCodeEcc = readonly [ordinal: number, formatBits: number];
export type QrSegmentMode = [
modeBits: number,
numBitsCharCount1: number,
numBitsCharCount2: number,
numBitsCharCount3: number,
];
export enum QrCodeDataType {
Border = -1,
Data = 0,
Function = 1,
Position = 2,
Timing = 3,
Alignment = 4,
}

View File

@@ -1,79 +0,0 @@
import type { QrCodeEcc, QrSegmentMode } from './types';
import { ECC_CODEWORDS_PER_BLOCK, MAX_VERSION, MIN_VERSION, NUM_ERROR_CORRECTION_BLOCKS } from './constants';
import type { QrSegment } from './segment';
const utf8Encoder = new TextEncoder();
/** Appends the given number of low-order bits of the given value to the buffer. */
export function appendBits(val: number, len: number, bb: number[]): void {
if (len < 0 || len > 31 || val >>> len !== 0)
throw new RangeError('Value out of range');
for (let i = len - 1; i >= 0; i--)
bb.push((val >>> i) & 1);
}
/** Returns true iff the i'th bit of x is set to 1. */
export function getBit(x: number, i: number): boolean {
return ((x >>> i) & 1) !== 0;
}
/** Throws an exception if the given condition is false. */
export function assert(cond: boolean): asserts cond {
if (!cond)
throw new Error('Assertion error');
}
/** Returns a Uint8Array representing the given string encoded in UTF-8. */
export function toUtf8ByteArray(str: string): Uint8Array {
return utf8Encoder.encode(str);
}
/** Returns the bit width of the character count field for a segment in this mode at the given version number. */
export function numCharCountBits(mode: QrSegmentMode, ver: number): number {
return mode[((ver + 7) / 17 | 0) + 1]!;
}
/**
* Returns the number of data bits that can be stored in a QR Code of the given version number,
* after all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
* The result is in the range [208, 29648].
*/
export function getNumRawDataModules(ver: number): number {
if (ver < MIN_VERSION || ver > MAX_VERSION)
throw new RangeError('Version number out of range');
let result = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
const numAlign = (ver / 7 | 0) + 2;
result -= (25 * numAlign - 10) * numAlign - 55;
if (ver >= 7)
result -= 36;
}
assert(result >= 208 && result <= 29648);
return result;
}
/**
* Returns the number of 8-bit data (i.e. not error correction) codewords contained in any
* QR Code of the given version number and error correction level, with remainder bits discarded.
*/
export function getNumDataCodewords(ver: number, ecl: QrCodeEcc): number {
return (getNumRawDataModules(ver) / 8 | 0)
- ECC_CODEWORDS_PER_BLOCK[ecl[0]]![ver]!
* NUM_ERROR_CORRECTION_BLOCKS[ecl[0]]![ver]!;
}
/**
* Calculates and returns the number of bits needed to encode the given segments at the given version.
* The result is infinity if a segment has too many characters to fit its length field.
*/
export function getTotalBits(segs: Readonly<QrSegment[]>, version: number): number {
let result = 0;
for (const seg of segs) {
const ccbits = numCharCountBits(seg.mode, version);
if (seg.numChars >= (1 << ccbits))
return Number.POSITIVE_INFINITY;
result += 4 + ccbits + seg.bitData.length;
}
return result;
}

View File

@@ -1,100 +0,0 @@
import { describe, expect, it } from 'vitest';
import { computeDivisor, computeRemainder, multiply } from '.';
describe('multiply', () => {
it('multiplies zero by anything to get zero', () => {
expect(multiply(0, 0)).toBe(0);
expect(multiply(0, 1)).toBe(0);
expect(multiply(0, 255)).toBe(0);
expect(multiply(1, 0)).toBe(0);
});
it('multiplies by one (identity)', () => {
expect(multiply(1, 1)).toBe(1);
expect(multiply(1, 42)).toBe(42);
expect(multiply(42, 1)).toBe(42);
expect(multiply(1, 255)).toBe(255);
});
it('is commutative', () => {
expect(multiply(5, 7)).toBe(multiply(7, 5));
expect(multiply(0x53, 0xCA)).toBe(multiply(0xCA, 0x53));
expect(multiply(100, 200)).toBe(multiply(200, 100));
});
it('produces known GF(2^8) products', () => {
expect(multiply(2, 2)).toBe(4);
expect(multiply(2, 0x80)).toBe(0x1D);
});
it('throws on out of range inputs', () => {
expect(() => multiply(256, 0)).toThrow(RangeError);
expect(() => multiply(0, 256)).toThrow(RangeError);
expect(() => multiply(1000, 1000)).toThrow(RangeError);
});
});
describe('computeDivisor', () => {
it('computes a degree-1 divisor', () => {
expect(computeDivisor(1)).toEqual(Uint8Array.from([1]));
});
it('computes a degree-2 divisor', () => {
const result = computeDivisor(2);
expect(result).toHaveLength(2);
expect(result).toEqual(Uint8Array.from([3, 2]));
});
it('has correct length for arbitrary degrees', () => {
expect(computeDivisor(7)).toHaveLength(7);
expect(computeDivisor(10)).toHaveLength(10);
expect(computeDivisor(30)).toHaveLength(30);
});
it('returns Uint8Array', () => {
expect(computeDivisor(5)).toBeInstanceOf(Uint8Array);
});
it('throws on degree out of range', () => {
expect(() => computeDivisor(0)).toThrow(RangeError);
expect(() => computeDivisor(256)).toThrow(RangeError);
expect(() => computeDivisor(-1)).toThrow(RangeError);
});
});
describe('computeRemainder', () => {
it('returns zero remainder for empty data', () => {
const divisor = computeDivisor(4);
const result = computeRemainder([], divisor);
expect(result).toEqual(new Uint8Array(4));
});
it('produces non-zero remainder for non-empty data', () => {
const divisor = computeDivisor(7);
const data = [0x40, 0xD2, 0x75, 0x47, 0x76, 0x17, 0x32, 0x06, 0x27, 0x26, 0x96, 0xC6, 0xC6, 0x96, 0x70, 0xEC];
const result = computeRemainder(data, divisor);
expect(result).toHaveLength(7);
expect(result).toBeInstanceOf(Uint8Array);
for (const b of result) {
expect(b).toBeGreaterThanOrEqual(0);
expect(b).toBeLessThanOrEqual(255);
}
});
it('accepts Uint8Array as data input', () => {
const divisor = computeDivisor(7);
const data = Uint8Array.from([0x40, 0xD2, 0x75, 0x47]);
const result = computeRemainder(data, divisor);
expect(result).toHaveLength(7);
expect(result).toBeInstanceOf(Uint8Array);
});
it('remainder length matches divisor length', () => {
for (const degree of [1, 5, 10, 20]) {
const divisor = computeDivisor(degree);
const data = [1, 2, 3, 4, 5];
const result = computeRemainder(data, divisor);
expect(result).toHaveLength(degree);
}
});
});

View File

@@ -1,92 +0,0 @@
/*
* Reed-Solomon error correction over GF(2^8/0x11D)
*
* Based on Project Nayuki's QR Code generator library (MIT License)
* https://www.nayuki.io/page/qr-code-generator-library
*/
/* -- GF(2^8) exp/log lookup tables (generator α=0x02, primitive polynomial 0x11D) -- */
const GF_EXP = new Uint8Array(256);
const GF_LOG = new Uint8Array(256);
{
let x = 1;
for (let i = 0; i < 255; i++) {
GF_EXP[i] = x;
GF_LOG[x] = i;
x = (x << 1) ^ ((x >>> 7) * 0x11D);
}
GF_EXP[255] = GF_EXP[0]!;
}
/**
* Returns the product of the two given field elements modulo GF(2^8/0x11D).
* The arguments and result are unsigned 8-bit integers.
*/
export function multiply(x: number, y: number): number {
if (x >>> 8 !== 0 || y >>> 8 !== 0)
throw new RangeError('Byte out of range');
if (x === 0 || y === 0)
return 0;
return GF_EXP[(GF_LOG[x]! + GF_LOG[y]!) % 255]!;
}
/**
* Returns a Reed-Solomon ECC generator polynomial for the given degree.
*
* Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
* For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93].
*/
export function computeDivisor(degree: number): Uint8Array {
if (degree < 1 || degree > 255)
throw new RangeError('Degree out of range');
const result = new Uint8Array(degree);
result[degree - 1] = 1;
// Compute the product polynomial (x - r^0) * (x - r^1) * ... * (x - r^{degree-1}),
// dropping the leading term which is always 1x^degree.
// r = 0x02, a generator element of GF(2^8/0x11D).
let root = 0; // GF_LOG[1] = 0, i.e. α^0 = 1
for (let i = 0; i < degree; i++) {
// Multiply the current product by (x - r^i)
for (let j = 0; j < degree; j++) {
// result[j] = multiply(result[j], α^root) — inlined for performance
if (result[j] !== 0)
result[j] = GF_EXP[(GF_LOG[result[j]!]! + root) % 255]!;
if (j + 1 < degree)
result[j]! ^= result[j + 1]!;
}
root = (root + 1) % 255; // root tracks log(α^i) = i mod 255
}
return result;
}
/**
* Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials.
*/
export function computeRemainder(data: ArrayLike<number>, divisor: Uint8Array): Uint8Array {
const len = divisor.length;
const result = new Uint8Array(len);
for (let d = 0, dLen = data.length; d < dLen; d++) {
const factor = data[d]! ^ result[0]!;
// Shift left by 1 position (native memcpy)
result.copyWithin(0, 1);
result[len - 1] = 0;
// XOR with divisor scaled by factor — inlined GF multiply for performance
if (factor !== 0) {
const logFactor = GF_LOG[factor]!;
for (let i = 0; i < len; i++) {
if (divisor[i] !== 0)
result[i]! ^= GF_EXP[(GF_LOG[divisor[i]!]! + logFactor) % 255]!;
}
}
}
return result;
}

View File

@@ -1,3 +0,0 @@
{
"extends": "@robonen/tsconfig/tsconfig.json"
}

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
entry: ['src/index.ts'],
});

View File

@@ -1,23 +0,0 @@
# @robonen/platform
Platform-dependent utilities for browser & multi-runtime environments.
## Install
```bash
pnpm install @robonen/platform
```
## Modules
| Entry | Utilities | Description |
| ------------------ | ------------- | -------------------------------- |
| `@robonen/platform/browsers` | `focusGuard` | Browser-specific helpers |
| `@robonen/platform/multi` | `global` | Cross-runtime (Node/Bun/Deno) utilities |
## Usage
```ts
import { focusGuard } from '@robonen/platform/browsers';
import { global } from '@robonen/platform/multi';
```

View File

@@ -1,15 +0,0 @@
import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
export default defineConfig(
compose(base, typescript, imports, stylistic, {
overrides: [
{
files: ['src/multi/global/index.ts'],
rules: {
'unicorn/prefer-global-this': 'off',
},
},
],
}),
);

View File

@@ -1 +0,0 @@
export * from './focusGuard';

View File

@@ -1,2 +0,0 @@
export * from './global';
// export * from './debounce';

View File

@@ -1,10 +0,0 @@
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
entry: {
browsers: 'src/browsers/index.ts',
multi: 'src/multi/index.ts',
},
});

View File

@@ -1,32 +0,0 @@
# @robonen/stdlib
Standard library of platform-independent utilities for TypeScript.
## Install
```bash
pnpm install @robonen/stdlib
```
## Modules
| Module | Utilities |
| --------------- | --------------------------------------------------------------- |
| **arrays** | `cluster`, `first`, `last`, `sum`, `unique` |
| **async** | `sleep`, `tryIt` |
| **bits** | `flags` |
| **collections** | `get` |
| **math** | `clamp`, `lerp`, `remap` + BigInt variants |
| **objects** | `omit`, `pick` |
| **patterns** | `pubsub` |
| **structs** | `stack` |
| **sync** | `mutex` |
| **text** | `levenshteinDistance`, `trigramDistance` |
| **types** | JS & TS type utilities |
| **utils** | `timestamp`, `noop` |
## Usage
```ts
import { first, sleep, clamp } from '@robonen/stdlib';
```

View File

@@ -1,4 +0,0 @@
import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
export default defineConfig(compose(base, typescript, imports, stylistic));

View File

@@ -1,3 +0,0 @@
export interface AsyncPoolOptions {
concurrency?: number;
}

View File

@@ -1,39 +0,0 @@
// eslint-disable
export interface RetryOptions {
times?: number;
delay?: number;
backoff: (options: RetryOptions & { count: number }) => number;
}
/**
* @name retry
* @category Async
* @description Retries a function a specified number of times with a delay between each retry
*
* @param {Promise<unknown>} fn - The function to retry
* @param {RetryOptions} options - The options for the retry
* @returns {Promise<unknown>} - The result of the function
*
* @example
* const result = await retry(() => {
* return fetch('https://jsonplaceholder.typicode.com/todos/1')
* .then(response => response.json())
* });
*
* @example
* const result = await retry(() => {
* return fetch('https://jsonplaceholder.typicode.com/todos/1')
* .then(response => response.json())
* }, { times: 3, delay: 1000 });
*
*/
export async function retry<Return>(
fn: () => Promise<Return>,
options: RetryOptions
) {
const {
times = 3,
} = options;
let count = 0;
}

View File

@@ -1,22 +0,0 @@
/**
* @name flagsGenerator
* @category Bits
* @description Create a function that generates unique flags
*
* @returns {Function} A function that generates unique flags
* @throws {RangeError} If more than 31 flags are created
*
* @since 0.0.2
*/
export function flagsGenerator() {
let lastFlag = 0;
return () => {
// 31 flags is the maximum number of flags that can be created
// (without zero) because of the 32-bit integer limit in bitwise operations
if (lastFlag & 0x40000000)
throw new RangeError('Cannot create more than 31 flags');
return (lastFlag = lastFlag === 0 ? 1 : lastFlag << 1);
};
}

View File

@@ -1 +0,0 @@
export * from './flags';

View File

@@ -1,34 +0,0 @@
import type { Collection, Path } from '../../types';
export type ExtractFromObject<O extends Record<PropertyKey, unknown>, K>
= K extends keyof O
? O[K]
: K extends keyof NonNullable<O>
? NonNullable<O>[K]
: never;
export type ExtractFromArray<A extends readonly any[], K>
= any[] extends A
? A extends ReadonlyArray<infer T>
? T | undefined
: undefined
: K extends keyof A
? A[K]
: undefined;
export type ExtractFromCollection<O, K>
= K extends []
? O
: K extends [infer Key, ...infer Rest]
? O extends Record<PropertyKey, unknown>
? ExtractFromCollection<ExtractFromObject<O, Key>, Rest>
: O extends readonly any[]
? ExtractFromCollection<ExtractFromArray<O, Key>, Rest>
: never
: never;
type Get<O, K> = ExtractFromCollection<O, Path<K>>;
export function get<O extends Collection, K extends string>(obj: O, path: K) {
return path.split('.').reduce((acc, key) => (acc as any)?.[key], obj) as Get<O, K> | undefined;
}

View File

@@ -1 +0,0 @@
export * from './get';

View File

@@ -1,55 +0,0 @@
import { BaseCommandHistory } from './base';
import type { AsyncCommand } from './types';
/**
* @name AsyncCommandHistory
* @category Patterns
* @description Async command history with undo/redo/batch support
*
* @since 0.0.8
*/
export class AsyncCommandHistory extends BaseCommandHistory<AsyncCommand> {
async execute(command: AsyncCommand): Promise<void> {
await command.execute();
this.pushUndo(command);
}
async undo(): Promise<boolean> {
const command = this.undoStack.pop();
if (!command)
return false;
await command.undo();
this.moveToRedo(command);
return true;
}
async redo(): Promise<boolean> {
const command = this.redoStack.pop();
if (!command)
return false;
await command.execute();
this.moveToUndo(command);
return true;
}
async batch(commands: AsyncCommand[]): Promise<void> {
const macro: AsyncCommand = {
execute: async () => {
for (const c of commands)
await c.execute();
},
undo: async () => {
for (let i = commands.length - 1; i >= 0; i--)
await commands[i]!.undo();
},
};
await this.execute(macro);
}
}

View File

@@ -1,49 +0,0 @@
/**
* @name BaseCommandHistory
* @category Patterns
* @description Base class with shared undo/redo stack management
*
* @since 0.0.8
*/
export abstract class BaseCommandHistory<C> {
protected undoStack: C[] = [];
protected redoStack: C[] = [];
protected readonly maxSize: number;
constructor(options?: { maxSize?: number }) {
this.maxSize = options?.maxSize ?? Infinity;
}
get size(): number {
return this.undoStack.length;
}
get canUndo(): boolean {
return this.undoStack.length > 0;
}
get canRedo(): boolean {
return this.redoStack.length > 0;
}
clear(): void {
this.undoStack.length = 0;
this.redoStack.length = 0;
}
protected pushUndo(command: C): void {
this.undoStack.push(command);
this.redoStack.length = 0;
if (this.undoStack.length > this.maxSize)
this.undoStack.splice(0, this.undoStack.length - this.maxSize);
}
protected moveToRedo(command: C): void {
this.redoStack.push(command);
}
protected moveToUndo(command: C): void {
this.undoStack.push(command);
}
}

View File

@@ -1,281 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CommandHistory, AsyncCommandHistory } from '.';
import type { Command, AsyncCommand } from '.';
describe('commandHistory', () => {
let history: CommandHistory;
let items: string[];
function addItem(item: string): Command {
return {
execute: () => { items.push(item); },
undo: () => { items.pop(); },
};
}
beforeEach(() => {
history = new CommandHistory();
items = [];
});
describe('execute', () => {
it('executes a command', () => {
history.execute(addItem('a'));
expect(items).toEqual(['a']);
});
it('tracks size', () => {
history.execute(addItem('a'));
history.execute(addItem('b'));
expect(history.size).toBe(2);
});
it('clears redo stack on new execute', () => {
history.execute(addItem('a'));
history.undo();
expect(history.canRedo).toBe(true);
history.execute(addItem('b'));
expect(history.canRedo).toBe(false);
});
});
describe('undo', () => {
it('undoes the last command', () => {
history.execute(addItem('a'));
history.execute(addItem('b'));
history.undo();
expect(items).toEqual(['a']);
});
it('returns true when undo was performed', () => {
history.execute(addItem('a'));
expect(history.undo()).toBe(true);
});
it('returns false when nothing to undo', () => {
expect(history.undo()).toBe(false);
});
it('multiple undos', () => {
history.execute(addItem('a'));
history.execute(addItem('b'));
history.execute(addItem('c'));
history.undo();
history.undo();
history.undo();
expect(items).toEqual([]);
expect(history.canUndo).toBe(false);
});
});
describe('redo', () => {
it('redoes the last undone command', () => {
history.execute(addItem('a'));
history.undo();
history.redo();
expect(items).toEqual(['a']);
});
it('returns true when redo was performed', () => {
history.execute(addItem('a'));
history.undo();
expect(history.redo()).toBe(true);
});
it('returns false when nothing to redo', () => {
expect(history.redo()).toBe(false);
});
it('undo then redo multiple times', () => {
history.execute(addItem('a'));
history.execute(addItem('b'));
history.undo();
history.undo();
history.redo();
history.redo();
expect(items).toEqual(['a', 'b']);
});
});
describe('batch', () => {
it('executes multiple commands', () => {
history.batch([addItem('a'), addItem('b'), addItem('c')]);
expect(items).toEqual(['a', 'b', 'c']);
});
it('undoes all batched commands in one step', () => {
history.batch([addItem('a'), addItem('b'), addItem('c')]);
history.undo();
expect(items).toEqual([]);
});
it('counts as single history entry', () => {
history.batch([addItem('a'), addItem('b'), addItem('c')]);
expect(history.size).toBe(1);
});
it('undoes batch in reverse order', () => {
const order: string[] = [];
history.batch([
{ execute: () => order.push('exec-1'), undo: () => order.push('undo-1') },
{ execute: () => order.push('exec-2'), undo: () => order.push('undo-2') },
{ execute: () => order.push('exec-3'), undo: () => order.push('undo-3') },
]);
history.undo();
expect(order).toEqual(['exec-1', 'exec-2', 'exec-3', 'undo-3', 'undo-2', 'undo-1']);
});
});
describe('maxSize', () => {
it('limits undo stack', () => {
const limited = new CommandHistory({ maxSize: 2 });
items = [];
limited.execute(addItem('a'));
limited.execute(addItem('b'));
limited.execute(addItem('c'));
expect(limited.size).toBe(2);
expect(limited.canUndo).toBe(true);
limited.undo();
limited.undo();
expect(limited.canUndo).toBe(false);
expect(items).toEqual(['a']);
});
});
describe('clear', () => {
it('clears all history', () => {
history.execute(addItem('a'));
history.execute(addItem('b'));
history.undo();
history.clear();
expect(history.canUndo).toBe(false);
expect(history.canRedo).toBe(false);
expect(history.size).toBe(0);
});
});
describe('canUndo / canRedo', () => {
it('initially false', () => {
expect(history.canUndo).toBe(false);
expect(history.canRedo).toBe(false);
});
it('canUndo after execute', () => {
history.execute(addItem('a'));
expect(history.canUndo).toBe(true);
expect(history.canRedo).toBe(false);
});
it('canRedo after undo', () => {
history.execute(addItem('a'));
history.undo();
expect(history.canUndo).toBe(false);
expect(history.canRedo).toBe(true);
});
});
});
describe('asyncCommandHistory', () => {
let history: AsyncCommandHistory;
let items: string[];
function addItemAsync(item: string): AsyncCommand {
return {
execute: async () => {
await new Promise(r => setTimeout(r, 5));
items.push(item);
},
undo: async () => {
await new Promise(r => setTimeout(r, 5));
items.pop();
},
};
}
beforeEach(() => {
history = new AsyncCommandHistory();
items = [];
});
it('executes async command', async () => {
await history.execute(addItemAsync('a'));
expect(items).toEqual(['a']);
});
it('undoes async command', async () => {
await history.execute(addItemAsync('a'));
await history.undo();
expect(items).toEqual([]);
});
it('redoes async command', async () => {
await history.execute(addItemAsync('a'));
await history.undo();
await history.redo();
expect(items).toEqual(['a']);
});
it('batches async commands', async () => {
await history.batch([addItemAsync('a'), addItemAsync('b'), addItemAsync('c')]);
expect(items).toEqual(['a', 'b', 'c']);
expect(history.size).toBe(1);
});
it('undoes async batch', async () => {
await history.batch([addItemAsync('a'), addItemAsync('b')]);
await history.undo();
expect(items).toEqual([]);
});
it('works with sync commands too', async () => {
await history.execute({
execute: () => { items.push('sync'); },
undo: () => { items.pop(); },
});
expect(items).toEqual(['sync']);
await history.undo();
expect(items).toEqual([]);
});
it('respects maxSize', async () => {
const limited = new AsyncCommandHistory({ maxSize: 2 });
items = [];
await limited.execute(addItemAsync('a'));
await limited.execute(addItemAsync('b'));
await limited.execute(addItemAsync('c'));
expect(limited.size).toBe(2);
});
});

View File

@@ -1,3 +0,0 @@
export type * from './types';
export * from './sync';
export * from './async';

View File

@@ -1,52 +0,0 @@
import { BaseCommandHistory } from './base';
import type { Command } from './types';
/**
* @name CommandHistory
* @category Patterns
* @description Command history with undo/redo/batch support
*
* @since 0.0.8
*/
export class CommandHistory extends BaseCommandHistory<Command> {
execute(command: Command): void {
command.execute();
this.pushUndo(command);
}
undo(): boolean {
const command = this.undoStack.pop();
if (!command)
return false;
command.undo();
this.moveToRedo(command);
return true;
}
redo(): boolean {
const command = this.redoStack.pop();
if (!command)
return false;
command.execute();
this.moveToUndo(command);
return true;
}
batch(commands: Command[]): void {
const macro: Command = {
execute: () => commands.forEach(c => c.execute()),
undo: () => {
for (let i = commands.length - 1; i >= 0; i--)
commands[i]!.undo();
},
};
this.execute(macro);
}
}

View File

@@ -1,11 +0,0 @@
import type { MaybePromise } from '../../../types';
export interface Command {
execute: () => void;
undo: () => void;
}
export interface AsyncCommand {
execute: () => MaybePromise<void>;
undo: () => MaybePromise<void>;
}

View File

@@ -1,134 +0,0 @@
import { isString } from '../../../types';
import { BaseStateMachine } from './base';
import type { AsyncStateNodeConfig, ExtractStates, ExtractEvents } from './types';
/**
* @name AsyncStateMachine
* @category Patterns
* @description Async finite state machine with support for async guards, actions, and hooks
*
* @since 0.0.8
*
* @template States - Union of state names
* @template Events - Union of event names
* @template Context - Machine context type
*/
export class AsyncStateMachine<
States extends string = string,
Events extends string = string,
Context = undefined,
> extends BaseStateMachine<States, Events, Context, AsyncStateNodeConfig<Context>> {
/**
* Send an event to the machine, awaiting async guards, actions, and hooks
*
* @param event - Event name
* @returns The current state after processing the event
*/
async send(event: Events): Promise<States> {
const stateNode = this.states[this.currentState];
if (!stateNode?.on)
return this.currentState;
const transition = stateNode.on[event];
if (transition === undefined)
return this.currentState;
let target: string;
if (isString(transition)) {
target = transition;
}
else {
if (transition.guard && !(await transition.guard(this.context)))
return this.currentState;
await transition.action?.(this.context);
target = transition.target;
}
await stateNode.exit?.(this.context);
this.currentState = target as States;
await this.states[this.currentState]?.entry?.(this.context);
return this.currentState;
}
/**
* Check if an event can trigger a transition, awaiting async guards
*
* @param event - Event to check
*/
async can(event: Events): Promise<boolean> {
const stateNode = this.states[this.currentState];
if (!stateNode?.on)
return false;
const transition = stateNode.on[event];
if (transition === undefined)
return false;
if (!isString(transition) && transition.guard)
return await transition.guard(this.context);
return true;
}
}
/**
* Create a type-safe async finite state machine with context
*
* @example
* ```ts
* const machine = createAsyncMachine({
* initial: 'idle',
* context: { data: '' },
* states: {
* idle: {
* on: {
* FETCH: {
* target: 'loaded',
* guard: async () => await hasPermission(),
* action: async (ctx) => { ctx.data = await fetchData(); },
* },
* },
* },
* loaded: {
* entry: async (ctx) => { await saveToCache(ctx.data); },
* },
* },
* });
*
* await machine.send('FETCH'); // 'loaded'
* ```
*/
export function createAsyncMachine<
const States extends Record<string, AsyncStateNodeConfig<Context>>,
Context,
>(config: {
initial: NoInfer<ExtractStates<States>>;
context: Context;
states: States;
}): AsyncStateMachine<ExtractStates<States>, ExtractEvents<States>, Context>;
export function createAsyncMachine<
const States extends Record<string, AsyncStateNodeConfig<undefined>>,
>(config: {
initial: NoInfer<ExtractStates<States>>;
states: States;
}): AsyncStateMachine<ExtractStates<States>, ExtractEvents<States>, undefined>;
export function createAsyncMachine(config: {
initial: string;
context?: unknown;
states: Record<string, AsyncStateNodeConfig<any>>;
}): AsyncStateMachine {
return new AsyncStateMachine(
config.initial,
config.states,
config.context as undefined,
);
}

View File

@@ -1,44 +0,0 @@
/**
* Base class for state machines — holds shared state, getters, and matches()
*
* @template States - Union of state names
* @template Events - Union of event names
* @template Context - Machine context type
* @template NodeConfig - State node configuration type
*/
export class BaseStateMachine<
States extends string,
_Events extends string,
Context,
NodeConfig,
> {
protected currentState: States;
protected readonly states: Record<string, NodeConfig>;
/** Machine context */
readonly context: Context;
constructor(
initial: States,
states: Record<string, NodeConfig>,
context: Context,
) {
this.currentState = initial;
this.context = context;
this.states = states;
}
/** Current state of the machine */
get current(): States {
return this.currentState;
}
/**
* Check if the machine is in a specific state
*
* @param state - State to check
*/
matches(state: States): boolean {
return this.currentState === state;
}
}

View File

@@ -1,688 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { createMachine, createAsyncMachine, StateMachine, AsyncStateMachine } from '.';
describe('stateMachine', () => {
describe('createMachine (without context)', () => {
let machine: ReturnType<typeof createSimpleMachine>;
function createSimpleMachine() {
return createMachine({
initial: 'idle',
states: {
idle: {
on: {
START: 'running',
},
},
running: {
on: {
STOP: 'idle',
PAUSE: 'paused',
},
},
paused: {
on: {
RESUME: 'running',
STOP: 'idle',
},
},
},
});
}
beforeEach(() => {
machine = createSimpleMachine();
});
it('initializes with the initial state', () => {
expect(machine.current).toBe('idle');
});
it('transitions on send', () => {
machine.send('START');
expect(machine.current).toBe('running');
});
it('returns new state from send', () => {
const result = machine.send('START');
expect(result).toBe('running');
});
it('handles multiple transitions', () => {
machine.send('START');
machine.send('PAUSE');
machine.send('RESUME');
machine.send('STOP');
expect(machine.current).toBe('idle');
});
it('ignores unhandled events', () => {
machine.send('STOP');
expect(machine.current).toBe('idle');
});
it('ignores events not defined in current state', () => {
machine.send('PAUSE');
expect(machine.current).toBe('idle');
});
it('matches current state', () => {
expect(machine.matches('idle')).toBe(true);
expect(machine.matches('running')).toBe(false);
machine.send('START');
expect(machine.matches('idle')).toBe(false);
expect(machine.matches('running')).toBe(true);
});
it('checks if event can be handled', () => {
expect(machine.can('START')).toBe(true);
expect(machine.can('STOP')).toBe(false);
expect(machine.can('PAUSE')).toBe(false);
machine.send('START');
expect(machine.can('START')).toBe(false);
expect(machine.can('STOP')).toBe(true);
expect(machine.can('PAUSE')).toBe(true);
});
});
describe('createMachine (with context)', () => {
function createContextMachine() {
return createMachine({
initial: 'idle',
context: { count: 0, log: '' },
states: {
idle: {
on: {
START: {
target: 'running',
action: (ctx) => { ctx.count = 0; },
},
},
},
running: {
on: {
INCREMENT: {
target: 'running',
action: (ctx) => { ctx.count++; },
},
STOP: 'idle',
},
},
},
});
}
it('provides typed context', () => {
const machine = createContextMachine();
expect(machine.context).toEqual({ count: 0, log: '' });
});
it('runs action on transition', () => {
const machine = createContextMachine();
machine.send('START');
machine.send('INCREMENT');
machine.send('INCREMENT');
machine.send('INCREMENT');
expect(machine.context.count).toBe(3);
});
it('resets context via action', () => {
const machine = createContextMachine();
machine.send('START');
machine.send('INCREMENT');
machine.send('INCREMENT');
machine.send('STOP');
machine.send('START');
expect(machine.context.count).toBe(0);
});
});
describe('guards', () => {
function createGuardedMachine() {
return createMachine({
initial: 'idle',
context: { retries: 0 },
states: {
idle: {
on: {
TRY: {
target: 'attempting',
action: (ctx) => { ctx.retries++; },
},
},
},
attempting: {
on: {
FAIL: {
target: 'idle',
guard: ctx => ctx.retries < 3,
},
SUCCESS: 'done',
},
},
done: {},
},
});
}
it('allows transition when guard returns true', () => {
const machine = createGuardedMachine();
machine.send('TRY');
machine.send('FAIL');
expect(machine.current).toBe('idle');
expect(machine.context.retries).toBe(1);
});
it('blocks transition when guard returns false', () => {
const machine = createGuardedMachine();
machine.send('TRY');
machine.send('FAIL');
machine.send('TRY');
machine.send('FAIL');
machine.send('TRY');
machine.send('FAIL');
expect(machine.current).toBe('attempting');
expect(machine.context.retries).toBe(3);
});
it('reflects guard in can()', () => {
const machine = createGuardedMachine();
machine.send('TRY');
expect(machine.can('FAIL')).toBe(true);
machine.send('FAIL');
machine.send('TRY');
machine.send('FAIL');
machine.send('TRY');
expect(machine.can('FAIL')).toBe(false);
});
});
describe('entry/exit hooks', () => {
it('calls exit on previous state and entry on next state', () => {
const exitIdle = vi.fn();
const enterRunning = vi.fn();
const machine = createMachine({
initial: 'idle',
states: {
idle: {
on: { START: 'running' },
exit: exitIdle,
},
running: {
on: { STOP: 'idle' },
entry: enterRunning,
},
},
});
machine.send('START');
expect(exitIdle).toHaveBeenCalledOnce();
expect(enterRunning).toHaveBeenCalledOnce();
});
it('does not call hooks when transition is blocked by guard', () => {
const exitHook = vi.fn();
const entryHook = vi.fn();
const machine = createMachine({
initial: 'locked',
context: { unlocked: false },
states: {
locked: {
on: {
UNLOCK: {
target: 'unlocked',
guard: ctx => ctx.unlocked,
},
},
exit: exitHook,
},
unlocked: {
entry: entryHook,
},
},
});
machine.send('UNLOCK');
expect(exitHook).not.toHaveBeenCalled();
expect(entryHook).not.toHaveBeenCalled();
expect(machine.current).toBe('locked');
});
it('calls hooks with context', () => {
const entryHook = vi.fn();
const machine = createMachine({
initial: 'idle',
context: { value: 42 },
states: {
idle: {
on: { GO: 'active' },
},
active: {
entry: entryHook,
},
},
});
machine.send('GO');
expect(entryHook).toHaveBeenCalledWith({ value: 42 });
});
it('calls exit and entry on self-transitions', () => {
const exitHook = vi.fn();
const entryHook = vi.fn();
const machine = createMachine({
initial: 'active',
states: {
active: {
on: { REFRESH: 'active' },
entry: entryHook,
exit: exitHook,
},
},
});
machine.send('REFRESH');
expect(exitHook).toHaveBeenCalledOnce();
expect(entryHook).toHaveBeenCalledOnce();
});
});
describe('StateMachine class', () => {
it('can be instantiated directly', () => {
const machine = new StateMachine<'on' | 'off', 'TOGGLE'>(
'off',
{
off: { on: { TOGGLE: 'on' } },
on: { on: { TOGGLE: 'off' } },
},
undefined,
);
expect(machine.current).toBe('off');
machine.send('TOGGLE');
expect(machine.current).toBe('on');
machine.send('TOGGLE');
expect(machine.current).toBe('off');
});
});
describe('edge cases', () => {
it('handles state with no transitions', () => {
const machine = createMachine({
initial: 'start',
states: {
start: {
on: { GO: 'end' },
},
end: {},
},
});
machine.send('GO');
expect(machine.current).toBe('end');
expect(machine.send('GO')).toBe('end');
});
it('handles action that modifies context before guard on next transition', () => {
const machine = createMachine({
initial: 'a',
context: { step: 0 },
states: {
a: {
on: {
NEXT: {
target: 'b',
action: (ctx) => { ctx.step = 1; },
},
},
},
b: {
on: {
NEXT: {
target: 'c',
guard: ctx => ctx.step === 1,
action: (ctx) => { ctx.step = 2; },
},
},
},
c: {},
},
});
machine.send('NEXT');
machine.send('NEXT');
expect(machine.current).toBe('c');
expect(machine.context.step).toBe(2);
});
});
});
describe('asyncStateMachine', () => {
describe('createAsyncMachine (without context)', () => {
it('handles simple string transitions', async () => {
const machine = createAsyncMachine({
initial: 'idle',
states: {
idle: { on: { START: 'running' } },
running: { on: { STOP: 'idle' } },
},
});
const result = await machine.send('START');
expect(result).toBe('running');
expect(machine.current).toBe('running');
});
it('ignores unhandled events', async () => {
const machine = createAsyncMachine({
initial: 'idle',
states: {
idle: { on: { START: 'running' } },
running: {},
},
});
const result = await machine.send('STOP');
expect(result).toBe('idle');
});
});
describe('async guards', () => {
it('allows transition on async guard returning true', async () => {
const machine = createAsyncMachine({
initial: 'idle',
context: { allowed: true },
states: {
idle: {
on: {
GO: {
target: 'active',
guard: async ctx => ctx.allowed,
},
},
},
active: {},
},
});
await machine.send('GO');
expect(machine.current).toBe('active');
});
it('blocks transition on async guard returning false', async () => {
const machine = createAsyncMachine({
initial: 'idle',
context: { allowed: false },
states: {
idle: {
on: {
GO: {
target: 'active',
guard: async ctx => ctx.allowed,
},
},
},
active: {},
},
});
await machine.send('GO');
expect(machine.current).toBe('idle');
});
});
describe('async actions', () => {
it('awaits async action before entering target', async () => {
const order: string[] = [];
const machine = createAsyncMachine({
initial: 'idle',
context: { data: '' },
states: {
idle: {
on: {
FETCH: {
target: 'done',
action: async (ctx) => {
await new Promise(r => setTimeout(r, 10));
ctx.data = 'fetched';
order.push('action');
},
},
},
},
done: {
entry: () => { order.push('entry'); },
},
},
});
await machine.send('FETCH');
expect(machine.context.data).toBe('fetched');
expect(order).toEqual(['action', 'entry']);
});
});
describe('async entry/exit hooks', () => {
it('awaits async exit and entry hooks in order', async () => {
const order: string[] = [];
const machine = createAsyncMachine({
initial: 'a',
states: {
a: {
on: { GO: 'b' },
exit: async () => {
await new Promise(r => setTimeout(r, 10));
order.push('exit-a');
},
},
b: {
entry: async () => {
await new Promise(r => setTimeout(r, 10));
order.push('entry-b');
},
},
},
});
await machine.send('GO');
expect(machine.current).toBe('b');
expect(order).toEqual(['exit-a', 'entry-b']);
});
it('does not call hooks when async guard blocks', async () => {
const exitHook = vi.fn();
const entryHook = vi.fn();
const machine = createAsyncMachine({
initial: 'locked',
context: { unlocked: false },
states: {
locked: {
on: {
UNLOCK: {
target: 'unlocked',
guard: async ctx => ctx.unlocked,
},
},
exit: exitHook,
},
unlocked: {
entry: entryHook,
},
},
});
await machine.send('UNLOCK');
expect(exitHook).not.toHaveBeenCalled();
expect(entryHook).not.toHaveBeenCalled();
expect(machine.current).toBe('locked');
});
});
describe('can()', () => {
it('evaluates async guard', async () => {
const machine = createAsyncMachine({
initial: 'idle',
context: { ready: true },
states: {
idle: {
on: {
GO: {
target: 'active',
guard: async ctx => ctx.ready,
},
},
},
active: {},
},
});
expect(await machine.can('GO')).toBe(true);
machine.context.ready = false;
expect(await machine.can('GO')).toBe(false);
});
it('returns false for undefined events', async () => {
const machine = createAsyncMachine({
initial: 'idle',
states: {
idle: { on: { START: 'running' } },
running: {},
},
});
expect(await machine.can('STOP')).toBe(false);
});
it('returns true for transitions without guard', async () => {
const machine = createAsyncMachine({
initial: 'idle',
states: {
idle: { on: { START: 'running' } },
running: {},
},
});
expect(await machine.can('START')).toBe(true);
});
});
describe('matches()', () => {
it('checks current state synchronously', async () => {
const machine = createAsyncMachine({
initial: 'idle',
states: {
idle: { on: { GO: 'active' } },
active: {},
},
});
expect(machine.matches('idle')).toBe(true);
await machine.send('GO');
expect(machine.matches('active')).toBe(true);
expect(machine.matches('idle')).toBe(false);
});
});
describe('AsyncStateMachine class', () => {
it('can be instantiated directly', async () => {
const machine = new AsyncStateMachine<'on' | 'off', 'TOGGLE'>(
'off',
{
off: { on: { TOGGLE: 'on' } },
on: { on: { TOGGLE: 'off' } },
},
undefined,
);
expect(machine.current).toBe('off');
await machine.send('TOGGLE');
expect(machine.current).toBe('on');
await machine.send('TOGGLE');
expect(machine.current).toBe('off');
});
});
describe('sync callbacks work too', () => {
it('handles sync guard/action/hooks in async machine', async () => {
const entryHook = vi.fn();
const machine = createAsyncMachine({
initial: 'idle',
context: { count: 0 },
states: {
idle: {
on: {
GO: {
target: 'active',
guard: ctx => ctx.count === 0,
action: (ctx) => { ctx.count++; },
},
},
},
active: {
entry: entryHook,
},
},
});
await machine.send('GO');
expect(machine.current).toBe('active');
expect(machine.context.count).toBe(1);
expect(entryHook).toHaveBeenCalledOnce();
});
});
});

View File

@@ -1,3 +0,0 @@
export type * from './types';
export * from './sync';
export * from './async';

View File

@@ -1,135 +0,0 @@
import { isString } from '../../../types';
import { BaseStateMachine } from './base';
import type { SyncStateNodeConfig, ExtractStates, ExtractEvents } from './types';
/**
* @name StateMachine
* @category Patterns
* @description Simple, performant, and type-safe finite state machine
*
* @since 0.0.8
*
* @template States - Union of state names
* @template Events - Union of event names
* @template Context - Machine context type
*/
export class StateMachine<
States extends string = string,
Events extends string = string,
Context = undefined,
> extends BaseStateMachine<States, Events, Context, SyncStateNodeConfig<Context>> {
/**
* Send an event to the machine, potentially causing a state transition
*
* @param event - Event name
* @returns The current state after processing the event
*/
send(event: Events): States {
const stateNode = this.states[this.currentState];
if (!stateNode?.on)
return this.currentState;
const transition = stateNode.on[event];
if (transition === undefined)
return this.currentState;
let target: string;
if (isString(transition)) {
target = transition;
}
else {
if (transition.guard && !transition.guard(this.context))
return this.currentState;
transition.action?.(this.context);
target = transition.target;
}
stateNode.exit?.(this.context);
this.currentState = target as States;
this.states[this.currentState]?.entry?.(this.context);
return this.currentState;
}
/**
* Check if an event can trigger a transition from the current state
*
* @param event - Event to check
*/
can(event: Events): boolean {
const stateNode = this.states[this.currentState];
if (!stateNode?.on)
return false;
const transition = stateNode.on[event];
if (transition === undefined)
return false;
if (!isString(transition) && transition.guard)
return transition.guard(this.context);
return true;
}
}
/**
* Create a type-safe synchronous finite state machine with context
*
* @example
* ```ts
* const machine = createMachine({
* initial: 'idle',
* context: { retries: 0 },
* states: {
* idle: {
* on: { START: 'running' },
* },
* running: {
* on: {
* FAIL: {
* target: 'idle',
* guard: (ctx) => ctx.retries < 3,
* action: (ctx) => { ctx.retries++; },
* },
* STOP: 'idle',
* },
* },
* },
* });
*
* machine.send('START'); // 'running'
* ```
*/
export function createMachine<
const States extends Record<string, SyncStateNodeConfig<Context>>,
Context,
>(config: {
initial: NoInfer<ExtractStates<States>>;
context: Context;
states: States;
}): StateMachine<ExtractStates<States>, ExtractEvents<States>, Context>;
export function createMachine<
const States extends Record<string, SyncStateNodeConfig<undefined>>,
>(config: {
initial: NoInfer<ExtractStates<States>>;
states: States;
}): StateMachine<ExtractStates<States>, ExtractEvents<States>, undefined>;
export function createMachine(config: {
initial: string;
context?: unknown;
states: Record<string, SyncStateNodeConfig<any>>;
}): StateMachine {
return new StateMachine(
config.initial,
config.states,
config.context as undefined,
);
}

View File

@@ -1,64 +0,0 @@
import type { MaybePromise } from '../../../types';
/**
* Configuration for a state transition
*
* @template Context - Machine context type
* @template Guard - Guard return type (boolean or MaybePromise\<boolean\>)
* @template Action - Action return type (void or MaybePromise\<void\>)
*/
export interface TransitionConfig<
Context,
Guard = boolean,
Action = void,
> {
/** Target state to transition to */
target: string;
/** Guard condition — transition only occurs if this returns true */
guard?: (context: Context) => Guard;
/** Side effect executed during transition (before entering target state) */
action?: (context: Context) => Action;
}
/**
* A transition can be a target state name or a detailed configuration
*/
export type Transition<
Context,
Guard = boolean,
Action = void,
> = string | TransitionConfig<Context, Guard, Action>;
/**
* Configuration for a state node
*
* @template Context - Machine context type
* @template Guard - Guard return type
* @template Hook - Hook return type (entry/exit/action)
*/
export interface StateNodeConfig<
Context,
Guard = boolean,
Hook = void,
> {
/** Map of event names to transitions */
on?: Record<string, Transition<Context, Guard, Hook>>;
/** Hook called when entering this state */
entry?: (context: Context) => Hook;
/** Hook called when exiting this state */
exit?: (context: Context) => Hook;
}
/** Sync state node config — guards return boolean, hooks return void */
export type SyncStateNodeConfig<Context> = StateNodeConfig<Context, boolean, void>;
/** Async state node config — guards return MaybePromise\<boolean\>, hooks return MaybePromise\<void\> */
export type AsyncStateNodeConfig<Context> = StateNodeConfig<Context, MaybePromise<boolean>, MaybePromise<void>>;
export type ExtractStates<T> = keyof T & string;
export type ExtractEvents<T> = {
[K in keyof T]: T[K] extends { readonly on?: Readonly<Record<infer E extends string, any>> }
? E
: never;
}[keyof T];

View File

@@ -1,3 +0,0 @@
export * from './behavioral/Command';
export * from './behavioral/PubSub';
export * from './behavioral/StateMachine';

View File

@@ -1,229 +0,0 @@
import { describe, expect, it } from 'vitest';
import { BinaryHeap } from '.';
describe('BinaryHeap', () => {
describe('constructor', () => {
it('should create an empty heap', () => {
const heap = new BinaryHeap<number>();
expect(heap.length).toBe(0);
expect(heap.isEmpty).toBe(true);
});
it('should create a heap from single value', () => {
const heap = new BinaryHeap(42);
expect(heap.length).toBe(1);
expect(heap.peek()).toBe(42);
});
it('should create a heap from array (heapify)', () => {
const heap = new BinaryHeap([5, 3, 8, 1, 4]);
expect(heap.length).toBe(5);
expect(heap.peek()).toBe(1);
});
it('should accept a custom comparator for max-heap', () => {
const heap = new BinaryHeap([5, 3, 8, 1, 4], {
comparator: (a, b) => b - a,
});
expect(heap.peek()).toBe(8);
});
});
describe('push', () => {
it('should insert elements maintaining heap property', () => {
const heap = new BinaryHeap<number>();
heap.push(5);
heap.push(3);
heap.push(8);
heap.push(1);
expect(heap.peek()).toBe(1);
expect(heap.length).toBe(4);
});
it('should handle duplicate values', () => {
const heap = new BinaryHeap<number>();
heap.push(3);
heap.push(3);
heap.push(3);
expect(heap.length).toBe(3);
expect(heap.peek()).toBe(3);
});
});
describe('pop', () => {
it('should return undefined for empty heap', () => {
const heap = new BinaryHeap<number>();
expect(heap.pop()).toBeUndefined();
});
it('should extract elements in min-heap order', () => {
const heap = new BinaryHeap([5, 3, 8, 1, 4, 2, 7, 6]);
const sorted: number[] = [];
while (!heap.isEmpty) {
sorted.push(heap.pop()!);
}
expect(sorted).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
});
it('should extract elements in max-heap order with custom comparator', () => {
const heap = new BinaryHeap([5, 3, 8, 1, 4], {
comparator: (a, b) => b - a,
});
const sorted: number[] = [];
while (!heap.isEmpty) {
sorted.push(heap.pop()!);
}
expect(sorted).toEqual([8, 5, 4, 3, 1]);
});
it('should handle single element', () => {
const heap = new BinaryHeap(42);
expect(heap.pop()).toBe(42);
expect(heap.isEmpty).toBe(true);
});
});
describe('peek', () => {
it('should return undefined for empty heap', () => {
const heap = new BinaryHeap<number>();
expect(heap.peek()).toBeUndefined();
});
it('should return root without removing it', () => {
const heap = new BinaryHeap([5, 3, 1]);
expect(heap.peek()).toBe(1);
expect(heap.length).toBe(3);
});
});
describe('clear', () => {
it('should remove all elements', () => {
const heap = new BinaryHeap([1, 2, 3]);
const result = heap.clear();
expect(heap.length).toBe(0);
expect(heap.isEmpty).toBe(true);
expect(result).toBe(heap);
});
});
describe('toArray', () => {
it('should return empty array for empty heap', () => {
const heap = new BinaryHeap<number>();
expect(heap.toArray()).toEqual([]);
});
it('should return a shallow copy', () => {
const heap = new BinaryHeap([3, 1, 2]);
const arr = heap.toArray();
arr.push(99);
expect(heap.length).toBe(3);
});
});
describe('toString', () => {
it('should return formatted string', () => {
const heap = new BinaryHeap([1, 2, 3]);
expect(heap.toString()).toBe('BinaryHeap(3)');
});
});
describe('iterator', () => {
it('should iterate over heap elements', () => {
const heap = new BinaryHeap([5, 3, 8, 1]);
const elements = [...heap];
expect(elements.length).toBe(4);
expect(elements[0]).toBe(1);
});
});
describe('custom comparator', () => {
it('should work with string length comparator', () => {
const heap = new BinaryHeap(['banana', 'apple', 'kiwi', 'fig'], {
comparator: (a, b) => a.length - b.length,
});
expect(heap.pop()).toBe('fig');
expect(heap.pop()).toBe('kiwi');
});
it('should work with object comparator', () => {
interface Task {
priority: number;
name: string;
}
const heap = new BinaryHeap<Task>(
[
{ priority: 3, name: 'low' },
{ priority: 1, name: 'high' },
{ priority: 2, name: 'medium' },
],
{ comparator: (a, b) => a.priority - b.priority },
);
expect(heap.pop()?.name).toBe('high');
expect(heap.pop()?.name).toBe('medium');
expect(heap.pop()?.name).toBe('low');
});
});
describe('heapify', () => {
it('should correctly heapify large arrays', () => {
const values = Array.from({ length: 1000 }, () => Math.random() * 1000 | 0);
const heap = new BinaryHeap(values);
const sorted: number[] = [];
while (!heap.isEmpty) {
sorted.push(heap.pop()!);
}
const expected = [...values].sort((a, b) => a - b);
expect(sorted).toEqual(expected);
});
});
describe('interleaved operations', () => {
it('should maintain heap property with mixed push and pop', () => {
const heap = new BinaryHeap<number>();
heap.push(10);
heap.push(5);
expect(heap.pop()).toBe(5);
heap.push(3);
heap.push(7);
expect(heap.pop()).toBe(3);
heap.push(1);
expect(heap.pop()).toBe(1);
expect(heap.pop()).toBe(7);
expect(heap.pop()).toBe(10);
expect(heap.pop()).toBeUndefined();
});
});
});

View File

@@ -1,220 +0,0 @@
import { first } from '../../arrays';
import { isArray } from '../../types';
import type { BinaryHeapLike, Comparator } from './types';
export type { BinaryHeapLike, Comparator } from './types';
export interface BinaryHeapOptions<T> {
comparator?: Comparator<T>;
}
/**
* Default min-heap comparator for numeric values
*
* @param {number} a First element
* @param {number} b Second element
* @returns {number} Negative if a < b, positive if a > b, zero if equal
*/
const defaultComparator: Comparator<any> = (a: number, b: number) => a - b;
/**
* @name BinaryHeap
* @category Data Structures
* @description Binary heap backed by a flat array with configurable comparator
*
* @since 0.0.8
*
* @template T The type of elements stored in the heap
*/
export class BinaryHeap<T> implements BinaryHeapLike<T> {
/**
* The comparator function used to order elements
*
* @private
* @type {Comparator<T>}
*/
private readonly comparator: Comparator<T>;
/**
* Internal flat array backing the heap
*
* @private
* @type {T[]}
*/
private readonly heap: T[] = [];
/**
* Creates an instance of BinaryHeap
*
* @param {(T[] | T)} [initialValues] The initial values to heapify
* @param {BinaryHeapOptions<T>} [options] Heap configuration
*/
constructor(initialValues?: T[] | T, options?: BinaryHeapOptions<T>) {
this.comparator = options?.comparator ?? defaultComparator;
if (initialValues !== null && initialValues !== undefined) {
const items = isArray(initialValues) ? initialValues : [initialValues];
this.heap.push(...items);
this.heapify();
}
}
/**
* Gets the number of elements in the heap
* @returns {number} The number of elements in the heap
*/
public get length(): number {
return this.heap.length;
}
/**
* Checks if the heap is empty
* @returns {boolean} `true` if the heap is empty, `false` otherwise
*/
public get isEmpty(): boolean {
return this.heap.length === 0;
}
/**
* Pushes an element into the heap
* @param {T} element The element to insert
*/
public push(element: T): void {
this.heap.push(element);
this.siftUp(this.heap.length - 1);
}
/**
* Removes and returns the root element (min or max depending on comparator)
* @returns {T | undefined} The root element, or `undefined` if the heap is empty
*/
public pop(): T | undefined {
if (this.heap.length === 0) return undefined;
const root = first(this.heap)!;
const last = this.heap.pop()!;
if (this.heap.length > 0) {
this.heap[0] = last;
this.siftDown(0);
}
return root;
}
/**
* Returns the root element without removing it
* @returns {T | undefined} The root element, or `undefined` if the heap is empty
*/
public peek(): T | undefined {
return first(this.heap);
}
/**
* Removes all elements from the heap
* @returns {this} The heap instance for chaining
*/
public clear(): this {
this.heap.length = 0;
return this;
}
/**
* Returns a shallow copy of the heap elements as an array (heap order, not sorted)
* @returns {T[]} Array of elements in heap order
*/
public toArray(): T[] {
return this.heap.slice();
}
/**
* Returns a string representation of the heap
* @returns {string} String representation
*/
public toString(): string {
return `BinaryHeap(${this.heap.length})`;
}
/**
* Iterator over heap elements in heap order
*/
public* [Symbol.iterator](): Iterator<T> {
yield* this.heap;
}
/**
* Async iterator over heap elements in heap order
*/
public async* [Symbol.asyncIterator](): AsyncIterator<T> {
for (const element of this.heap)
yield element;
}
/**
* Restores heap property by sifting an element up
*
* @private
* @param {number} index The index of the element to sift up
*/
private siftUp(index: number): void {
const heap = this.heap;
const cmp = this.comparator;
while (index > 0) {
const parent = (index - 1) >> 1;
if (cmp(heap[index]!, heap[parent]!) >= 0) break;
const temp = heap[index]!;
heap[index] = heap[parent]!;
heap[parent] = temp;
index = parent;
}
}
/**
* Restores heap property by sifting an element down
*
* @private
* @param {number} index The index of the element to sift down
*/
private siftDown(index: number): void {
const heap = this.heap;
const cmp = this.comparator;
const length = heap.length;
while (true) {
let smallest = index;
const left = 2 * index + 1;
const right = 2 * index + 2;
if (left < length && cmp(heap[left]!, heap[smallest]!) < 0) {
smallest = left;
}
if (right < length && cmp(heap[right]!, heap[smallest]!) < 0) {
smallest = right;
}
if (smallest === index) break;
const temp = heap[index]!;
heap[index] = heap[smallest]!;
heap[smallest] = temp;
index = smallest;
}
}
/**
* Builds heap from unordered array in O(n) using Floyd's algorithm
*
* @private
*/
private heapify(): void {
for (let i = (this.heap.length >> 1) - 1; i >= 0; i--) {
this.siftDown(i);
}
}
}

View File

@@ -1,13 +0,0 @@
export type Comparator<T> = (a: T, b: T) => number;
export interface BinaryHeapLike<T> extends Iterable<T>, AsyncIterable<T> {
readonly length: number;
readonly isEmpty: boolean;
push(element: T): void;
pop(): T | undefined;
peek(): T | undefined;
clear(): this;
toArray(): T[];
toString(): string;
}

View File

@@ -1,247 +0,0 @@
import { describe, it, expect } from 'vitest';
import { CircularBuffer } from '.';
describe('circularBuffer', () => {
describe('constructor', () => {
it('create an empty buffer', () => {
const buf = new CircularBuffer<number>();
expect(buf.length).toBe(0);
expect(buf.isEmpty).toBe(true);
expect(buf.capacity).toBeGreaterThanOrEqual(4);
});
it('create a buffer with initial array', () => {
const buf = new CircularBuffer([1, 2, 3]);
expect(buf.length).toBe(3);
expect(buf.peekFront()).toBe(1);
expect(buf.peekBack()).toBe(3);
});
it('create a buffer with a single value', () => {
const buf = new CircularBuffer(42);
expect(buf.length).toBe(1);
expect(buf.peekFront()).toBe(42);
});
it('create a buffer with initial capacity hint', () => {
const buf = new CircularBuffer<number>(undefined, 32);
expect(buf.capacity).toBe(32);
});
it('round capacity up to next power of two', () => {
const buf = new CircularBuffer<number>(undefined, 5);
expect(buf.capacity).toBe(8);
});
});
describe('pushBack / popFront', () => {
it('FIFO order', () => {
const buf = new CircularBuffer<number>();
buf.pushBack(1);
buf.pushBack(2);
buf.pushBack(3);
expect(buf.popFront()).toBe(1);
expect(buf.popFront()).toBe(2);
expect(buf.popFront()).toBe(3);
});
});
describe('pushFront / popBack', () => {
it('LIFO order', () => {
const buf = new CircularBuffer<number>();
buf.pushFront(1);
buf.pushFront(2);
buf.pushFront(3);
expect(buf.popBack()).toBe(1);
expect(buf.popBack()).toBe(2);
expect(buf.popBack()).toBe(3);
});
});
describe('popFront', () => {
it('return undefined if empty', () => {
const buf = new CircularBuffer<number>();
expect(buf.popFront()).toBeUndefined();
});
});
describe('popBack', () => {
it('return undefined if empty', () => {
const buf = new CircularBuffer<number>();
expect(buf.popBack()).toBeUndefined();
});
});
describe('peekFront / peekBack', () => {
it('return elements without removing', () => {
const buf = new CircularBuffer([1, 2, 3]);
expect(buf.peekFront()).toBe(1);
expect(buf.peekBack()).toBe(3);
expect(buf.length).toBe(3);
});
it('return undefined if empty', () => {
const buf = new CircularBuffer<number>();
expect(buf.peekFront()).toBeUndefined();
expect(buf.peekBack()).toBeUndefined();
});
});
describe('get', () => {
it('access element by logical index', () => {
const buf = new CircularBuffer([10, 20, 30]);
expect(buf.get(0)).toBe(10);
expect(buf.get(1)).toBe(20);
expect(buf.get(2)).toBe(30);
});
it('return undefined for out-of-bounds', () => {
const buf = new CircularBuffer([1, 2]);
expect(buf.get(-1)).toBeUndefined();
expect(buf.get(2)).toBeUndefined();
});
it('work correctly after wrap-around', () => {
const buf = new CircularBuffer<number>(undefined, 4);
buf.pushBack(1);
buf.pushBack(2);
buf.pushBack(3);
buf.pushBack(4);
buf.popFront();
buf.popFront();
buf.pushBack(5);
buf.pushBack(6);
expect(buf.get(0)).toBe(3);
expect(buf.get(1)).toBe(4);
expect(buf.get(2)).toBe(5);
expect(buf.get(3)).toBe(6);
});
});
describe('clear', () => {
it('clear the buffer', () => {
const buf = new CircularBuffer([1, 2, 3]);
buf.clear();
expect(buf.length).toBe(0);
expect(buf.isEmpty).toBe(true);
});
it('return this for chaining', () => {
const buf = new CircularBuffer([1]);
expect(buf.clear()).toBe(buf);
});
});
describe('auto-grow', () => {
it('grow when capacity is exceeded', () => {
const buf = new CircularBuffer<number>();
const initialCapacity = buf.capacity;
for (let i = 0; i < initialCapacity + 1; i++)
buf.pushBack(i);
expect(buf.length).toBe(initialCapacity + 1);
expect(buf.capacity).toBe(initialCapacity * 2);
});
it('preserve order after grow', () => {
const buf = new CircularBuffer<number>(undefined, 4);
buf.pushBack(1);
buf.pushBack(2);
buf.popFront();
buf.pushBack(3);
buf.pushBack(4);
buf.pushBack(5);
buf.pushBack(6);
expect(buf.toArray()).toEqual([2, 3, 4, 5, 6]);
});
});
describe('wrap-around', () => {
it('handle wrap-around correctly', () => {
const buf = new CircularBuffer<number>(undefined, 4);
buf.pushBack(1);
buf.pushBack(2);
buf.pushBack(3);
buf.pushBack(4);
buf.popFront();
buf.popFront();
buf.pushBack(5);
buf.pushBack(6);
expect(buf.toArray()).toEqual([3, 4, 5, 6]);
});
it('handle alternating front/back', () => {
const buf = new CircularBuffer<number>();
buf.pushFront(3);
buf.pushBack(4);
buf.pushFront(2);
buf.pushBack(5);
buf.pushFront(1);
expect(buf.toArray()).toEqual([1, 2, 3, 4, 5]);
});
});
describe('toArray', () => {
it('return elements front to back', () => {
const buf = new CircularBuffer([1, 2, 3]);
expect(buf.toArray()).toEqual([1, 2, 3]);
});
it('return empty array if empty', () => {
const buf = new CircularBuffer<number>();
expect(buf.toArray()).toEqual([]);
});
});
describe('toString', () => {
it('return comma-separated string', () => {
const buf = new CircularBuffer([1, 2, 3]);
expect(buf.toString()).toBe('1,2,3');
});
});
describe('iteration', () => {
it('iterate front to back', () => {
const buf = new CircularBuffer([1, 2, 3]);
expect([...buf]).toEqual([1, 2, 3]);
});
it('iterate asynchronously', async () => {
const buf = new CircularBuffer([1, 2, 3]);
const elements: number[] = [];
for await (const element of buf)
elements.push(element);
expect(elements).toEqual([1, 2, 3]);
});
});
});

View File

@@ -1,277 +0,0 @@
import { isArray } from '../../types';
import type { CircularBufferLike } from './types';
export type { CircularBufferLike } from './types';
const MIN_CAPACITY = 4;
/**
* @name CircularBuffer
* @category Data Structures
* @description A circular (ring) buffer with automatic growth, O(1) push/pop on both ends
*
* @since 0.0.8
*
* @template T The type of elements stored in the buffer
*/
export class CircularBuffer<T> implements CircularBufferLike<T> {
/**
* The internal storage
*
* @private
* @type {(T | undefined)[]}
*/
private buffer: Array<T | undefined>;
/**
* The index of the front element
*
* @private
* @type {number}
*/
private head: number;
/**
* The number of elements in the buffer
*
* @private
* @type {number}
*/
private count: number;
/**
* Creates an instance of CircularBuffer
*
* @param {(T[] | T)} [initialValues] The initial values to add to the buffer
* @param {number} [initialCapacity] The initial capacity hint (rounded up to next power of two)
*/
constructor(initialValues?: T[] | T, initialCapacity?: number) {
this.head = 0;
this.count = 0;
const items = isArray(initialValues) ? initialValues : initialValues !== undefined ? [initialValues] : [];
const requested = Math.max(items.length, initialCapacity ?? 0);
const cap = Math.max(MIN_CAPACITY, nextPowerOfTwo(requested));
this.buffer = Array.from<T | undefined>({ length: cap });
for (const item of items)
this.pushBack(item);
}
/**
* Gets the number of elements in the buffer
* @returns {number}
*/
get length() {
return this.count;
}
/**
* Gets the current capacity of the buffer
* @returns {number}
*/
get capacity() {
return this.buffer.length;
}
/**
* Checks if the buffer is empty
* @returns {boolean}
*/
get isEmpty() {
return this.count === 0;
}
/**
* Checks if the buffer is at capacity (before auto-grow)
* @returns {boolean}
*/
get isFull() {
return this.count === this.buffer.length;
}
/**
* Adds an element to the back of the buffer
* @param {T} element The element to add
*/
pushBack(element: T) {
if (this.count === this.buffer.length)
this.grow();
this.buffer[(this.head + this.count) & (this.buffer.length - 1)] = element;
this.count++;
}
/**
* Adds an element to the front of the buffer
* @param {T} element The element to add
*/
pushFront(element: T) {
if (this.count === this.buffer.length)
this.grow();
this.head = (this.head - 1 + this.buffer.length) & (this.buffer.length - 1);
this.buffer[this.head] = element;
this.count++;
}
/**
* Removes and returns the back element
* @returns {T | undefined} The back element, or undefined if empty
*/
popBack() {
if (this.isEmpty)
return undefined;
const index = (this.head + this.count - 1) & (this.buffer.length - 1);
const element = this.buffer[index];
this.buffer[index] = undefined;
this.count--;
return element;
}
/**
* Removes and returns the front element
* @returns {T | undefined} The front element, or undefined if empty
*/
popFront() {
if (this.isEmpty)
return undefined;
const element = this.buffer[this.head];
this.buffer[this.head] = undefined;
this.head = (this.head + 1) & (this.buffer.length - 1);
this.count--;
return element;
}
/**
* Returns the back element without removing it
* @returns {T | undefined}
*/
peekBack() {
if (this.isEmpty)
return undefined;
return this.buffer[(this.head + this.count - 1) & (this.buffer.length - 1)];
}
/**
* Returns the front element without removing it
* @returns {T | undefined}
*/
peekFront() {
if (this.isEmpty)
return undefined;
return this.buffer[this.head];
}
/**
* Gets element at logical index (0 = front)
* @param {number} index The logical index
* @returns {T | undefined}
*/
get(index: number) {
if (index < 0 || index >= this.count)
return undefined;
return this.buffer[(this.head + index) & (this.buffer.length - 1)];
}
/**
* Clears the buffer
*
* @returns {this}
*/
clear() {
this.buffer = Array.from<T | undefined>({ length: MIN_CAPACITY });
this.head = 0;
this.count = 0;
return this;
}
/**
* Converts the buffer to an array from front to back
*
* @returns {T[]}
*/
toArray() {
const result = Array.from<T>({ length: this.count });
for (let i = 0; i < this.count; i++)
result[i] = this.buffer[(this.head + i) & (this.buffer.length - 1)] as T;
return result;
}
/**
* Returns a string representation
*
* @returns {string}
*/
toString() {
return this.toArray().toString();
}
/**
* Returns an iterator (front to back)
*
* @returns {IterableIterator<T>}
*/
[Symbol.iterator]() {
return this.toArray()[Symbol.iterator]();
}
/**
* Returns an async iterator (front to back)
*
* @returns {AsyncIterableIterator<T>}
*/
async* [Symbol.asyncIterator]() {
for (const element of this)
yield element;
}
/**
* Doubles the buffer capacity and linearizes elements
*
* @private
*/
private grow() {
const newCapacity = this.buffer.length << 1;
const newBuffer = Array.from<T | undefined>({ length: newCapacity });
for (let i = 0; i < this.count; i++)
newBuffer[i] = this.buffer[(this.head + i) & (this.buffer.length - 1)];
this.buffer = newBuffer;
this.head = 0;
}
}
/**
* Returns the next power of two >= n
*
* @param {number} n
* @returns {number}
*/
function nextPowerOfTwo(n: number): number {
if (n <= 0)
return 1;
n--;
n |= n >> 1;
n |= n >> 2;
n |= n >> 4;
n |= n >> 8;
n |= n >> 16;
return n + 1;
}

View File

@@ -1,17 +0,0 @@
export interface CircularBufferLike<T> extends Iterable<T>, AsyncIterable<T> {
readonly length: number;
readonly capacity: number;
readonly isEmpty: boolean;
readonly isFull: boolean;
pushBack(element: T): void;
pushFront(element: T): void;
popBack(): T | undefined;
popFront(): T | undefined;
peekBack(): T | undefined;
peekFront(): T | undefined;
get(index: number): T | undefined;
clear(): this;
toArray(): T[];
toString(): string;
}

View File

@@ -1,288 +0,0 @@
import { describe, it, expect } from 'vitest';
import { Deque } from '.';
describe('deque', () => {
describe('constructor', () => {
it('create an empty deque if no initial values are provided', () => {
const deque = new Deque<number>();
expect(deque.length).toBe(0);
expect(deque.isEmpty).toBe(true);
});
it('create a deque with the provided initial values', () => {
const deque = new Deque([1, 2, 3]);
expect(deque.length).toBe(3);
expect(deque.peekFront()).toBe(1);
expect(deque.peekBack()).toBe(3);
});
it('create a deque with a single initial value', () => {
const deque = new Deque(42);
expect(deque.length).toBe(1);
expect(deque.peekFront()).toBe(42);
});
it('create a deque with the provided options', () => {
const deque = new Deque<number>(undefined, { maxSize: 5 });
expect(deque.length).toBe(0);
expect(deque.isFull).toBe(false);
});
});
describe('pushBack', () => {
it('add an element to the back', () => {
const deque = new Deque<number>();
deque.pushBack(1).pushBack(2);
expect(deque.peekFront()).toBe(1);
expect(deque.peekBack()).toBe(2);
expect(deque.length).toBe(2);
});
it('throw an error if the deque is full', () => {
const deque = new Deque<number>(undefined, { maxSize: 1 });
deque.pushBack(1);
expect(() => deque.pushBack(2)).toThrow(new RangeError('Deque is full'));
});
it('return this for chaining', () => {
const deque = new Deque<number>();
expect(deque.pushBack(1)).toBe(deque);
});
});
describe('pushFront', () => {
it('add an element to the front', () => {
const deque = new Deque<number>();
deque.pushFront(1).pushFront(2);
expect(deque.peekFront()).toBe(2);
expect(deque.peekBack()).toBe(1);
expect(deque.length).toBe(2);
});
it('throw an error if the deque is full', () => {
const deque = new Deque<number>(undefined, { maxSize: 1 });
deque.pushFront(1);
expect(() => deque.pushFront(2)).toThrow(new RangeError('Deque is full'));
});
it('return this for chaining', () => {
const deque = new Deque<number>();
expect(deque.pushFront(1)).toBe(deque);
});
});
describe('popBack', () => {
it('remove and return the back element', () => {
const deque = new Deque([1, 2, 3]);
expect(deque.popBack()).toBe(3);
expect(deque.length).toBe(2);
});
it('return undefined if the deque is empty', () => {
const deque = new Deque<number>();
expect(deque.popBack()).toBeUndefined();
});
});
describe('popFront', () => {
it('remove and return the front element', () => {
const deque = new Deque([1, 2, 3]);
expect(deque.popFront()).toBe(1);
expect(deque.length).toBe(2);
});
it('return undefined if the deque is empty', () => {
const deque = new Deque<number>();
expect(deque.popFront()).toBeUndefined();
});
});
describe('peekBack', () => {
it('return the back element without removing it', () => {
const deque = new Deque([1, 2, 3]);
expect(deque.peekBack()).toBe(3);
expect(deque.length).toBe(3);
});
it('return undefined if the deque is empty', () => {
const deque = new Deque<number>();
expect(deque.peekBack()).toBeUndefined();
});
});
describe('peekFront', () => {
it('return the front element without removing it', () => {
const deque = new Deque([1, 2, 3]);
expect(deque.peekFront()).toBe(1);
expect(deque.length).toBe(3);
});
it('return undefined if the deque is empty', () => {
const deque = new Deque<number>();
expect(deque.peekFront()).toBeUndefined();
});
});
describe('clear', () => {
it('clear the deque', () => {
const deque = new Deque([1, 2, 3]);
deque.clear();
expect(deque.length).toBe(0);
expect(deque.isEmpty).toBe(true);
});
it('return this for chaining', () => {
const deque = new Deque([1, 2, 3]);
expect(deque.clear()).toBe(deque);
});
});
describe('toArray', () => {
it('return elements from front to back', () => {
const deque = new Deque([1, 2, 3]);
expect(deque.toArray()).toEqual([1, 2, 3]);
});
it('return correct order after mixed operations', () => {
const deque = new Deque<number>();
deque.pushBack(2);
deque.pushBack(3);
deque.pushFront(1);
deque.pushFront(0);
expect(deque.toArray()).toEqual([0, 1, 2, 3]);
});
});
describe('toString', () => {
it('return comma-separated string', () => {
const deque = new Deque([1, 2, 3]);
expect(deque.toString()).toBe('1,2,3');
});
});
describe('iteration', () => {
it('iterate in front-to-back order', () => {
const deque = new Deque([1, 2, 3]);
expect([...deque]).toEqual([1, 2, 3]);
});
it('iterate asynchronously', async () => {
const deque = new Deque([1, 2, 3]);
const elements: number[] = [];
for await (const element of deque)
elements.push(element);
expect(elements).toEqual([1, 2, 3]);
});
});
describe('circular buffer behavior', () => {
it('handle wrap-around correctly', () => {
const deque = new Deque<number>();
for (let i = 0; i < 4; i++)
deque.pushBack(i);
deque.popFront();
deque.popFront();
deque.pushBack(4);
deque.pushBack(5);
expect(deque.toArray()).toEqual([2, 3, 4, 5]);
});
it('grow the buffer when needed', () => {
const deque = new Deque<number>();
for (let i = 0; i < 100; i++)
deque.pushBack(i);
expect(deque.length).toBe(100);
expect(deque.peekFront()).toBe(0);
expect(deque.peekBack()).toBe(99);
});
it('handle alternating front/back operations', () => {
const deque = new Deque<number>();
deque.pushFront(3);
deque.pushBack(4);
deque.pushFront(2);
deque.pushBack(5);
deque.pushFront(1);
expect(deque.toArray()).toEqual([1, 2, 3, 4, 5]);
expect(deque.popFront()).toBe(1);
expect(deque.popBack()).toBe(5);
expect(deque.toArray()).toEqual([2, 3, 4]);
});
});
describe('mixed operations', () => {
it('use as a stack (LIFO)', () => {
const deque = new Deque<number>();
deque.pushBack(1).pushBack(2).pushBack(3);
expect(deque.popBack()).toBe(3);
expect(deque.popBack()).toBe(2);
expect(deque.popBack()).toBe(1);
});
it('use as a queue (FIFO)', () => {
const deque = new Deque<number>();
deque.pushBack(1).pushBack(2).pushBack(3);
expect(deque.popFront()).toBe(1);
expect(deque.popFront()).toBe(2);
expect(deque.popFront()).toBe(3);
});
it('reuse deque after clear', () => {
const deque = new Deque([1, 2, 3]);
deque.clear();
deque.pushBack(4);
expect(deque.length).toBe(1);
expect(deque.peekFront()).toBe(4);
});
it('maxSize limits capacity', () => {
const deque = new Deque<number>(undefined, { maxSize: 3 });
deque.pushBack(1).pushBack(2).pushBack(3);
expect(deque.isFull).toBe(true);
expect(() => deque.pushFront(0)).toThrow(new RangeError('Deque is full'));
deque.popFront();
deque.pushFront(0);
expect(deque.toArray()).toEqual([0, 2, 3]);
});
});
});

View File

@@ -1,180 +0,0 @@
import { CircularBuffer } from '../CircularBuffer';
import type { DequeLike } from './types';
export type { DequeLike } from './types';
export interface DequeOptions {
maxSize?: number;
}
/**
* @name Deque
* @category Data Structures
* @description Represents a double-ended queue backed by a circular buffer
*
* @since 0.0.8
*
* @template T The type of elements stored in the deque
*/
export class Deque<T> implements DequeLike<T> {
/**
* The maximum number of elements that the deque can hold
*
* @private
* @type {number}
*/
private readonly maxSize: number;
/**
* The underlying circular buffer
*
* @private
* @type {CircularBuffer<T>}
*/
private readonly buffer: CircularBuffer<T>;
/**
* Creates an instance of Deque
*
* @param {(T[] | T)} [initialValues] The initial values to add to the deque
* @param {DequeOptions} [options] The options for the deque
*/
constructor(initialValues?: T[] | T, options?: DequeOptions) {
this.maxSize = options?.maxSize ?? Infinity;
this.buffer = new CircularBuffer(initialValues);
}
/**
* Gets the number of elements in the deque
* @returns {number} The number of elements in the deque
*/
get length() {
return this.buffer.length;
}
/**
* Checks if the deque is empty
* @returns {boolean} `true` if the deque is empty, `false` otherwise
*/
get isEmpty() {
return this.buffer.isEmpty;
}
/**
* Checks if the deque is full
* @returns {boolean} `true` if the deque is full, `false` otherwise
*/
get isFull() {
return this.buffer.length === this.maxSize;
}
/**
* Adds an element to the back of the deque
* @param {T} element The element to add
* @returns {this}
* @throws {RangeError} If the deque is full
*/
pushBack(element: T) {
if (this.isFull)
throw new RangeError('Deque is full');
this.buffer.pushBack(element);
return this;
}
/**
* Adds an element to the front of the deque
* @param {T} element The element to add
* @returns {this}
* @throws {RangeError} If the deque is full
*/
pushFront(element: T) {
if (this.isFull)
throw new RangeError('Deque is full');
this.buffer.pushFront(element);
return this;
}
/**
* Removes and returns the back element of the deque
* @returns {T | undefined} The back element, or undefined if empty
*/
popBack() {
return this.buffer.popBack();
}
/**
* Removes and returns the front element of the deque
* @returns {T | undefined} The front element, or undefined if empty
*/
popFront() {
return this.buffer.popFront();
}
/**
* Returns the back element without removing it
* @returns {T | undefined} The back element, or undefined if empty
*/
peekBack() {
return this.buffer.peekBack();
}
/**
* Returns the front element without removing it
* @returns {T | undefined} The front element, or undefined if empty
*/
peekFront() {
return this.buffer.peekFront();
}
/**
* Clears the deque
*
* @returns {this}
*/
clear() {
this.buffer.clear();
return this;
}
/**
* Converts the deque to an array from front to back
*
* @returns {T[]}
*/
toArray() {
return this.buffer.toArray();
}
/**
* Returns a string representation of the deque
*
* @returns {string}
*/
toString() {
return this.buffer.toString();
}
/**
* Returns an iterator for the deque (front to back)
*
* @returns {IterableIterator<T>}
*/
[Symbol.iterator]() {
return this.buffer[Symbol.iterator]();
}
/**
* Returns an async iterator for the deque (front to back)
*
* @returns {AsyncIterableIterator<T>}
*/
async* [Symbol.asyncIterator]() {
for (const element of this.buffer)
yield element;
}
}

View File

@@ -1,15 +0,0 @@
export interface DequeLike<T> extends Iterable<T>, AsyncIterable<T> {
readonly length: number;
readonly isEmpty: boolean;
readonly isFull: boolean;
pushBack(element: T): this;
pushFront(element: T): this;
popBack(): T | undefined;
popFront(): T | undefined;
peekBack(): T | undefined;
peekFront(): T | undefined;
clear(): this;
toArray(): T[];
toString(): string;
}

View File

@@ -1,406 +0,0 @@
import { describe, expect, it } from 'vitest';
import { LinkedList } from '.';
describe('LinkedList', () => {
describe('constructor', () => {
it('should create an empty list', () => {
const list = new LinkedList<number>();
expect(list.length).toBe(0);
expect(list.isEmpty).toBe(true);
expect(list.head).toBeUndefined();
expect(list.tail).toBeUndefined();
});
it('should create a list from single value', () => {
const list = new LinkedList(42);
expect(list.length).toBe(1);
expect(list.peekFront()).toBe(42);
expect(list.peekBack()).toBe(42);
});
it('should create a list from array', () => {
const list = new LinkedList([1, 2, 3]);
expect(list.length).toBe(3);
expect(list.peekFront()).toBe(1);
expect(list.peekBack()).toBe(3);
});
});
describe('pushBack', () => {
it('should append to empty list', () => {
const list = new LinkedList<number>();
const node = list.pushBack(1);
expect(list.length).toBe(1);
expect(node.value).toBe(1);
expect(list.head).toBe(node);
expect(list.tail).toBe(node);
});
it('should append to non-empty list', () => {
const list = new LinkedList([1, 2]);
list.pushBack(3);
expect(list.length).toBe(3);
expect(list.peekBack()).toBe(3);
expect(list.peekFront()).toBe(1);
});
it('should return the created node', () => {
const list = new LinkedList<number>();
const node = list.pushBack(5);
expect(node.value).toBe(5);
expect(node.prev).toBeUndefined();
expect(node.next).toBeUndefined();
});
});
describe('pushFront', () => {
it('should prepend to empty list', () => {
const list = new LinkedList<number>();
const node = list.pushFront(1);
expect(list.length).toBe(1);
expect(list.head).toBe(node);
expect(list.tail).toBe(node);
});
it('should prepend to non-empty list', () => {
const list = new LinkedList([2, 3]);
list.pushFront(1);
expect(list.length).toBe(3);
expect(list.peekFront()).toBe(1);
expect(list.peekBack()).toBe(3);
});
});
describe('popBack', () => {
it('should return undefined for empty list', () => {
const list = new LinkedList<number>();
expect(list.popBack()).toBeUndefined();
});
it('should remove and return last value', () => {
const list = new LinkedList([1, 2, 3]);
expect(list.popBack()).toBe(3);
expect(list.length).toBe(2);
expect(list.peekBack()).toBe(2);
});
it('should handle single element', () => {
const list = new LinkedList(1);
expect(list.popBack()).toBe(1);
expect(list.isEmpty).toBe(true);
expect(list.head).toBeUndefined();
expect(list.tail).toBeUndefined();
});
});
describe('popFront', () => {
it('should return undefined for empty list', () => {
const list = new LinkedList<number>();
expect(list.popFront()).toBeUndefined();
});
it('should remove and return first value', () => {
const list = new LinkedList([1, 2, 3]);
expect(list.popFront()).toBe(1);
expect(list.length).toBe(2);
expect(list.peekFront()).toBe(2);
});
it('should handle single element', () => {
const list = new LinkedList(1);
expect(list.popFront()).toBe(1);
expect(list.isEmpty).toBe(true);
expect(list.head).toBeUndefined();
expect(list.tail).toBeUndefined();
});
});
describe('peekBack', () => {
it('should return undefined for empty list', () => {
const list = new LinkedList<number>();
expect(list.peekBack()).toBeUndefined();
});
it('should return last value without removing', () => {
const list = new LinkedList([1, 2, 3]);
expect(list.peekBack()).toBe(3);
expect(list.length).toBe(3);
});
});
describe('peekFront', () => {
it('should return undefined for empty list', () => {
const list = new LinkedList<number>();
expect(list.peekFront()).toBeUndefined();
});
it('should return first value without removing', () => {
const list = new LinkedList([1, 2, 3]);
expect(list.peekFront()).toBe(1);
expect(list.length).toBe(3);
});
});
describe('insertBefore', () => {
it('should insert before head', () => {
const list = new LinkedList<number>();
const node = list.pushBack(2);
list.insertBefore(node, 1);
expect(list.peekFront()).toBe(1);
expect(list.peekBack()).toBe(2);
expect(list.length).toBe(2);
});
it('should insert before middle node', () => {
const list = new LinkedList([1, 3]);
const tail = list.tail!;
list.insertBefore(tail, 2);
expect(list.toArray()).toEqual([1, 2, 3]);
});
it('should return the created node', () => {
const list = new LinkedList<number>();
const existing = list.pushBack(2);
const newNode = list.insertBefore(existing, 1);
expect(newNode.value).toBe(1);
expect(newNode.next).toBe(existing);
});
});
describe('insertAfter', () => {
it('should insert after tail', () => {
const list = new LinkedList<number>();
const node = list.pushBack(1);
list.insertAfter(node, 2);
expect(list.peekFront()).toBe(1);
expect(list.peekBack()).toBe(2);
expect(list.length).toBe(2);
});
it('should insert after middle node', () => {
const list = new LinkedList([1, 3]);
const head = list.head!;
list.insertAfter(head, 2);
expect(list.toArray()).toEqual([1, 2, 3]);
});
it('should return the created node', () => {
const list = new LinkedList<number>();
const existing = list.pushBack(1);
const newNode = list.insertAfter(existing, 2);
expect(newNode.value).toBe(2);
expect(newNode.prev).toBe(existing);
});
});
describe('remove', () => {
it('should remove head node', () => {
const list = new LinkedList([1, 2, 3]);
const head = list.head!;
const value = list.remove(head);
expect(value).toBe(1);
expect(list.length).toBe(2);
expect(list.peekFront()).toBe(2);
});
it('should remove tail node', () => {
const list = new LinkedList([1, 2, 3]);
const tail = list.tail!;
const value = list.remove(tail);
expect(value).toBe(3);
expect(list.length).toBe(2);
expect(list.peekBack()).toBe(2);
});
it('should remove middle node', () => {
const list = new LinkedList([1, 2, 3]);
const middle = list.head!.next!;
const value = list.remove(middle);
expect(value).toBe(2);
expect(list.toArray()).toEqual([1, 3]);
});
it('should remove single element', () => {
const list = new LinkedList<number>();
const node = list.pushBack(1);
list.remove(node);
expect(list.isEmpty).toBe(true);
expect(list.head).toBeUndefined();
expect(list.tail).toBeUndefined();
});
it('should detach the removed node', () => {
const list = new LinkedList([1, 2, 3]);
const middle = list.head!.next!;
list.remove(middle);
expect(middle.prev).toBeUndefined();
expect(middle.next).toBeUndefined();
});
});
describe('clear', () => {
it('should remove all elements', () => {
const list = new LinkedList([1, 2, 3]);
const result = list.clear();
expect(list.length).toBe(0);
expect(list.isEmpty).toBe(true);
expect(list.head).toBeUndefined();
expect(list.tail).toBeUndefined();
expect(result).toBe(list);
});
});
describe('toArray', () => {
it('should return empty array for empty list', () => {
const list = new LinkedList<number>();
expect(list.toArray()).toEqual([]);
});
it('should return values from head to tail', () => {
const list = new LinkedList([1, 2, 3]);
expect(list.toArray()).toEqual([1, 2, 3]);
});
});
describe('toString', () => {
it('should return comma-separated values', () => {
const list = new LinkedList([1, 2, 3]);
expect(list.toString()).toBe('1,2,3');
});
});
describe('iterator', () => {
it('should iterate from head to tail', () => {
const list = new LinkedList([1, 2, 3]);
expect([...list]).toEqual([1, 2, 3]);
});
it('should yield nothing for empty list', () => {
const list = new LinkedList<number>();
expect([...list]).toEqual([]);
});
});
describe('async iterator', () => {
it('should async iterate from head to tail', async () => {
const list = new LinkedList([1, 2, 3]);
const result: number[] = [];
for await (const value of list)
result.push(value);
expect(result).toEqual([1, 2, 3]);
});
});
describe('node linking', () => {
it('should maintain correct prev/next references', () => {
const list = new LinkedList<number>();
const a = list.pushBack(1);
const b = list.pushBack(2);
const c = list.pushBack(3);
expect(a.next).toBe(b);
expect(b.prev).toBe(a);
expect(b.next).toBe(c);
expect(c.prev).toBe(b);
expect(a.prev).toBeUndefined();
expect(c.next).toBeUndefined();
});
it('should update links after removal', () => {
const list = new LinkedList<number>();
const a = list.pushBack(1);
const b = list.pushBack(2);
const c = list.pushBack(3);
list.remove(b);
expect(a.next).toBe(c);
expect(c.prev).toBe(a);
});
});
describe('interleaved operations', () => {
it('should handle mixed push/pop from both ends', () => {
const list = new LinkedList<number>();
list.pushBack(1);
list.pushBack(2);
list.pushFront(0);
expect(list.popFront()).toBe(0);
expect(list.popBack()).toBe(2);
expect(list.popFront()).toBe(1);
expect(list.isEmpty).toBe(true);
});
it('should handle insert and remove by node reference', () => {
const list = new LinkedList<number>();
const a = list.pushBack(1);
const c = list.pushBack(3);
const b = list.insertAfter(a, 2);
const d = list.insertBefore(c, 2.5);
expect(list.toArray()).toEqual([1, 2, 2.5, 3]);
list.remove(b);
list.remove(d);
expect(list.toArray()).toEqual([1, 3]);
});
});
});

View File

@@ -1,330 +0,0 @@
import { isArray } from '../../types';
import type { LinkedListLike, LinkedListNode } from './types';
export type { LinkedListLike, LinkedListNode } from './types';
/**
* Creates a new doubly linked list node
*
* @template T The type of the value
* @param {T} value The value to store
* @returns {LinkedListNode<T>} The created node
*/
function createNode<T>(value: T): LinkedListNode<T> {
return { value, prev: undefined, next: undefined };
}
/**
* @name LinkedList
* @category Data Structures
* @description Doubly linked list with O(1) push/pop on both ends and O(1) insert/remove by node reference
*
* @since 0.0.8
*
* @template T The type of elements stored in the list
*/
export class LinkedList<T> implements LinkedListLike<T> {
/**
* The number of elements in the list
*
* @private
* @type {number}
*/
private count = 0;
/**
* The first node in the list
*
* @private
* @type {LinkedListNode<T> | undefined}
*/
private first: LinkedListNode<T> | undefined;
/**
* The last node in the list
*
* @private
* @type {LinkedListNode<T> | undefined}
*/
private last: LinkedListNode<T> | undefined;
/**
* Creates an instance of LinkedList
*
* @param {(T[] | T)} [initialValues] The initial values to add to the list
*/
constructor(initialValues?: T[] | T) {
if (initialValues !== null && initialValues !== undefined) {
const items = isArray(initialValues) ? initialValues : [initialValues];
for (const item of items)
this.pushBack(item);
}
}
/**
* Gets the number of elements in the list
* @returns {number} The number of elements in the list
*/
public get length(): number {
return this.count;
}
/**
* Checks if the list is empty
* @returns {boolean} `true` if the list is empty, `false` otherwise
*/
public get isEmpty(): boolean {
return this.count === 0;
}
/**
* Gets the first node
* @returns {LinkedListNode<T> | undefined} The first node, or `undefined` if the list is empty
*/
public get head(): LinkedListNode<T> | undefined {
return this.first;
}
/**
* Gets the last node
* @returns {LinkedListNode<T> | undefined} The last node, or `undefined` if the list is empty
*/
public get tail(): LinkedListNode<T> | undefined {
return this.last;
}
/**
* Appends a value to the end of the list
* @param {T} value The value to append
* @returns {LinkedListNode<T>} The created node
*/
public pushBack(value: T): LinkedListNode<T> {
const node = createNode(value);
if (this.last) {
node.prev = this.last;
this.last.next = node;
this.last = node;
}
else {
this.first = node;
this.last = node;
}
this.count++;
return node;
}
/**
* Prepends a value to the beginning of the list
* @param {T} value The value to prepend
* @returns {LinkedListNode<T>} The created node
*/
public pushFront(value: T): LinkedListNode<T> {
const node = createNode(value);
if (this.first) {
node.next = this.first;
this.first.prev = node;
this.first = node;
}
else {
this.first = node;
this.last = node;
}
this.count++;
return node;
}
/**
* Removes and returns the last value
* @returns {T | undefined} The last value, or `undefined` if the list is empty
*/
public popBack(): T | undefined {
if (!this.last) return undefined;
const node = this.last;
this.detach(node);
return node.value;
}
/**
* Removes and returns the first value
* @returns {T | undefined} The first value, or `undefined` if the list is empty
*/
public popFront(): T | undefined {
if (!this.first) return undefined;
const node = this.first;
this.detach(node);
return node.value;
}
/**
* Returns the last value without removing it
* @returns {T | undefined} The last value, or `undefined` if the list is empty
*/
public peekBack(): T | undefined {
return this.last?.value;
}
/**
* Returns the first value without removing it
* @returns {T | undefined} The first value, or `undefined` if the list is empty
*/
public peekFront(): T | undefined {
return this.first?.value;
}
/**
* Inserts a value before the given node
* @param {LinkedListNode<T>} node The reference node
* @param {T} value The value to insert
* @returns {LinkedListNode<T>} The created node
*/
public insertBefore(node: LinkedListNode<T>, value: T): LinkedListNode<T> {
const newNode = createNode(value);
newNode.next = node;
newNode.prev = node.prev;
if (node.prev) {
node.prev.next = newNode;
}
else {
this.first = newNode;
}
node.prev = newNode;
this.count++;
return newNode;
}
/**
* Inserts a value after the given node
* @param {LinkedListNode<T>} node The reference node
* @param {T} value The value to insert
* @returns {LinkedListNode<T>} The created node
*/
public insertAfter(node: LinkedListNode<T>, value: T): LinkedListNode<T> {
const newNode = createNode(value);
newNode.prev = node;
newNode.next = node.next;
if (node.next) {
node.next.prev = newNode;
}
else {
this.last = newNode;
}
node.next = newNode;
this.count++;
return newNode;
}
/**
* Removes a node from the list by reference in O(1)
* @param {LinkedListNode<T>} node The node to remove
* @returns {T} The value of the removed node
*/
public remove(node: LinkedListNode<T>): T {
this.detach(node);
return node.value;
}
/**
* Removes all elements from the list
* @returns {this} The list instance for chaining
*/
public clear(): this {
this.first = undefined;
this.last = undefined;
this.count = 0;
return this;
}
/**
* Returns a shallow copy of the list values as an array
* @returns {T[]} Array of values from head to tail
*/
public toArray(): T[] {
const result = Array.from<T>({ length: this.count });
let current = this.first;
let i = 0;
while (current) {
result[i++] = current.value;
current = current.next;
}
return result;
}
/**
* Returns a string representation of the list
* @returns {string} String representation
*/
public toString(): string {
return this.toArray().toString();
}
/**
* Iterator over list values from head to tail
*/
public* [Symbol.iterator](): Iterator<T> {
let current = this.first;
while (current) {
yield current.value;
current = current.next;
}
}
/**
* Async iterator over list values from head to tail
*/
public async* [Symbol.asyncIterator](): AsyncIterator<T> {
for (const value of this)
yield value;
}
/**
* Detaches a node from the list, updating head/tail and count
*
* @private
* @param {LinkedListNode<T>} node The node to detach
*/
private detach(node: LinkedListNode<T>): void {
if (node.prev) {
node.prev.next = node.next;
}
else {
this.first = node.next;
}
if (node.next) {
node.next.prev = node.prev;
}
else {
this.last = node.prev;
}
node.prev = undefined;
node.next = undefined;
this.count--;
}
}

View File

@@ -1,28 +0,0 @@
export interface LinkedListNode<T> {
value: T;
prev: LinkedListNode<T> | undefined;
next: LinkedListNode<T> | undefined;
}
export interface LinkedListLike<T> extends Iterable<T>, AsyncIterable<T> {
readonly length: number;
readonly isEmpty: boolean;
readonly head: LinkedListNode<T> | undefined;
readonly tail: LinkedListNode<T> | undefined;
pushBack(value: T): LinkedListNode<T>;
pushFront(value: T): LinkedListNode<T>;
popBack(): T | undefined;
popFront(): T | undefined;
peekBack(): T | undefined;
peekFront(): T | undefined;
insertBefore(node: LinkedListNode<T>, value: T): LinkedListNode<T>;
insertAfter(node: LinkedListNode<T>, value: T): LinkedListNode<T>;
remove(node: LinkedListNode<T>): T;
clear(): this;
toArray(): T[];
toString(): string;
}

View File

@@ -1,213 +0,0 @@
import { describe, expect, it } from 'vitest';
import { PriorityQueue } from '.';
describe('PriorityQueue', () => {
describe('constructor', () => {
it('should create an empty queue', () => {
const pq = new PriorityQueue<number>();
expect(pq.length).toBe(0);
expect(pq.isEmpty).toBe(true);
expect(pq.isFull).toBe(false);
});
it('should create a queue from single value', () => {
const pq = new PriorityQueue(42);
expect(pq.length).toBe(1);
expect(pq.peek()).toBe(42);
});
it('should create a queue from array', () => {
const pq = new PriorityQueue([5, 3, 8, 1, 4]);
expect(pq.length).toBe(5);
expect(pq.peek()).toBe(1);
});
it('should throw if initial values exceed maxSize', () => {
expect(() => new PriorityQueue([1, 2, 3], { maxSize: 2 }))
.toThrow('Initial values exceed maxSize');
});
});
describe('enqueue', () => {
it('should enqueue elements by priority', () => {
const pq = new PriorityQueue<number>();
pq.enqueue(5);
pq.enqueue(1);
pq.enqueue(3);
expect(pq.peek()).toBe(1);
expect(pq.length).toBe(3);
});
it('should throw when queue is full', () => {
const pq = new PriorityQueue<number>(undefined, { maxSize: 2 });
pq.enqueue(1);
pq.enqueue(2);
expect(() => pq.enqueue(3)).toThrow('PriorityQueue is full');
});
});
describe('dequeue', () => {
it('should return undefined for empty queue', () => {
const pq = new PriorityQueue<number>();
expect(pq.dequeue()).toBeUndefined();
});
it('should dequeue elements in priority order (min-heap)', () => {
const pq = new PriorityQueue([5, 3, 8, 1, 4]);
const result: number[] = [];
while (!pq.isEmpty) {
result.push(pq.dequeue()!);
}
expect(result).toEqual([1, 3, 4, 5, 8]);
});
it('should dequeue elements in priority order (max-heap)', () => {
const pq = new PriorityQueue([5, 3, 8, 1, 4], {
comparator: (a, b) => b - a,
});
const result: number[] = [];
while (!pq.isEmpty) {
result.push(pq.dequeue()!);
}
expect(result).toEqual([8, 5, 4, 3, 1]);
});
});
describe('peek', () => {
it('should return undefined for empty queue', () => {
const pq = new PriorityQueue<number>();
expect(pq.peek()).toBeUndefined();
});
it('should return highest-priority element without removing', () => {
const pq = new PriorityQueue([5, 1, 3]);
expect(pq.peek()).toBe(1);
expect(pq.length).toBe(3);
});
});
describe('isFull', () => {
it('should be false when no maxSize', () => {
const pq = new PriorityQueue([1, 2, 3]);
expect(pq.isFull).toBe(false);
});
it('should be true when at maxSize', () => {
const pq = new PriorityQueue([1, 2], { maxSize: 2 });
expect(pq.isFull).toBe(true);
});
it('should become false after dequeue', () => {
const pq = new PriorityQueue([1, 2], { maxSize: 2 });
pq.dequeue();
expect(pq.isFull).toBe(false);
});
});
describe('clear', () => {
it('should remove all elements', () => {
const pq = new PriorityQueue([1, 2, 3]);
const result = pq.clear();
expect(pq.length).toBe(0);
expect(pq.isEmpty).toBe(true);
expect(result).toBe(pq);
});
});
describe('toArray', () => {
it('should return empty array for empty queue', () => {
const pq = new PriorityQueue<number>();
expect(pq.toArray()).toEqual([]);
});
it('should return a shallow copy', () => {
const pq = new PriorityQueue([3, 1, 2]);
const arr = pq.toArray();
arr.push(99);
expect(pq.length).toBe(3);
});
});
describe('toString', () => {
it('should return formatted string', () => {
const pq = new PriorityQueue([1, 2, 3]);
expect(pq.toString()).toBe('PriorityQueue(3)');
});
});
describe('iterator', () => {
it('should iterate over elements', () => {
const pq = new PriorityQueue([5, 3, 1]);
const elements = [...pq];
expect(elements.length).toBe(3);
});
});
describe('custom comparator', () => {
it('should work with object priority', () => {
interface Job {
priority: number;
name: string;
}
const pq = new PriorityQueue<Job>(
[
{ priority: 3, name: 'low' },
{ priority: 1, name: 'critical' },
{ priority: 2, name: 'normal' },
],
{ comparator: (a, b) => a.priority - b.priority },
);
expect(pq.dequeue()?.name).toBe('critical');
expect(pq.dequeue()?.name).toBe('normal');
expect(pq.dequeue()?.name).toBe('low');
});
});
describe('interleaved operations', () => {
it('should maintain priority with mixed enqueue and dequeue', () => {
const pq = new PriorityQueue<number>();
pq.enqueue(10);
pq.enqueue(5);
expect(pq.dequeue()).toBe(5);
pq.enqueue(3);
pq.enqueue(7);
expect(pq.dequeue()).toBe(3);
pq.enqueue(1);
expect(pq.dequeue()).toBe(1);
expect(pq.dequeue()).toBe(7);
expect(pq.dequeue()).toBe(10);
expect(pq.dequeue()).toBeUndefined();
});
});
});

View File

@@ -1,144 +0,0 @@
import { BinaryHeap } from '../BinaryHeap';
import type { Comparator, PriorityQueueLike } from './types';
export type { PriorityQueueLike } from './types';
export type { Comparator } from './types';
export interface PriorityQueueOptions<T> {
comparator?: Comparator<T>;
maxSize?: number;
}
/**
* @name PriorityQueue
* @category Data Structures
* @description Priority queue backed by a binary heap with configurable comparator and optional max size
*
* @since 0.0.8
*
* @template T The type of elements stored in the queue
*/
export class PriorityQueue<T> implements PriorityQueueLike<T> {
/**
* The maximum number of elements the queue can hold
*
* @private
* @type {number}
*/
private readonly maxSize: number;
/**
* Internal binary heap backing the queue
*
* @private
* @type {BinaryHeap<T>}
*/
private readonly heap: BinaryHeap<T>;
/**
* Creates an instance of PriorityQueue
*
* @param {(T[] | T)} [initialValues] The initial values to add to the queue
* @param {PriorityQueueOptions<T>} [options] Queue configuration
*/
constructor(initialValues?: T[] | T, options?: PriorityQueueOptions<T>) {
this.maxSize = options?.maxSize ?? Infinity;
this.heap = new BinaryHeap(initialValues, { comparator: options?.comparator });
if (this.heap.length > this.maxSize) {
throw new RangeError('Initial values exceed maxSize');
}
}
/**
* Gets the number of elements in the queue
* @returns {number} The number of elements in the queue
*/
public get length(): number {
return this.heap.length;
}
/**
* Checks if the queue is empty
* @returns {boolean} `true` if the queue is empty, `false` otherwise
*/
public get isEmpty(): boolean {
return this.heap.isEmpty;
}
/**
* Checks if the queue is full
* @returns {boolean} `true` if the queue has reached maxSize, `false` otherwise
*/
public get isFull(): boolean {
return this.heap.length >= this.maxSize;
}
/**
* Enqueues an element by priority
* @param {T} element The element to enqueue
* @throws {RangeError} If the queue is full
*/
public enqueue(element: T): void {
if (this.isFull)
throw new RangeError('PriorityQueue is full');
this.heap.push(element);
}
/**
* Dequeues the highest-priority element
* @returns {T | undefined} The highest-priority element, or `undefined` if empty
*/
public dequeue(): T | undefined {
return this.heap.pop();
}
/**
* Returns the highest-priority element without removing it
* @returns {T | undefined} The highest-priority element, or `undefined` if empty
*/
public peek(): T | undefined {
return this.heap.peek();
}
/**
* Removes all elements from the queue
* @returns {this} The queue instance for chaining
*/
public clear(): this {
this.heap.clear();
return this;
}
/**
* Returns a shallow copy of elements in heap order
* @returns {T[]} Array of elements
*/
public toArray(): T[] {
return this.heap.toArray();
}
/**
* Returns a string representation of the queue
* @returns {string} String representation
*/
public toString(): string {
return `PriorityQueue(${this.heap.length})`;
}
/**
* Iterator over queue elements in heap order
*/
public* [Symbol.iterator](): Iterator<T> {
yield* this.heap;
}
/**
* Async iterator over queue elements in heap order
*/
public async* [Symbol.asyncIterator](): AsyncIterator<T> {
for (const element of this.heap)
yield element;
}
}

View File

@@ -1,16 +0,0 @@
import type { Comparator } from '../BinaryHeap';
export interface PriorityQueueLike<T> extends Iterable<T>, AsyncIterable<T> {
readonly length: number;
readonly isEmpty: boolean;
readonly isFull: boolean;
enqueue(element: T): void;
dequeue(): T | undefined;
peek(): T | undefined;
clear(): this;
toArray(): T[];
toString(): string;
}
export type { Comparator };

View File

@@ -1,207 +0,0 @@
import { describe, it, expect } from 'vitest';
import { Queue } from '.';
describe('queue', () => {
describe('constructor', () => {
it('create an empty queue if no initial values are provided', () => {
const queue = new Queue<number>();
expect(queue.length).toBe(0);
expect(queue.isEmpty).toBe(true);
});
it('create a queue with the provided initial values', () => {
const queue = new Queue([1, 2, 3]);
expect(queue.length).toBe(3);
expect(queue.peek()).toBe(1);
});
it('create a queue with a single initial value', () => {
const queue = new Queue(42);
expect(queue.length).toBe(1);
expect(queue.peek()).toBe(42);
});
it('create a queue with the provided options', () => {
const queue = new Queue<number>(undefined, { maxSize: 5 });
expect(queue.length).toBe(0);
expect(queue.isFull).toBe(false);
});
});
describe('enqueue', () => {
it('add an element to the back of the queue', () => {
const queue = new Queue<number>();
queue.enqueue(1);
expect(queue.length).toBe(1);
expect(queue.peek()).toBe(1);
});
it('maintain FIFO order', () => {
const queue = new Queue<number>();
queue.enqueue(1).enqueue(2).enqueue(3);
expect(queue.peek()).toBe(1);
});
it('throw an error if the queue is full', () => {
const queue = new Queue<number>(undefined, { maxSize: 1 });
queue.enqueue(1);
expect(() => queue.enqueue(2)).toThrow(new RangeError('Queue is full'));
});
it('return this for chaining', () => {
const queue = new Queue<number>();
const result = queue.enqueue(1);
expect(result).toBe(queue);
});
});
describe('dequeue', () => {
it('remove and return the front element', () => {
const queue = new Queue([1, 2, 3]);
const element = queue.dequeue();
expect(element).toBe(1);
expect(queue.length).toBe(2);
});
it('return undefined if the queue is empty', () => {
const queue = new Queue<number>();
expect(queue.dequeue()).toBeUndefined();
});
it('maintain FIFO order across multiple dequeues', () => {
const queue = new Queue([1, 2, 3]);
expect(queue.dequeue()).toBe(1);
expect(queue.dequeue()).toBe(2);
expect(queue.dequeue()).toBe(3);
expect(queue.dequeue()).toBeUndefined();
});
it('compact internal storage after many dequeues', () => {
const queue = new Queue<number>();
for (let i = 0; i < 100; i++)
queue.enqueue(i);
for (let i = 0; i < 80; i++)
queue.dequeue();
expect(queue.length).toBe(20);
expect(queue.peek()).toBe(80);
});
});
describe('peek', () => {
it('return the front element without removing it', () => {
const queue = new Queue([1, 2, 3]);
expect(queue.peek()).toBe(1);
expect(queue.length).toBe(3);
});
it('return undefined if the queue is empty', () => {
const queue = new Queue<number>();
expect(queue.peek()).toBeUndefined();
});
});
describe('clear', () => {
it('clear the queue', () => {
const queue = new Queue([1, 2, 3]);
queue.clear();
expect(queue.length).toBe(0);
expect(queue.isEmpty).toBe(true);
});
it('return this for chaining', () => {
const queue = new Queue([1, 2, 3]);
expect(queue.clear()).toBe(queue);
});
});
describe('toArray', () => {
it('return elements in FIFO order', () => {
const queue = new Queue([1, 2, 3]);
expect(queue.toArray()).toEqual([1, 2, 3]);
});
it('return correct array after dequeues', () => {
const queue = new Queue([1, 2, 3, 4, 5]);
queue.dequeue();
queue.dequeue();
expect(queue.toArray()).toEqual([3, 4, 5]);
});
});
describe('toString', () => {
it('return comma-separated string', () => {
const queue = new Queue([1, 2, 3]);
expect(queue.toString()).toBe('1,2,3');
});
});
describe('iteration', () => {
it('iterate over the queue in FIFO order', () => {
const queue = new Queue([1, 2, 3]);
expect([...queue]).toEqual([1, 2, 3]);
});
it('iterate correctly after dequeues', () => {
const queue = new Queue([1, 2, 3, 4]);
queue.dequeue();
expect([...queue]).toEqual([2, 3, 4]);
});
it('iterate over the queue asynchronously in FIFO order', async () => {
const queue = new Queue([1, 2, 3]);
const elements: number[] = [];
for await (const element of queue)
elements.push(element);
expect(elements).toEqual([1, 2, 3]);
});
});
describe('mixed operations', () => {
it('interleave enqueue and dequeue', () => {
const queue = new Queue<number>();
queue.enqueue(1);
queue.enqueue(2);
expect(queue.dequeue()).toBe(1);
queue.enqueue(3);
expect(queue.dequeue()).toBe(2);
expect(queue.dequeue()).toBe(3);
expect(queue.isEmpty).toBe(true);
});
it('reuse queue after clear', () => {
const queue = new Queue([1, 2, 3]);
queue.clear();
queue.enqueue(4);
expect(queue.length).toBe(1);
expect(queue.peek()).toBe(4);
});
});
});

View File

@@ -1,140 +0,0 @@
import { Deque } from '../Deque';
import type { QueueLike } from './types';
export type { QueueLike } from './types';
export interface QueueOptions {
maxSize?: number;
}
/**
* @name Queue
* @category Data Structures
* @description Represents a queue data structure (FIFO) backed by a Deque
*
* @since 0.0.8
*
* @template T The type of elements stored in the queue
*/
export class Queue<T> implements QueueLike<T> {
/**
* The underlying deque
*
* @private
* @type {Deque<T>}
*/
private readonly deque: Deque<T>;
/**
* Creates an instance of Queue
*
* @param {(T[] | T)} [initialValues] The initial values to add to the queue
* @param {QueueOptions} [options] The options for the queue
*/
constructor(initialValues?: T[] | T, options?: QueueOptions) {
this.deque = new Deque(initialValues, options);
}
/**
* Gets the number of elements in the queue
* @returns {number} The number of elements in the queue
*/
get length() {
return this.deque.length;
}
/**
* Checks if the queue is empty
* @returns {boolean} `true` if the queue is empty, `false` otherwise
*/
get isEmpty() {
return this.deque.isEmpty;
}
/**
* Checks if the queue is full
* @returns {boolean} `true` if the queue is full, `false` otherwise
*/
get isFull() {
return this.deque.isFull;
}
/**
* Adds an element to the back of the queue
* @param {T} element The element to enqueue
* @returns {this}
* @throws {RangeError} If the queue is full
*/
enqueue(element: T) {
if (this.deque.isFull)
throw new RangeError('Queue is full');
this.deque.pushBack(element);
return this;
}
/**
* Removes and returns the front element of the queue
* @returns {T | undefined} The front element, or undefined if the queue is empty
*/
dequeue() {
return this.deque.popFront();
}
/**
* Returns the front element without removing it
* @returns {T | undefined} The front element, or undefined if the queue is empty
*/
peek() {
return this.deque.peekFront();
}
/**
* Clears the queue
*
* @returns {this}
*/
clear() {
this.deque.clear();
return this;
}
/**
* Converts the queue to an array in FIFO order
*
* @returns {T[]}
*/
toArray() {
return this.deque.toArray();
}
/**
* Returns a string representation of the queue
*
* @returns {string}
*/
toString() {
return this.deque.toString();
}
/**
* Returns an iterator for the queue
*
* @returns {IterableIterator<T>}
*/
[Symbol.iterator]() {
return this.deque[Symbol.iterator]();
}
/**
* Returns an async iterator for the queue
*
* @returns {AsyncIterableIterator<T>}
*/
async* [Symbol.asyncIterator]() {
for (const element of this.deque)
yield element;
}
}

Some files were not shown because too many files have changed in this diff Show More