mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 19:04:46 +00:00
Compare commits
34 Commits
7a5bc07472
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1111848728 | ||
|
|
170093a039 | ||
|
|
11f823afb4 | ||
| 5fa38110b7 | |||
| 4574bae0b6 | |||
| a996eb74b9 | |||
| bcc9cb2915 | |||
| 41d5e18f6b | |||
| abd6605db3 | |||
| a83e2bb797 | |||
| 9bece480ca | |||
| c48de9a3d1 | |||
| 624e12ed96 | |||
| 3380d90cee | |||
| bb644579ca | |||
| e7d1021d27 | |||
| 1782184761 | |||
| 70d96b7f39 | |||
|
|
9587c92e50 | ||
| 678c18a08d | |||
| 68afec40b7 | |||
| 50b1498f3e | |||
| 7b5da22290 | |||
| 09fe8079c0 | |||
| ab9f45f908 | |||
| 49b9f2aa79 | |||
| 2a5412c3b8 | |||
| 5f9e0dc72d | |||
| 6565fa3de8 | |||
| 7dce7ed482 | |||
| df13f0b827 | |||
| 3da393ed08 | |||
| efadb5fe28 | |||
|
|
07e6d3eadc |
409
.github/skills/monorepo/SKILL.md
vendored
Normal file
409
.github/skills/monorepo/SKILL.md
vendored
Normal file
@@ -0,0 +1,409 @@
|
||||
---
|
||||
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)
|
||||
8
.github/workflows/ci.yaml
vendored
8
.github/workflows/ci.yaml
vendored
@@ -31,5 +31,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint:check
|
||||
|
||||
- name: Test
|
||||
run: pnpm build && pnpm test
|
||||
run: pnpm test
|
||||
67
configs/oxlint/README.md
Normal file
67
configs/oxlint/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# @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
|
||||
4
configs/oxlint/oxlint.config.ts
Normal file
4
configs/oxlint/oxlint.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(compose(base, typescript, imports, stylistic));
|
||||
66
configs/oxlint/package.json
Normal file
66
configs/oxlint/package.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"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": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./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"
|
||||
}
|
||||
}
|
||||
21
configs/oxlint/rules/README.md
Normal file
21
configs/oxlint/rules/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 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`.
|
||||
34
configs/oxlint/rules/base.md
Normal file
34
configs/oxlint/rules/base.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 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';
|
||||
}
|
||||
```
|
||||
27
configs/oxlint/rules/imports.md
Normal file
27
configs/oxlint/rules/imports.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 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;
|
||||
```
|
||||
22
configs/oxlint/rules/node.md
Normal file
22
configs/oxlint/rules/node.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 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');
|
||||
```
|
||||
51
configs/oxlint/rules/stylistic.md
Normal file
51
configs/oxlint/rules/stylistic.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 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`.
|
||||
33
configs/oxlint/rules/typescript.md
Normal file
33
configs/oxlint/rules/typescript.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 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;
|
||||
}
|
||||
```
|
||||
34
configs/oxlint/rules/vitest.md
Normal file
34
configs/oxlint/rules/vitest.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 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);
|
||||
});
|
||||
}
|
||||
```
|
||||
30
configs/oxlint/rules/vue.md
Normal file
30
configs/oxlint/rules/vue.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 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>
|
||||
```
|
||||
120
configs/oxlint/src/compose.ts
Normal file
120
configs/oxlint/src/compose.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
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;
|
||||
}
|
||||
18
configs/oxlint/src/index.ts
Normal file
18
configs/oxlint/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/* 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';
|
||||
73
configs/oxlint/src/presets/base.ts
Normal file
73
configs/oxlint/src/presets/base.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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',
|
||||
},
|
||||
};
|
||||
22
configs/oxlint/src/presets/imports.ts
Normal file
22
configs/oxlint/src/presets/imports.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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',
|
||||
},
|
||||
};
|
||||
7
configs/oxlint/src/presets/index.ts
Normal file
7
configs/oxlint/src/presets/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
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';
|
||||
17
configs/oxlint/src/presets/node.ts
Normal file
17
configs/oxlint/src/presets/node.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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',
|
||||
},
|
||||
};
|
||||
162
configs/oxlint/src/presets/stylistic.ts
Normal file
162
configs/oxlint/src/presets/stylistic.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
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', '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',
|
||||
}],
|
||||
},
|
||||
};
|
||||
39
configs/oxlint/src/presets/typescript.ts
Normal file
39
configs/oxlint/src/presets/typescript.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
35
configs/oxlint/src/presets/vitest.ts
Normal file
35
configs/oxlint/src/presets/vitest.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
26
configs/oxlint/src/presets/vue.ts
Normal file
26
configs/oxlint/src/presets/vue.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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',
|
||||
},
|
||||
};
|
||||
19
configs/oxlint/src/types.ts
Normal file
19
configs/oxlint/src/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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';
|
||||
171
configs/oxlint/test/compose.test.ts
Normal file
171
configs/oxlint/test/compose.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM"]
|
||||
}
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
7
configs/oxlint/tsdown.config.ts
Normal file
7
configs/oxlint/tsdown.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
import { sharedConfig } from '@robonen/tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
...sharedConfig,
|
||||
entry: ['src/index.ts'],
|
||||
});
|
||||
7
configs/oxlint/vitest.config.ts
Normal file
7
configs/oxlint/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -1,45 +1,27 @@
|
||||
# @robonen/tsconfig
|
||||
|
||||
Базовый конфигурационный файл для TypeScript
|
||||
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
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "Preserve", // использовать ту же версию модуля, что и сборщик
|
||||
"noEmit": true, // не генерировать файлы
|
||||
"moduleResolution": "Bundler", // разрешение модулей на основе сборщика
|
||||
"target": "ESNext", // целевая версия JavaScript
|
||||
|
||||
|
||||
"skipLibCheck": true, // не проверять типы, заданные во всех файлах описания типов (*.d.ts)
|
||||
"esModuleInterop": true, // создать хелперы __importStar и __importDefault для обеспечения совместимости с экосистемой Babel и включить allowSyntheticDefaultImports для совместимости с системой типов
|
||||
"allowSyntheticDefaultImports": true, // разрешить импортировать модули не имеющие внутри себя "import default"
|
||||
"allowJs": true, // разрешить импортировать файлы JavaScript
|
||||
"resolveJsonModule": true, // разрешить импортировать файлы JSON
|
||||
"moduleDetection": "force", // заставляет TypeScript рассматривать все файлы как модули. Это помогает избежать ошибок cannot redeclare block-scoped variable»
|
||||
"isolatedModules": true, // орабатывать каждый файл, как отдельный изолированный модуль
|
||||
"removeComments": false, // удалять комментарии из исходного кода
|
||||
"verbatimModuleSyntax": true, // сохранять синтаксис модулей в исходном коде (важно при импорте типов)
|
||||
"useDefineForClassFields": true, // использование классов стандарта TC39, а не TypeScript
|
||||
"strict": true, // включить все строгие проверки (noImplicitAny, noImplicitThis, alwaysStrict, strictNullChecks, strictFunctionTypes, strictPropertyInitialization)
|
||||
"noUncheckedIndexedAccess": true, // запрещает доступ к массиву или объекту без предварительной проверки того, определен ли он
|
||||
"declaration": true, // генерировать файлы описания типов (*.d.ts)
|
||||
|
||||
"composite": true, // указывает TypeScript создавать файлы .tsbuildinfo. Это сообщает TypeScript, что ваш проект является частью монорепозитория, а также помогает кэшировать сборки для более быстрой работы
|
||||
"sourceMap": true, // генерировать карту исходного кода
|
||||
"declarationMap": true // генерировать карту исходного кода для файлов описания типов (*.d.ts)
|
||||
}
|
||||
```
|
||||
- **Target / Module**: ESNext with Bundler resolution
|
||||
- **Strict mode**: `strict`, `noUncheckedIndexedAccess`
|
||||
- **Module safety**: `verbatimModuleSyntax`, `isolatedModules`
|
||||
- **Declarations**: `declaration` enabled
|
||||
- **Interop**: `esModuleInterop`, `allowJs`, `resolveJsonModule`
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/tsconfig"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"engines": {
|
||||
"node": ">=24.13.0"
|
||||
"node": ">=24.13.1"
|
||||
},
|
||||
"files": [
|
||||
"**tsconfig.json"
|
||||
|
||||
30
configs/tsdown/package.json
Normal file
30
configs/tsdown/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
13
configs/tsdown/src/index.ts
Normal file
13
configs/tsdown/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { InlineConfig } 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 InlineConfig;
|
||||
3
configs/tsdown/tsconfig.json
Normal file
3
configs/tsdown/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json"
|
||||
}
|
||||
6
core/encoding/jsr.json
Normal file
6
core/encoding/jsr.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||
"name": "@robonen/encoding",
|
||||
"version": "0.0.1",
|
||||
"exports": "./src/index.ts"
|
||||
}
|
||||
14
core/encoding/oxlint.config.ts
Normal file
14
core/encoding/oxlint.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
52
core/encoding/package.json
Normal file
52
core/encoding/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"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": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./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:"
|
||||
}
|
||||
}
|
||||
2
core/encoding/src/index.ts
Normal file
2
core/encoding/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './reed-solomon';
|
||||
export * from './qr';
|
||||
96
core/encoding/src/qr/__test__/index.bench.ts
Normal file
96
core/encoding/src/qr/__test__/index.bench.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
257
core/encoding/src/qr/__test__/index.test.ts
Normal file
257
core/encoding/src/qr/__test__/index.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { encodeText, encodeBinary, encodeSegments, makeSegments, isNumeric, isAlphanumeric, QrCode, QrCodeDataType, EccMap, LOW, MEDIUM, QUARTILE, 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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeSegments', () => {
|
||||
it('uses explicit mask when specified', () => {
|
||||
const qr = encodeSegments(makeSegments('Test'), LOW, 1, 40, 3);
|
||||
expect(qr.mask).toBe(3);
|
||||
});
|
||||
|
||||
it('preserves ECC level when boostEcl is false', () => {
|
||||
const qr = encodeSegments(makeSegments('Test'), LOW, 1, 40, -1, false);
|
||||
expect(qr.ecc).toBe(LOW);
|
||||
});
|
||||
|
||||
it('boosts ECC level by default when data fits', () => {
|
||||
const qr = encodeSegments(makeSegments('Test'), LOW);
|
||||
expect(qr.ecc).toBe(HIGH);
|
||||
});
|
||||
|
||||
it('forces a specific version when min equals max', () => {
|
||||
const qr = encodeSegments(makeSegments('Test'), LOW, 5, 5);
|
||||
expect(qr.version).toBe(5);
|
||||
});
|
||||
|
||||
it('throws on invalid version range', () => {
|
||||
expect(() => encodeSegments(makeSegments('Test'), LOW, 2, 1)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('throws on invalid mask value', () => {
|
||||
expect(() => encodeSegments(makeSegments('Test'), LOW, 1, 40, 8)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeBinary edge cases', () => {
|
||||
it('encodes an empty array', () => {
|
||||
const qr = encodeBinary([], LOW);
|
||||
expect(qr).toBeInstanceOf(QrCode);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeText edge cases', () => {
|
||||
it('encodes Unicode emoji text', () => {
|
||||
const qr = encodeText('Hello \uD83C\uDF0D', LOW);
|
||||
expect(qr).toBeInstanceOf(QrCode);
|
||||
expect(qr.size).toBeGreaterThanOrEqual(21);
|
||||
});
|
||||
|
||||
it('uses compact encoding for alphanumeric text', () => {
|
||||
const qr = encodeText('HELLO WORLD', LOW);
|
||||
expect(qr.version).toBe(1);
|
||||
});
|
||||
|
||||
it('selects version >= 7 for long data (triggers drawVersion)', () => {
|
||||
const qr = encodeText('a'.repeat(200), LOW);
|
||||
expect(qr.version).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getType semantics', () => {
|
||||
it('identifies finder pattern modules as Position', () => {
|
||||
const qr = encodeText('Test', LOW);
|
||||
// Top-left finder pattern
|
||||
expect(qr.getType(0, 0)).toBe(QrCodeDataType.Position);
|
||||
expect(qr.getType(3, 3)).toBe(QrCodeDataType.Position);
|
||||
expect(qr.getType(6, 6)).toBe(QrCodeDataType.Position);
|
||||
// Top-right finder pattern
|
||||
expect(qr.getType(qr.size - 1, 0)).toBe(QrCodeDataType.Position);
|
||||
// Bottom-left finder pattern
|
||||
expect(qr.getType(0, qr.size - 1)).toBe(QrCodeDataType.Position);
|
||||
});
|
||||
|
||||
it('identifies timing pattern modules as Timing', () => {
|
||||
const qr = encodeText('Test', LOW);
|
||||
// Horizontal timing row y=6, between finders
|
||||
expect(qr.getType(8, 6)).toBe(QrCodeDataType.Timing);
|
||||
});
|
||||
});
|
||||
92
core/encoding/src/qr/__test__/segment.test.ts
Normal file
92
core/encoding/src/qr/__test__/segment.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { QrSegment, makeNumeric, makeAlphanumeric, makeBytes } from '../segment';
|
||||
import { MODE_ALPHANUMERIC, MODE_BYTE, MODE_NUMERIC } from '../constants';
|
||||
|
||||
describe('QrSegment', () => {
|
||||
it('throws on negative numChars', () => {
|
||||
expect(() => new QrSegment(MODE_BYTE, -1, [])).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('accepts zero numChars', () => {
|
||||
const seg = new QrSegment(MODE_BYTE, 0, []);
|
||||
expect(seg.numChars).toBe(0);
|
||||
expect(seg.bitData).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeNumeric', () => {
|
||||
it('encodes a 5-digit string', () => {
|
||||
const seg = makeNumeric('12345');
|
||||
expect(seg.mode).toBe(MODE_NUMERIC);
|
||||
expect(seg.numChars).toBe(5);
|
||||
// "123" → 10 bits, "45" → 7 bits
|
||||
expect(seg.bitData).toHaveLength(17);
|
||||
});
|
||||
|
||||
it('encodes a single digit', () => {
|
||||
const seg = makeNumeric('0');
|
||||
expect(seg.numChars).toBe(1);
|
||||
expect(seg.bitData).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('encodes an empty string', () => {
|
||||
const seg = makeNumeric('');
|
||||
expect(seg.numChars).toBe(0);
|
||||
expect(seg.bitData).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('throws on non-numeric input', () => {
|
||||
expect(() => makeNumeric('12a3')).toThrow(RangeError);
|
||||
expect(() => makeNumeric('hello')).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeAlphanumeric', () => {
|
||||
it('encodes a character pair', () => {
|
||||
const seg = makeAlphanumeric('AB');
|
||||
expect(seg.mode).toBe(MODE_ALPHANUMERIC);
|
||||
expect(seg.numChars).toBe(2);
|
||||
// 1 pair → 11 bits
|
||||
expect(seg.bitData).toHaveLength(11);
|
||||
});
|
||||
|
||||
it('encodes a pair plus remainder', () => {
|
||||
const seg = makeAlphanumeric('ABC');
|
||||
expect(seg.numChars).toBe(3);
|
||||
// 1 pair (11 bits) + 1 remainder (6 bits)
|
||||
expect(seg.bitData).toHaveLength(17);
|
||||
});
|
||||
|
||||
it('throws on lowercase input', () => {
|
||||
expect(() => makeAlphanumeric('hello')).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('throws on invalid characters', () => {
|
||||
expect(() => makeAlphanumeric('test@email')).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeBytes', () => {
|
||||
it('encodes an empty array', () => {
|
||||
const seg = makeBytes([]);
|
||||
expect(seg.mode).toBe(MODE_BYTE);
|
||||
expect(seg.numChars).toBe(0);
|
||||
expect(seg.bitData).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('encodes two bytes', () => {
|
||||
const seg = makeBytes([0x48, 0x65]);
|
||||
expect(seg.numChars).toBe(2);
|
||||
expect(seg.bitData).toHaveLength(16);
|
||||
});
|
||||
|
||||
it('encodes 0xFF correctly', () => {
|
||||
const seg = makeBytes([0xFF]);
|
||||
expect(seg.bitData).toEqual([1, 1, 1, 1, 1, 1, 1, 1]);
|
||||
});
|
||||
|
||||
it('encodes 0x00 correctly', () => {
|
||||
const seg = makeBytes([0x00]);
|
||||
expect(seg.bitData).toEqual([0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
});
|
||||
});
|
||||
119
core/encoding/src/qr/__test__/utils.test.ts
Normal file
119
core/encoding/src/qr/__test__/utils.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { appendBits, getBit, getNumDataCodewords, getNumRawDataModules, getTotalBits, numCharCountBits } from '../utils';
|
||||
import { HIGH, LOW, MODE_BYTE, MODE_NUMERIC } from '../constants';
|
||||
import { QrSegment } from '../segment';
|
||||
|
||||
describe('appendBits', () => {
|
||||
it('appends nothing when len is 0', () => {
|
||||
const bb: number[] = [];
|
||||
appendBits(0, 0, bb);
|
||||
expect(bb).toEqual([]);
|
||||
});
|
||||
|
||||
it('appends bits in MSB-first order', () => {
|
||||
const bb: number[] = [];
|
||||
appendBits(0b101, 3, bb);
|
||||
expect(bb).toEqual([1, 0, 1]);
|
||||
});
|
||||
|
||||
it('appends to an existing array', () => {
|
||||
const bb = [1, 0];
|
||||
appendBits(0b11, 2, bb);
|
||||
expect(bb).toEqual([1, 0, 1, 1]);
|
||||
});
|
||||
|
||||
it('throws when value exceeds bit length', () => {
|
||||
expect(() => appendBits(5, 2, [])).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('throws on negative length', () => {
|
||||
expect(() => appendBits(0, -1, [])).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBit', () => {
|
||||
it('returns correct bits for 0b10110', () => {
|
||||
expect(getBit(0b10110, 0)).toBe(false);
|
||||
expect(getBit(0b10110, 1)).toBe(true);
|
||||
expect(getBit(0b10110, 2)).toBe(true);
|
||||
expect(getBit(0b10110, 3)).toBe(false);
|
||||
expect(getBit(0b10110, 4)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for high bits of a small number', () => {
|
||||
expect(getBit(1, 7)).toBe(false);
|
||||
expect(getBit(1, 31)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNumRawDataModules', () => {
|
||||
it('returns 208 for version 1', () => {
|
||||
expect(getNumRawDataModules(1)).toBe(208);
|
||||
});
|
||||
|
||||
it('returns correct value for version 2 (with alignment)', () => {
|
||||
expect(getNumRawDataModules(2)).toBe(359);
|
||||
});
|
||||
|
||||
it('returns correct value for version 7 (with version info)', () => {
|
||||
expect(getNumRawDataModules(7)).toBe(1568);
|
||||
});
|
||||
|
||||
it('returns 29648 for version 40', () => {
|
||||
expect(getNumRawDataModules(40)).toBe(29648);
|
||||
});
|
||||
|
||||
it('throws on version 0', () => {
|
||||
expect(() => getNumRawDataModules(0)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('throws on version 41', () => {
|
||||
expect(() => getNumRawDataModules(41)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNumDataCodewords', () => {
|
||||
it('returns 19 for version 1 LOW', () => {
|
||||
expect(getNumDataCodewords(1, LOW)).toBe(19);
|
||||
});
|
||||
|
||||
it('returns 9 for version 1 HIGH', () => {
|
||||
expect(getNumDataCodewords(1, HIGH)).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotalBits', () => {
|
||||
it('returns 0 for empty segments', () => {
|
||||
expect(getTotalBits([], 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns Infinity when numChars overflows char count field', () => {
|
||||
// MODE_BYTE at v1 has ccbits=8, so numChars=256 overflows
|
||||
const seg = new QrSegment(MODE_BYTE, 256, []);
|
||||
expect(getTotalBits([seg], 1)).toBe(Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
it('calculates total bits for a single segment', () => {
|
||||
// MODE_BYTE at v1: 4 (mode) + 8 (char count) + 8 (data) = 20
|
||||
const seg = new QrSegment(MODE_BYTE, 1, [0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
expect(getTotalBits([seg], 1)).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('numCharCountBits', () => {
|
||||
it('returns correct bits for MODE_NUMERIC across version ranges', () => {
|
||||
expect(numCharCountBits(MODE_NUMERIC, 1)).toBe(10);
|
||||
expect(numCharCountBits(MODE_NUMERIC, 9)).toBe(10);
|
||||
expect(numCharCountBits(MODE_NUMERIC, 10)).toBe(12);
|
||||
expect(numCharCountBits(MODE_NUMERIC, 26)).toBe(12);
|
||||
expect(numCharCountBits(MODE_NUMERIC, 27)).toBe(14);
|
||||
expect(numCharCountBits(MODE_NUMERIC, 40)).toBe(14);
|
||||
});
|
||||
|
||||
it('returns correct bits for MODE_BYTE across version ranges', () => {
|
||||
expect(numCharCountBits(MODE_BYTE, 1)).toBe(8);
|
||||
expect(numCharCountBits(MODE_BYTE, 9)).toBe(8);
|
||||
expect(numCharCountBits(MODE_BYTE, 10)).toBe(16);
|
||||
expect(numCharCountBits(MODE_BYTE, 40)).toBe(16);
|
||||
});
|
||||
});
|
||||
67
core/encoding/src/qr/constants.ts
Normal file
67
core/encoding/src/qr/constants.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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
|
||||
];
|
||||
94
core/encoding/src/qr/encode.ts
Normal file
94
core/encoding/src/qr/encode.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
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);
|
||||
}
|
||||
8
core/encoding/src/qr/index.ts
Normal file
8
core/encoding/src/qr/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
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';
|
||||
428
core/encoding/src/qr/qr-code.ts
Normal file
428
core/encoding/src/qr/qr-code.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
/*
|
||||
* 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, PENALTY_N1, PENALTY_N2, PENALTY_N3, PENALTY_N4 } from './constants';
|
||||
import { assert, getBit, getNumDataCodewords, getNumRawDataModules } from './utils';
|
||||
import { computeDivisor, computeRemainder } from '../reed-solomon';
|
||||
|
||||
/**
|
||||
* 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 & 1) ^ 1;
|
||||
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 {
|
||||
if (h0 === 0) runX += size;
|
||||
h6 = h5; h5 = h4; h4 = h3; h3 = h2; h2 = h1; h1 = runX; h0 = runX;
|
||||
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;
|
||||
}
|
||||
}
|
||||
{
|
||||
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 <= 2_568_888);
|
||||
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 = Array.from<number>({ length: numAlign });
|
||||
result[0] = 6;
|
||||
for (let i = numAlign - 1, pos = this.size - 7; i >= 1; i--, pos -= step)
|
||||
result[i] = pos;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
82
core/encoding/src/qr/segment.ts
Normal file
82
core/encoding/src/qr/segment.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
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);
|
||||
}
|
||||
17
core/encoding/src/qr/types.ts
Normal file
17
core/encoding/src/qr/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
}
|
||||
79
core/encoding/src/qr/utils.ts
Normal file
79
core/encoding/src/qr/utils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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;
|
||||
}
|
||||
115
core/encoding/src/reed-solomon/__test__/index.test.ts
Normal file
115
core/encoding/src/reed-solomon/__test__/index.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
it('produces correct ECC for QR Version 1-M reference data', () => {
|
||||
const data = [0x40, 0xD2, 0x75, 0x47, 0x76, 0x17, 0x32, 0x06, 0x27, 0x26, 0x96, 0xC6, 0xC6, 0x96, 0x70, 0xEC];
|
||||
const divisor = computeDivisor(10);
|
||||
const result = computeRemainder(data, divisor);
|
||||
expect(result).toEqual(Uint8Array.from([188, 42, 144, 19, 107, 175, 239, 253, 75, 224]));
|
||||
});
|
||||
|
||||
it('is deterministic', () => {
|
||||
const data = [0x10, 0x20, 0x30, 0x40, 0x50];
|
||||
const divisor = computeDivisor(7);
|
||||
const a = computeRemainder(data, divisor);
|
||||
const b = computeRemainder(data, divisor);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
92
core/encoding/src/reed-solomon/index.ts
Normal file
92
core/encoding/src/reed-solomon/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
3
core/encoding/tsconfig.json
Normal file
3
core/encoding/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json"
|
||||
}
|
||||
7
core/encoding/tsdown.config.ts
Normal file
7
core/encoding/tsdown.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
import { sharedConfig } from '@robonen/tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
...sharedConfig,
|
||||
entry: ['src/index.ts'],
|
||||
});
|
||||
7
core/encoding/vitest.config.ts
Normal file
7
core/encoding/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
6
core/fetch/jsr.json
Normal file
6
core/fetch/jsr.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://jsr.io/schema/config-file.v1.json",
|
||||
"name": "@robonen/fetch",
|
||||
"version": "0.0.1",
|
||||
"exports": "./src/index.ts"
|
||||
}
|
||||
4
core/fetch/oxlint.config.ts
Normal file
4
core/fetch/oxlint.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { base, compose, imports, stylistic, typescript } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(compose(base, typescript, imports, stylistic));
|
||||
53
core/fetch/package.json
Normal file
53
core/fetch/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@robonen/fetch",
|
||||
"version": "0.0.1",
|
||||
"license": "Apache-2.0",
|
||||
"description": "A lightweight, type-safe fetch wrapper with interceptors, retry, and V8-optimized internals",
|
||||
"keywords": [
|
||||
"fetch",
|
||||
"http",
|
||||
"request",
|
||||
"tools"
|
||||
],
|
||||
"author": "Robonen Andrew <robonenandrew@gmail.com>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "core/fetch"
|
||||
},
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"engines": {
|
||||
"node": ">=24.13.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./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:"
|
||||
}
|
||||
}
|
||||
71
core/fetch/src/error.test.ts
Normal file
71
core/fetch/src/error.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FetchError, createFetchError } from './error';
|
||||
import type { FetchContext } from './types';
|
||||
|
||||
function makeContext(overrides: Partial<FetchContext> = {}): FetchContext {
|
||||
return {
|
||||
request: 'https://example.com/api',
|
||||
options: { headers: new Headers() },
|
||||
response: undefined,
|
||||
error: undefined,
|
||||
...overrides,
|
||||
} as FetchContext;
|
||||
}
|
||||
|
||||
describe('FetchError', () => {
|
||||
it('is an instance of Error', () => {
|
||||
const err = new FetchError('oops');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(FetchError);
|
||||
});
|
||||
|
||||
it('has name "FetchError"', () => {
|
||||
expect(new FetchError('x').name).toBe('FetchError');
|
||||
});
|
||||
|
||||
it('preserves the message', () => {
|
||||
expect(new FetchError('something went wrong').message).toBe('something went wrong');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFetchError', () => {
|
||||
it('includes the request URL in the message', () => {
|
||||
const err = createFetchError(makeContext());
|
||||
expect(err.message).toContain('https://example.com/api');
|
||||
});
|
||||
|
||||
it('appends status information when a response is present', () => {
|
||||
const response = new Response('', { status: 404, statusText: 'Not Found' });
|
||||
const err = createFetchError(makeContext({ response: response as never }));
|
||||
expect(err.message).toContain('404');
|
||||
expect(err.message).toContain('Not Found');
|
||||
expect(err.status).toBe(404);
|
||||
expect(err.statusCode).toBe(404);
|
||||
expect(err.statusText).toBe('Not Found');
|
||||
expect(err.statusMessage).toBe('Not Found');
|
||||
});
|
||||
|
||||
it('appends the underlying error message when present', () => {
|
||||
const networkErr = new Error('Failed to fetch');
|
||||
const err = createFetchError(makeContext({ error: networkErr }));
|
||||
expect(err.message).toContain('Failed to fetch');
|
||||
});
|
||||
|
||||
it('populates response._data as data', () => {
|
||||
const response = Object.assign(new Response('', { status: 422 }), { _data: { code: 42 } });
|
||||
const err = createFetchError(makeContext({ response: response as never }));
|
||||
expect(err.data).toEqual({ code: 42 });
|
||||
});
|
||||
|
||||
it('works with a URL object as request', () => {
|
||||
const ctx = makeContext({ request: new URL('https://example.com/test') });
|
||||
const err = createFetchError(ctx);
|
||||
expect(err.message).toContain('https://example.com/test');
|
||||
});
|
||||
|
||||
it('works with a Request object as request', () => {
|
||||
const ctx = makeContext({ request: new Request('https://example.com/req') });
|
||||
const err = createFetchError(ctx);
|
||||
expect(err.message).toContain('https://example.com/req');
|
||||
});
|
||||
});
|
||||
70
core/fetch/src/error.ts
Normal file
70
core/fetch/src/error.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { FetchContext, FetchOptions, FetchRequest, FetchResponse, IFetchError } from './types';
|
||||
|
||||
/**
|
||||
* @name FetchError
|
||||
* @category Fetch
|
||||
* @description Error thrown by $fetch on network failures or non-2xx responses
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export class FetchError<T = unknown> extends Error implements IFetchError<T> {
|
||||
request?: FetchRequest;
|
||||
options?: FetchOptions;
|
||||
response?: FetchResponse<T>;
|
||||
data?: T;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
statusCode?: number;
|
||||
statusMessage?: string;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'FetchError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name createFetchError
|
||||
* @category Fetch
|
||||
* @description Builds a FetchError from a FetchContext, extracting URL, status, and error message
|
||||
*
|
||||
* @param {FetchContext} context - The context at the point of failure
|
||||
* @returns {FetchError} A populated FetchError instance
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function createFetchError<T = unknown>(context: FetchContext<T>): FetchError<T> {
|
||||
const url
|
||||
= typeof context.request === 'string'
|
||||
? context.request
|
||||
: context.request instanceof URL
|
||||
? context.request.href
|
||||
: (context.request as Request).url;
|
||||
|
||||
const statusPart = context.response
|
||||
? `${context.response.status} ${context.response.statusText}`
|
||||
: '';
|
||||
|
||||
const errorPart = context.error?.message ?? '';
|
||||
|
||||
// Build message from non-empty parts
|
||||
let message = url;
|
||||
if (statusPart) message += ` ${statusPart}`;
|
||||
if (errorPart) message += `: ${errorPart}`;
|
||||
|
||||
const error = new FetchError<T>(message);
|
||||
|
||||
error.request = context.request;
|
||||
error.options = context.options;
|
||||
|
||||
if (context.response !== undefined) {
|
||||
error.response = context.response;
|
||||
error.data = context.response._data;
|
||||
error.status = context.response.status;
|
||||
error.statusText = context.response.statusText;
|
||||
error.statusCode = context.response.status;
|
||||
error.statusMessage = context.response.statusText;
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
526
core/fetch/src/fetch.test.ts
Normal file
526
core/fetch/src/fetch.test.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FetchError } from './error';
|
||||
import { createFetch } from './fetch';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeFetchMock(
|
||||
body: unknown = { ok: true },
|
||||
init: ResponseInit = { status: 200 },
|
||||
contentType = 'application/json',
|
||||
): ReturnType<typeof vi.fn> {
|
||||
return vi.fn().mockResolvedValue(
|
||||
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
||||
...init,
|
||||
headers: { 'content-type': contentType, ...init.headers },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic fetch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createFetch — basic', () => {
|
||||
it('returns parsed JSON body', async () => {
|
||||
const fetchMock = makeFetchMock({ id: 1 });
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const data = await $fetch<{ id: number }>('https://api.example.com/user');
|
||||
|
||||
expect(data).toEqual({ id: 1 });
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('passes options through to the underlying fetch', async () => {
|
||||
const fetchMock = makeFetchMock({ done: true });
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await $fetch('https://api.example.com/task', {
|
||||
method: 'POST',
|
||||
headers: { 'x-token': 'abc' },
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect((init.headers as Headers).get('x-token')).toBe('abc');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('uppercases the HTTP method', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await $fetch('https://api.example.com', { method: 'post' });
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// raw
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('$fetch.raw', () => {
|
||||
it('returns a Response with _data', async () => {
|
||||
const fetchMock = makeFetchMock({ value: 42 });
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const response = await $fetch.raw<{ value: number }>('https://api.example.com');
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(response._data).toEqual({ value: 42 });
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Method shortcuts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('method shortcuts', () => {
|
||||
it('$fetch.get sends a GET request', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
await $fetch.get('https://api.example.com/items');
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('$fetch.post sends a POST request', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
await $fetch.post('https://api.example.com/items', { body: { name: 'x' } });
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('$fetch.put sends a PUT request', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
await $fetch.put('https://api.example.com/items/1', { body: { name: 'y' } });
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('PUT');
|
||||
});
|
||||
|
||||
it('$fetch.patch sends a PATCH request', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
await $fetch.patch('https://api.example.com/items/1', { body: { name: 'z' } });
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('PATCH');
|
||||
});
|
||||
|
||||
it('$fetch.delete sends a DELETE request', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
await $fetch.delete('https://api.example.com/items/1');
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.method).toBe('DELETE');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// baseURL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('baseURL', () => {
|
||||
it('prepends baseURL to a relative path', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await $fetch('/users', { baseURL: 'https://api.example.com/v1' });
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toBe('https://api.example.com/v1/users');
|
||||
});
|
||||
|
||||
it('inherits baseURL from create() defaults', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const api = createFetch({ fetch: fetchMock }).create({ baseURL: 'https://api.example.com' });
|
||||
|
||||
await api('/health');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toBe('https://api.example.com/health');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query params
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('query params', () => {
|
||||
it('appends query to the request URL', async () => {
|
||||
const fetchMock = makeFetchMock([]);
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await $fetch('https://api.example.com/items', { query: { page: 2, limit: 10 } });
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toContain('page=2');
|
||||
expect(url).toContain('limit=10');
|
||||
});
|
||||
|
||||
it('merges default query with per-request query', async () => {
|
||||
const fetchMock = makeFetchMock([]);
|
||||
const $fetch = createFetch({ fetch: fetchMock }).create({
|
||||
baseURL: 'https://api.example.com',
|
||||
query: { version: 2 },
|
||||
});
|
||||
|
||||
await $fetch('/items', { query: { page: 1 } });
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toContain('version=2');
|
||||
expect(url).toContain('page=1');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON body serialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('JSON body serialisation', () => {
|
||||
it('serialises plain objects and sets content-type to application/json', async () => {
|
||||
const fetchMock = makeFetchMock({ ok: true });
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await $fetch('https://api.example.com/users', {
|
||||
method: 'POST',
|
||||
body: { name: 'Alice' },
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe('{"name":"Alice"}');
|
||||
expect((init.headers as Headers).get('content-type')).toBe('application/json');
|
||||
});
|
||||
|
||||
it('respects a pre-set content-type header', async () => {
|
||||
const fetchMock = makeFetchMock({ ok: true });
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await $fetch('https://api.example.com/form', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: { key: 'value' },
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe('key=value');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws FetchError on 4xx response', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('{"error":"not found"}', {
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await expect($fetch('https://api.example.com/missing')).rejects.toBeInstanceOf(FetchError);
|
||||
});
|
||||
|
||||
it('throws FetchError on 5xx response', async () => {
|
||||
// Use mockImplementation so each retry attempt gets a fresh Response (body not yet read)
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(async () => new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await expect($fetch('https://api.example.com/crash')).rejects.toThrow(FetchError);
|
||||
});
|
||||
|
||||
it('does not throw when ignoreResponseError is true', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('{"error":"bad request"}', {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await expect(
|
||||
$fetch('https://api.example.com/bad', { ignoreResponseError: true }),
|
||||
).resolves.toEqual({ error: 'bad request' });
|
||||
});
|
||||
|
||||
it('throws FetchError on network error', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await expect($fetch('https://api.example.com/offline')).rejects.toBeInstanceOf(FetchError);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('retry', () => {
|
||||
it('retries once on 500 by default for GET', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response('error', { status: 500 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response('{"ok":true}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const data = await $fetch('https://api.example.com/flaky');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(data).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('does not retry POST by default', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 500 }));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await expect(
|
||||
$fetch('https://api.example.com/task', { method: 'POST' }),
|
||||
).rejects.toBeInstanceOf(FetchError);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('respects retry: false', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 503 }));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
await expect(
|
||||
$fetch('https://api.example.com/flaky', { retry: false }),
|
||||
).rejects.toBeInstanceOf(FetchError);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('respects custom retryStatusCodes', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response('', { status: 418 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response('{"ok":true}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const data = await $fetch('https://api.example.com/teapot', {
|
||||
retryStatusCodes: [418],
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(data).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('lifecycle hooks', () => {
|
||||
it('calls onRequest before sending', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const calls: string[] = [];
|
||||
|
||||
await $fetch('https://api.example.com', {
|
||||
onRequest: () => {
|
||||
calls.push('request');
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls).toContain('request');
|
||||
expect(calls.indexOf('request')).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('calls onResponse after a successful response', async () => {
|
||||
const fetchMock = makeFetchMock({ data: 1 });
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const calls: string[] = [];
|
||||
|
||||
await $fetch('https://api.example.com', {
|
||||
onResponse: () => {
|
||||
calls.push('response');
|
||||
},
|
||||
});
|
||||
|
||||
expect(calls).toContain('response');
|
||||
});
|
||||
|
||||
it('calls onResponseError for 4xx responses', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 401 }));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const calls: string[] = [];
|
||||
|
||||
await expect(
|
||||
$fetch('https://api.example.com/protected', {
|
||||
retry: false,
|
||||
onResponseError: () => {
|
||||
calls.push('responseError');
|
||||
},
|
||||
}),
|
||||
).rejects.toBeInstanceOf(FetchError);
|
||||
|
||||
expect(calls).toContain('responseError');
|
||||
});
|
||||
|
||||
it('calls onRequestError on network failure', async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError('Network error'));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const calls: string[] = [];
|
||||
|
||||
await expect(
|
||||
$fetch('https://api.example.com/offline', {
|
||||
retry: false,
|
||||
onRequestError: () => {
|
||||
calls.push('requestError');
|
||||
},
|
||||
}),
|
||||
).rejects.toBeInstanceOf(FetchError);
|
||||
|
||||
expect(calls).toContain('requestError');
|
||||
});
|
||||
|
||||
it('supports multiple hooks as an array', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const calls: number[] = [];
|
||||
|
||||
await $fetch('https://api.example.com', {
|
||||
onRequest: [
|
||||
() => {
|
||||
calls.push(1);
|
||||
},
|
||||
() => {
|
||||
calls.push(2);
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(calls).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// create / extend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('create and extend', () => {
|
||||
it('creates a new instance with merged defaults', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const api = $fetch.create({ baseURL: 'https://api.example.com' });
|
||||
|
||||
await api('/ping');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toBe('https://api.example.com/ping');
|
||||
});
|
||||
|
||||
it('extend is an alias for create', async () => {
|
||||
const fetchMock = makeFetchMock({});
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
const api = $fetch.extend({ baseURL: 'https://api.example.com' });
|
||||
|
||||
await api('/ping');
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] as [string];
|
||||
expect(url).toBe('https://api.example.com/ping');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response type variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('response types', () => {
|
||||
it('returns text when responseType is "text"', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response('hello world', { headers: { 'content-type': 'text/plain' } }));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const text = await $fetch<string, 'text'>('https://api.example.com/text', {
|
||||
responseType: 'text',
|
||||
});
|
||||
|
||||
expect(text).toBe('hello world');
|
||||
});
|
||||
|
||||
it('returns a Blob when responseType is "blob"', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response('binary', { headers: { 'content-type': 'image/png' } }));
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const blob = await $fetch<Blob, 'blob'>('https://api.example.com/img', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it('uses a custom parseResponse function', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response('{"value":10}', { headers: { 'content-type': 'application/json' } }),
|
||||
);
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const data = await $fetch<{ value: number }>('https://api.example.com/custom', {
|
||||
parseResponse: text => ({ ...JSON.parse(text) as object, custom: true }),
|
||||
});
|
||||
|
||||
expect(data).toEqual({ value: 10, custom: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('timeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('aborts a request that exceeds the timeout', async () => {
|
||||
// fetchMock that never resolves until the signal fires
|
||||
const fetchMock = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
(init.signal as AbortSignal).addEventListener('abort', () => {
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const $fetch = createFetch({ fetch: fetchMock });
|
||||
|
||||
const promise = $fetch('https://api.example.com/slow', { timeout: 100, retry: false });
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(FetchError);
|
||||
});
|
||||
});
|
||||
324
core/fetch/src/fetch.ts
Normal file
324
core/fetch/src/fetch.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import type { ResponseMap, $Fetch, CreateFetchOptions, FetchContext, FetchOptions, FetchRequest, FetchResponse, ResponseType } from './types';
|
||||
import { createFetchError } from './error';
|
||||
import {
|
||||
NULL_BODY_STATUSES,
|
||||
buildURL,
|
||||
callHooks,
|
||||
detectResponseType,
|
||||
isJSONSerializable,
|
||||
isPayloadMethod,
|
||||
joinURL,
|
||||
resolveFetchOptions,
|
||||
} from './utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V8: module-level Set — initialised once, never mutated, allows V8 to
|
||||
// embed the set reference as a constant in compiled code.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** HTTP status codes that trigger automatic retry by default */
|
||||
const DEFAULT_RETRY_STATUS_CODES: ReadonlySet<number> = /* @__PURE__ */ new Set([
|
||||
408, // Request Timeout
|
||||
409, // Conflict
|
||||
425, // Too Early (Experimental)
|
||||
429, // Too Many Requests
|
||||
500, // Internal Server Error
|
||||
502, // Bad Gateway
|
||||
503, // Service Unavailable
|
||||
504, // Gateway Timeout
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createFetch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @name createFetch
|
||||
* @category Fetch
|
||||
* @description Creates a configured $fetch instance
|
||||
*
|
||||
* V8 optimisation notes:
|
||||
* - All inner objects are created with a fixed property set so V8 can reuse
|
||||
* their hidden class across invocations (no dynamic property additions).
|
||||
* - `Error.captureStackTrace` is called only when available (V8 / Node.js)
|
||||
* to produce clean stack traces without internal frames.
|
||||
* - Retry and timeout paths avoid allocating closures on the hot path.
|
||||
* - `NULL_BODY_STATUSES` / `DEFAULT_RETRY_STATUS_CODES` are frozen module-
|
||||
* level Sets, so their `.has()` calls are always monomorphic.
|
||||
*
|
||||
* @param {CreateFetchOptions} [globalOptions={}] - Global defaults and custom fetch implementation
|
||||
* @returns {$Fetch} Configured fetch instance
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
|
||||
const fetchImpl = globalOptions.fetch ?? globalThis.fetch;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Error handler — shared between network errors and 4xx/5xx responses
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async function onError(context: FetchContext): Promise<FetchResponse<unknown>> {
|
||||
// Explicit user-triggered abort should not be retried automatically
|
||||
const isAbort
|
||||
= context.error !== undefined
|
||||
&& context.error.name === 'AbortError'
|
||||
&& context.options.timeout === undefined;
|
||||
|
||||
if (!isAbort && context.options.retry !== false) {
|
||||
// Default retry count: 0 for payload methods, 1 for idempotent methods
|
||||
const maxRetries
|
||||
= typeof context.options.retry === 'number'
|
||||
? context.options.retry
|
||||
: isPayloadMethod(context.options.method ?? 'GET')
|
||||
? 0
|
||||
: 1;
|
||||
|
||||
if (maxRetries > 0) {
|
||||
const responseStatus = context.response?.status ?? 500;
|
||||
const retryStatusCodes = context.options.retryStatusCodes;
|
||||
const shouldRetry
|
||||
= retryStatusCodes !== undefined
|
||||
? retryStatusCodes.includes(responseStatus)
|
||||
: DEFAULT_RETRY_STATUS_CODES.has(responseStatus);
|
||||
|
||||
if (shouldRetry) {
|
||||
const retryDelay
|
||||
= typeof context.options.retryDelay === 'function'
|
||||
? context.options.retryDelay(context)
|
||||
: (context.options.retryDelay ?? 0);
|
||||
|
||||
if (retryDelay > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, retryDelay);
|
||||
});
|
||||
}
|
||||
|
||||
return $fetchRaw(context.request, {
|
||||
...context.options,
|
||||
retry: maxRetries - 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const error = createFetchError(context);
|
||||
|
||||
// V8 / Node.js — clip internal frames from the error stack trace
|
||||
if (typeof Error.captureStackTrace === 'function') {
|
||||
Error.captureStackTrace(error, $fetchRaw);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// $fetchRaw — returns the full Response object with a parsed `_data` field
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const $fetchRaw: $Fetch['raw'] = async function $fetchRaw<
|
||||
T = unknown,
|
||||
R extends ResponseType = 'json',
|
||||
>(
|
||||
_request: FetchRequest,
|
||||
_options: FetchOptions<R, T> = {} as FetchOptions<R, T>,
|
||||
): Promise<FetchResponse<T>> {
|
||||
// V8: object literal with a fixed shape — V8 allocates a single hidden
|
||||
// class for all context objects created by this function.
|
||||
const context: FetchContext<T, R> = {
|
||||
request: _request,
|
||||
options: resolveFetchOptions(
|
||||
_request,
|
||||
_options,
|
||||
globalOptions.defaults as FetchOptions<R, T>,
|
||||
),
|
||||
response: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
// Normalise method to uppercase before any hook or header logic
|
||||
if (context.options.method !== undefined) {
|
||||
context.options.method = context.options.method.toUpperCase();
|
||||
}
|
||||
|
||||
if (context.options.onRequest !== undefined) {
|
||||
await callHooks(context, context.options.onRequest);
|
||||
}
|
||||
|
||||
// URL transformations — only when request is a plain string
|
||||
if (typeof context.request === 'string') {
|
||||
if (context.options.baseURL !== undefined) {
|
||||
context.request = joinURL(context.options.baseURL, context.request);
|
||||
}
|
||||
|
||||
const query = context.options.query ?? context.options.params;
|
||||
if (query !== undefined) {
|
||||
context.request = buildURL(context.request, query);
|
||||
}
|
||||
}
|
||||
|
||||
// Body serialisation
|
||||
const method = context.options.method ?? 'GET';
|
||||
if (context.options.body !== undefined && context.options.body !== null && isPayloadMethod(method)) {
|
||||
if (isJSONSerializable(context.options.body)) {
|
||||
const contentType = context.options.headers.get('content-type');
|
||||
|
||||
if (typeof context.options.body !== 'string') {
|
||||
context.options.body
|
||||
= contentType === 'application/x-www-form-urlencoded'
|
||||
? new URLSearchParams(
|
||||
context.options.body as Record<string, string>,
|
||||
).toString()
|
||||
: JSON.stringify(context.options.body);
|
||||
}
|
||||
|
||||
if (contentType === null) {
|
||||
context.options.headers.set('content-type', 'application/json');
|
||||
}
|
||||
if (!context.options.headers.has('accept')) {
|
||||
context.options.headers.set('accept', 'application/json');
|
||||
}
|
||||
}
|
||||
else if (
|
||||
// Web Streams API body
|
||||
typeof (context.options.body as ReadableStream | null)?.pipeTo === 'function'
|
||||
) {
|
||||
if (!('duplex' in context.options)) {
|
||||
context.options.duplex = 'half';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout via AbortSignal — compose with any caller-supplied signal
|
||||
if (context.options.timeout !== undefined) {
|
||||
const timeoutSignal = AbortSignal.timeout(context.options.timeout);
|
||||
context.options.signal
|
||||
= context.options.signal !== undefined
|
||||
? AbortSignal.any([timeoutSignal, context.options.signal as AbortSignal])
|
||||
: timeoutSignal;
|
||||
}
|
||||
|
||||
// Actual fetch call
|
||||
try {
|
||||
context.response = await fetchImpl(context.request, context.options as RequestInit);
|
||||
}
|
||||
catch (err) {
|
||||
context.error = err as Error;
|
||||
|
||||
if (context.options.onRequestError !== undefined) {
|
||||
await callHooks(
|
||||
context as FetchContext<T, R> & { error: Error },
|
||||
context.options.onRequestError,
|
||||
);
|
||||
}
|
||||
|
||||
return (await onError(context)) as FetchResponse<T>;
|
||||
}
|
||||
|
||||
// Response body parsing
|
||||
const hasBody
|
||||
= context.response.body !== null
|
||||
&& !NULL_BODY_STATUSES.has(context.response.status)
|
||||
&& method !== 'HEAD';
|
||||
|
||||
if (hasBody) {
|
||||
const responseType
|
||||
= context.options.parseResponse !== undefined
|
||||
? 'json'
|
||||
: (context.options.responseType
|
||||
?? detectResponseType(context.response.headers.get('content-type') ?? ''));
|
||||
|
||||
// V8: switch over a string constant — compiled to a jump table
|
||||
switch (responseType) {
|
||||
case 'json': {
|
||||
const text = await context.response.text();
|
||||
if (text) {
|
||||
context.response._data
|
||||
= context.options.parseResponse !== undefined
|
||||
? context.options.parseResponse(text)
|
||||
: (JSON.parse(text) as T);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'stream': {
|
||||
context.response._data = context.response.body as unknown as T;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
context.response._data = (await context.response[responseType]()) as T;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (context.options.onResponse !== undefined) {
|
||||
await callHooks(
|
||||
context as FetchContext<T, R> & { response: FetchResponse<T> },
|
||||
context.options.onResponse,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!context.options.ignoreResponseError
|
||||
&& context.response.status >= 400
|
||||
&& context.response.status < 600
|
||||
) {
|
||||
if (context.options.onResponseError !== undefined) {
|
||||
await callHooks(
|
||||
context as FetchContext<T, R> & { response: FetchResponse<T> },
|
||||
context.options.onResponseError,
|
||||
);
|
||||
}
|
||||
|
||||
return (await onError(context)) as FetchResponse<T>;
|
||||
}
|
||||
|
||||
return context.response;
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// $fetch — convenience wrapper that returns only the parsed data
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const $fetch = async function $fetch<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: FetchOptions<R, T>,
|
||||
): Promise<InferResponseType<R, T>> {
|
||||
const response = await $fetchRaw<T, R>(request, options);
|
||||
return response._data as InferResponseType<R, T>;
|
||||
} as $Fetch;
|
||||
|
||||
$fetch.raw = $fetchRaw;
|
||||
|
||||
$fetch.native = (...args: Parameters<typeof fetchImpl>) => fetchImpl(...args);
|
||||
|
||||
$fetch.create = (defaults: FetchOptions = {}, customGlobalOptions: CreateFetchOptions = {}) =>
|
||||
createFetch({
|
||||
...globalOptions,
|
||||
...customGlobalOptions,
|
||||
defaults: {
|
||||
...globalOptions.defaults,
|
||||
...customGlobalOptions.defaults,
|
||||
...defaults,
|
||||
},
|
||||
});
|
||||
|
||||
$fetch.extend = $fetch.create;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Method shortcuts
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
$fetch.get = (request, options) => $fetch(request, { ...options, method: 'GET' });
|
||||
$fetch.post = (request, options) => $fetch(request, { ...options, method: 'POST' });
|
||||
$fetch.put = (request, options) => $fetch(request, { ...options, method: 'PUT' });
|
||||
$fetch.patch = (request, options) => $fetch(request, { ...options, method: 'PATCH' });
|
||||
$fetch.delete = (request, options) => $fetch(request, { ...options, method: 'DELETE' });
|
||||
$fetch.head = (request, options) => $fetchRaw(request, { ...options, method: 'HEAD' });
|
||||
|
||||
return $fetch;
|
||||
}
|
||||
|
||||
/** Resolves the inferred return value type from a ResponseType key */
|
||||
type InferResponseType<R extends ResponseType, T> = R extends keyof ResponseMap
|
||||
? ResponseMap[R]
|
||||
: T;
|
||||
46
core/fetch/src/index.ts
Normal file
46
core/fetch/src/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export { createFetch } from './fetch';
|
||||
export { FetchError, createFetchError } from './error';
|
||||
export {
|
||||
isPayloadMethod,
|
||||
isJSONSerializable,
|
||||
detectResponseType,
|
||||
buildURL,
|
||||
joinURL,
|
||||
callHooks,
|
||||
resolveFetchOptions,
|
||||
} from './utils';
|
||||
export type {
|
||||
$Fetch,
|
||||
CreateFetchOptions,
|
||||
Fetch,
|
||||
FetchContext,
|
||||
FetchHook,
|
||||
FetchHooks,
|
||||
FetchOptions,
|
||||
FetchRequest,
|
||||
FetchResponse,
|
||||
IFetchError,
|
||||
MappedResponseType,
|
||||
MaybeArray,
|
||||
MaybePromise,
|
||||
ResponseMap,
|
||||
ResponseType,
|
||||
ResolvedFetchOptions,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* @name $fetch
|
||||
* @category Fetch
|
||||
* @description Default $fetch instance backed by globalThis.fetch
|
||||
*
|
||||
* @example
|
||||
* const data = await $fetch<User>('https://api.example.com/users/1');
|
||||
*
|
||||
* @example
|
||||
* const user = await $fetch.post<User>('https://api.example.com/users', {
|
||||
* body: { name: 'Alice' },
|
||||
* });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export const $fetch = createFetch();
|
||||
237
core/fetch/src/types.ts
Normal file
237
core/fetch/src/types.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
// --------------------------
|
||||
// Fetch API
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* @name $Fetch
|
||||
* @category Fetch
|
||||
* @description The main fetch interface with method shortcuts, raw access, and factory methods
|
||||
*/
|
||||
export interface $Fetch {
|
||||
<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: FetchOptions<R, T>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
raw<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: FetchOptions<R, T>,
|
||||
): Promise<FetchResponse<MappedResponseType<R, T>>>;
|
||||
/** Access to the underlying native fetch function */
|
||||
native: Fetch;
|
||||
/** Create a new fetch instance with merged defaults */
|
||||
create(defaults?: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch;
|
||||
/** Alias for create — extend this instance with new defaults */
|
||||
extend(defaults?: FetchOptions, globalOptions?: CreateFetchOptions): $Fetch;
|
||||
/** Shorthand for GET requests */
|
||||
get<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for POST requests */
|
||||
post<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for PUT requests */
|
||||
put<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for PATCH requests */
|
||||
patch<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for DELETE requests */
|
||||
delete<T = unknown, R extends ResponseType = 'json'>(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<R, T>, 'method'>,
|
||||
): Promise<MappedResponseType<R, T>>;
|
||||
/** Shorthand for HEAD requests */
|
||||
head(
|
||||
request: FetchRequest,
|
||||
options?: Omit<FetchOptions<'text', never>, 'method'>,
|
||||
): Promise<FetchResponse<never>>;
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Options
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* @name FetchOptions
|
||||
* @category Fetch
|
||||
* @description Options for a fetch request, extending native RequestInit with additional features
|
||||
*/
|
||||
export interface FetchOptions<R extends ResponseType = 'json', T = unknown>
|
||||
extends Omit<RequestInit, 'body'>,
|
||||
FetchHooks<T, R> {
|
||||
/** Base URL prepended to all relative request URLs */
|
||||
baseURL?: string;
|
||||
/** Request body — plain objects are automatically JSON-serialized */
|
||||
body?: BodyInit | Record<string, unknown> | unknown[] | null;
|
||||
/** Suppress throwing on 4xx/5xx responses */
|
||||
ignoreResponseError?: boolean;
|
||||
/** URL query parameters serialized and appended to the request URL */
|
||||
query?: Record<string, string | number | boolean | null | undefined>;
|
||||
/**
|
||||
* @deprecated use `query` instead
|
||||
*/
|
||||
params?: Record<string, string | number | boolean | null | undefined>;
|
||||
/** Custom response parser — overrides built-in JSON.parse */
|
||||
parseResponse?: (responseText: string) => T;
|
||||
/** Expected response format — drives body parsing */
|
||||
responseType?: R;
|
||||
/**
|
||||
* Enable duplex streaming.
|
||||
* Automatically set to "half" when a ReadableStream is used as body.
|
||||
* @see https://fetch.spec.whatwg.org/#enumdef-requestduplex
|
||||
*/
|
||||
duplex?: 'half';
|
||||
/** Request timeout in milliseconds. Uses AbortSignal.timeout internally. */
|
||||
timeout?: number;
|
||||
/** Number of retry attempts on failure, or false to disable. Defaults to 1 for non-payload methods. */
|
||||
retry?: number | false;
|
||||
/** Delay in milliseconds between retries, or a function receiving the context */
|
||||
retryDelay?: number | ((context: FetchContext<T, R>) => number);
|
||||
/**
|
||||
* HTTP status codes that trigger a retry.
|
||||
* Defaults to [408, 409, 425, 429, 500, 502, 503, 504].
|
||||
*/
|
||||
retryStatusCodes?: readonly number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @name ResolvedFetchOptions
|
||||
* @category Fetch
|
||||
* @description FetchOptions after merging defaults — headers are always a Headers instance
|
||||
*/
|
||||
export interface ResolvedFetchOptions<R extends ResponseType = 'json', T = unknown>
|
||||
extends FetchOptions<R, T> {
|
||||
headers: Headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name CreateFetchOptions
|
||||
* @category Fetch
|
||||
* @description Global options for createFetch
|
||||
*/
|
||||
export interface CreateFetchOptions {
|
||||
/** Default options merged into every request */
|
||||
defaults?: FetchOptions;
|
||||
/** Custom fetch implementation — defaults to globalThis.fetch */
|
||||
fetch?: Fetch;
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Hooks and Context
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* @name FetchContext
|
||||
* @category Fetch
|
||||
* @description Mutable context object passed to all hooks and the core fetch pipeline
|
||||
*/
|
||||
export interface FetchContext<T = unknown, R extends ResponseType = 'json'> {
|
||||
request: FetchRequest;
|
||||
options: ResolvedFetchOptions<R, T>;
|
||||
response?: FetchResponse<T>;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
export type MaybeArray<T> = T | readonly T[];
|
||||
|
||||
/**
|
||||
* @name FetchHook
|
||||
* @category Fetch
|
||||
* @description A function invoked at a specific point in the fetch lifecycle
|
||||
*/
|
||||
export type FetchHook<C extends FetchContext = FetchContext> = (context: C) => MaybePromise<void>;
|
||||
|
||||
/**
|
||||
* @name FetchHooks
|
||||
* @category Fetch
|
||||
* @description Lifecycle hooks for the fetch pipeline
|
||||
*/
|
||||
export interface FetchHooks<T = unknown, R extends ResponseType = 'json'> {
|
||||
/** Called before the request is sent */
|
||||
onRequest?: MaybeArray<FetchHook<FetchContext<T, R>>>;
|
||||
/** Called when the request itself throws (e.g. network error, timeout) */
|
||||
onRequestError?: MaybeArray<FetchHook<FetchContext<T, R> & { error: Error }>>;
|
||||
/** Called after a successful response is received and parsed */
|
||||
onResponse?: MaybeArray<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>>;
|
||||
/** Called when the response status is 4xx or 5xx */
|
||||
onResponseError?: MaybeArray<FetchHook<FetchContext<T, R> & { response: FetchResponse<T> }>>;
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Response Types
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* @name ResponseMap
|
||||
* @category Fetch
|
||||
* @description Maps response type keys to their parsed value types
|
||||
*/
|
||||
export interface ResponseMap {
|
||||
blob: Blob;
|
||||
text: string;
|
||||
arrayBuffer: ArrayBuffer;
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name ResponseType
|
||||
* @category Fetch
|
||||
* @description Supported response body parsing modes
|
||||
*/
|
||||
export type ResponseType = keyof ResponseMap | 'json';
|
||||
|
||||
/**
|
||||
* @name MappedResponseType
|
||||
* @category Fetch
|
||||
* @description Resolves the response value type from a ResponseType key
|
||||
*/
|
||||
export type MappedResponseType<R extends ResponseType, T = unknown> = R extends keyof ResponseMap
|
||||
? ResponseMap[R]
|
||||
: T;
|
||||
|
||||
/**
|
||||
* @name FetchResponse
|
||||
* @category Fetch
|
||||
* @description Extended Response with a parsed `_data` field
|
||||
*/
|
||||
export interface FetchResponse<T> extends Response {
|
||||
_data?: T;
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Error
|
||||
// --------------------------
|
||||
|
||||
/**
|
||||
* @name IFetchError
|
||||
* @category Fetch
|
||||
* @description Shape of errors thrown by $fetch
|
||||
*/
|
||||
export interface IFetchError<T = unknown> extends Error {
|
||||
request?: FetchRequest;
|
||||
options?: FetchOptions;
|
||||
response?: FetchResponse<T>;
|
||||
data?: T;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
statusCode?: number;
|
||||
statusMessage?: string;
|
||||
}
|
||||
|
||||
// --------------------------
|
||||
// Primitives
|
||||
// --------------------------
|
||||
|
||||
/** The native fetch function signature */
|
||||
export type Fetch = typeof globalThis.fetch;
|
||||
|
||||
/** A fetch request — URL string, URL object, or Request object */
|
||||
export type FetchRequest = RequestInfo;
|
||||
257
core/fetch/src/utils.test.ts
Normal file
257
core/fetch/src/utils.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildURL,
|
||||
callHooks,
|
||||
detectResponseType,
|
||||
isJSONSerializable,
|
||||
isPayloadMethod,
|
||||
joinURL,
|
||||
resolveFetchOptions,
|
||||
} from './utils';
|
||||
import type { FetchContext } from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isPayloadMethod
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isPayloadMethod', () => {
|
||||
it('returns true for payload methods', () => {
|
||||
expect(isPayloadMethod('POST')).toBe(true);
|
||||
expect(isPayloadMethod('PUT')).toBe(true);
|
||||
expect(isPayloadMethod('PATCH')).toBe(true);
|
||||
expect(isPayloadMethod('DELETE')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-payload methods', () => {
|
||||
expect(isPayloadMethod('GET')).toBe(false);
|
||||
expect(isPayloadMethod('HEAD')).toBe(false);
|
||||
expect(isPayloadMethod('OPTIONS')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isJSONSerializable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isJSONSerializable', () => {
|
||||
it('returns false for undefined', () => {
|
||||
expect(isJSONSerializable(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for primitives', () => {
|
||||
expect(isJSONSerializable('hello')).toBe(true);
|
||||
expect(isJSONSerializable(42)).toBe(true);
|
||||
expect(isJSONSerializable(true)).toBe(true);
|
||||
expect(isJSONSerializable(null)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for functions, symbols, bigints', () => {
|
||||
expect(isJSONSerializable(() => {})).toBe(false);
|
||||
expect(isJSONSerializable(Symbol('x'))).toBe(false);
|
||||
expect(isJSONSerializable(42n)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for plain arrays', () => {
|
||||
expect(isJSONSerializable([1, 2, 3])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for ArrayBuffer-like values', () => {
|
||||
expect(isJSONSerializable(new Uint8Array([1, 2]))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for FormData and URLSearchParams', () => {
|
||||
expect(isJSONSerializable(new FormData())).toBe(false);
|
||||
expect(isJSONSerializable(new URLSearchParams())).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for plain objects', () => {
|
||||
expect(isJSONSerializable({ a: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for objects with toJSON', () => {
|
||||
expect(isJSONSerializable({ toJSON: () => ({}) })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectResponseType
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectResponseType', () => {
|
||||
it('defaults to json when content-type is empty', () => {
|
||||
expect(detectResponseType('')).toBe('json');
|
||||
expect(detectResponseType()).toBe('json');
|
||||
});
|
||||
|
||||
it('detects json content types', () => {
|
||||
expect(detectResponseType('application/json')).toBe('json');
|
||||
expect(detectResponseType('application/json; charset=utf-8')).toBe('json');
|
||||
expect(detectResponseType('application/vnd.api+json')).toBe('json');
|
||||
});
|
||||
|
||||
it('detects event-stream as stream', () => {
|
||||
expect(detectResponseType('text/event-stream')).toBe('stream');
|
||||
});
|
||||
|
||||
it('detects text content types', () => {
|
||||
expect(detectResponseType('text/plain')).toBe('text');
|
||||
expect(detectResponseType('text/html')).toBe('text');
|
||||
expect(detectResponseType('application/xml')).toBe('text');
|
||||
});
|
||||
|
||||
it('falls back to blob for binary types', () => {
|
||||
expect(detectResponseType('image/png')).toBe('blob');
|
||||
expect(detectResponseType('application/octet-stream')).toBe('blob');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildURL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildURL', () => {
|
||||
it('appends query params to a clean URL', () => {
|
||||
expect(buildURL('https://api.example.com', { page: 1, limit: 20 })).toBe(
|
||||
'https://api.example.com?page=1&limit=20',
|
||||
);
|
||||
});
|
||||
|
||||
it('appends to an existing query string with &', () => {
|
||||
expect(buildURL('https://api.example.com?foo=bar', { baz: 'qux' })).toBe(
|
||||
'https://api.example.com?foo=bar&baz=qux',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits null and undefined values', () => {
|
||||
expect(buildURL('https://api.example.com', { a: null, b: undefined, c: 'keep' })).toBe(
|
||||
'https://api.example.com?c=keep',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the URL unchanged when all params are omitted', () => {
|
||||
expect(buildURL('https://api.example.com', { a: null })).toBe('https://api.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// joinURL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('joinURL', () => {
|
||||
it('joins base and path correctly', () => {
|
||||
expect(joinURL('https://api.example.com/v1', '/users')).toBe(
|
||||
'https://api.example.com/v1/users',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not double slashes', () => {
|
||||
expect(joinURL('https://api.example.com/v1/', '/users')).toBe(
|
||||
'https://api.example.com/v1/users',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds a slash when neither side has one', () => {
|
||||
expect(joinURL('https://api.example.com/v1', 'users')).toBe(
|
||||
'https://api.example.com/v1/users',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns base when path is empty', () => {
|
||||
expect(joinURL('https://api.example.com', '')).toBe('https://api.example.com');
|
||||
});
|
||||
|
||||
it('returns base when path is "/"', () => {
|
||||
expect(joinURL('https://api.example.com', '/')).toBe('https://api.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// callHooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('callHooks', () => {
|
||||
function makeCtx(): FetchContext {
|
||||
return {
|
||||
request: 'https://example.com',
|
||||
options: { headers: new Headers() },
|
||||
response: undefined,
|
||||
error: undefined,
|
||||
} as FetchContext;
|
||||
}
|
||||
|
||||
it('does nothing when hooks is undefined', async () => {
|
||||
await expect(callHooks(makeCtx(), undefined)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('calls a single hook', async () => {
|
||||
const calls: number[] = [];
|
||||
await callHooks(makeCtx(), () => {
|
||||
calls.push(1);
|
||||
});
|
||||
expect(calls).toEqual([1]);
|
||||
});
|
||||
|
||||
it('calls an array of hooks in order', async () => {
|
||||
const calls: number[] = [];
|
||||
await callHooks(makeCtx(), [
|
||||
() => { calls.push(1); },
|
||||
() => { calls.push(2); },
|
||||
() => { calls.push(3); },
|
||||
]);
|
||||
expect(calls).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('awaits async hooks', async () => {
|
||||
const calls: number[] = [];
|
||||
await callHooks(makeCtx(), [
|
||||
async () => {
|
||||
await Promise.resolve();
|
||||
calls.push(1);
|
||||
},
|
||||
() => {
|
||||
calls.push(2);
|
||||
},
|
||||
]);
|
||||
expect(calls).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveFetchOptions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveFetchOptions', () => {
|
||||
it('returns an object with a Headers instance', () => {
|
||||
const resolved = resolveFetchOptions('https://example.com', undefined, undefined);
|
||||
expect(resolved.headers).toBeInstanceOf(Headers);
|
||||
});
|
||||
|
||||
it('merges input and default headers (input wins)', () => {
|
||||
const resolved = resolveFetchOptions(
|
||||
'https://example.com',
|
||||
{ headers: { 'x-custom': 'input' } },
|
||||
{ headers: { 'x-custom': 'default', 'x-default-only': 'yes' } },
|
||||
);
|
||||
expect(resolved.headers.get('x-custom')).toBe('input');
|
||||
expect(resolved.headers.get('x-default-only')).toBe('yes');
|
||||
});
|
||||
|
||||
it('merges query params from defaults and input', () => {
|
||||
const resolved = resolveFetchOptions(
|
||||
'https://example.com',
|
||||
{ query: { a: '1' } },
|
||||
{ query: { b: '2' } },
|
||||
);
|
||||
expect(resolved.query).toEqual({ a: '1', b: '2' });
|
||||
});
|
||||
|
||||
it('merges params alias into query', () => {
|
||||
const resolved = resolveFetchOptions(
|
||||
'https://example.com',
|
||||
{ params: { p: '10' } },
|
||||
undefined,
|
||||
);
|
||||
expect(resolved.query).toEqual({ p: '10' });
|
||||
expect(resolved.params).toEqual({ p: '10' });
|
||||
});
|
||||
});
|
||||
282
core/fetch/src/utils.ts
Normal file
282
core/fetch/src/utils.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import type {
|
||||
FetchContext,
|
||||
FetchHook,
|
||||
FetchOptions,
|
||||
FetchRequest,
|
||||
ResolvedFetchOptions,
|
||||
ResponseType,
|
||||
} from './types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V8 optimisation: module-level frozen Sets avoid per-call allocations and
|
||||
// allow V8 to treat them as compile-time constants in hidden-class analysis.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** HTTP methods whose requests carry a body */
|
||||
const PAYLOAD_METHODS: ReadonlySet<string> = /* @__PURE__ */ new Set(['PATCH', 'POST', 'PUT', 'DELETE']);
|
||||
|
||||
/** HTTP status codes whose responses never have a body */
|
||||
export const NULL_BODY_STATUSES: ReadonlySet<number> = /* @__PURE__ */ new Set([101, 204, 205, 304]);
|
||||
|
||||
/** Content-types treated as plain text */
|
||||
const TEXT_CONTENT_TYPES: ReadonlySet<string> = /* @__PURE__ */ new Set([
|
||||
'image/svg',
|
||||
'application/xml',
|
||||
'application/xhtml',
|
||||
'application/html',
|
||||
]);
|
||||
|
||||
/** V8: pre-compiled at module load — avoids per-call RegExp construction */
|
||||
const JSON_CONTENT_TYPE_RE = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Predicate helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @name isPayloadMethod
|
||||
* @category Fetch
|
||||
* @description Returns true for HTTP methods that carry a request body
|
||||
*
|
||||
* V8: function is monomorphic — always called with an uppercase string.
|
||||
*
|
||||
* @param {string} method - Uppercase HTTP method string
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function isPayloadMethod(method: string): boolean {
|
||||
return PAYLOAD_METHODS.has(method);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name isJSONSerializable
|
||||
* @category Fetch
|
||||
* @description Returns true when a value can be serialised with JSON.stringify
|
||||
*
|
||||
* V8: typeof checks are ordered from most-common to least-common to maximise
|
||||
* the probability of an early return and keep the IC monomorphic.
|
||||
*
|
||||
* @param {unknown} value - Any value
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function isJSONSerializable(value: unknown): boolean {
|
||||
if (value === undefined) return false;
|
||||
|
||||
const type = typeof value;
|
||||
|
||||
// Fast path — primitives are always serialisable
|
||||
if (type === 'string' || type === 'number' || type === 'boolean' || value === null) return true;
|
||||
|
||||
// Non-object types (bigint, function, symbol) are not serialisable
|
||||
if (type !== 'object') return false;
|
||||
|
||||
// Arrays are serialisable
|
||||
if (Array.isArray(value)) return true;
|
||||
|
||||
// TypedArrays / ArrayBuffers carry a .buffer property — not JSON-serialisable
|
||||
if ((value as Record<string, unknown>).buffer !== undefined) return false;
|
||||
|
||||
// FormData and URLSearchParams should not be auto-serialised
|
||||
if (value instanceof FormData || value instanceof URLSearchParams) return false;
|
||||
|
||||
// Plain objects or objects with a custom toJSON
|
||||
const ctor = (value as object).constructor;
|
||||
return (
|
||||
ctor === undefined
|
||||
|| ctor === Object
|
||||
|| typeof (value as Record<string, unknown>).toJSON === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response type detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @name detectResponseType
|
||||
* @category Fetch
|
||||
* @description Infers the response body parsing strategy from a Content-Type header value
|
||||
*
|
||||
* @param {string} [contentType] - Value of the Content-Type response header
|
||||
* @returns {ResponseType}
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function detectResponseType(contentType = ''): ResponseType {
|
||||
if (!contentType) return 'json';
|
||||
|
||||
// V8: split once and reuse — avoids calling split multiple times
|
||||
const type = contentType.split(';')[0] ?? '';
|
||||
|
||||
if (JSON_CONTENT_TYPE_RE.test(type)) return 'json';
|
||||
if (type === 'text/event-stream') return 'stream';
|
||||
if (TEXT_CONTENT_TYPES.has(type) || type.startsWith('text/')) return 'text';
|
||||
|
||||
return 'blob';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @name buildURL
|
||||
* @category Fetch
|
||||
* @description Appends serialised query parameters to a URL string
|
||||
*
|
||||
* Null and undefined values are omitted. Existing query strings are preserved.
|
||||
*
|
||||
* @param {string} url - Base URL (may already contain a query string)
|
||||
* @param {Record<string, string | number | boolean | null | undefined>} query - Parameters to append
|
||||
* @returns {string} URL with query string
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function buildURL(
|
||||
url: string,
|
||||
query: Record<string, string | number | boolean | null | undefined>,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const key of Object.keys(query)) {
|
||||
const value = query[key];
|
||||
if (value !== null && value !== undefined) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const qs = params.toString();
|
||||
if (!qs) return url;
|
||||
|
||||
return url.includes('?') ? `${url}&${qs}` : `${url}?${qs}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name joinURL
|
||||
* @category Fetch
|
||||
* @description Joins a base URL with a relative path, normalising the slash boundary
|
||||
*
|
||||
* @param {string} base - Base URL (e.g. "https://api.example.com/v1")
|
||||
* @param {string} path - Relative path (e.g. "/users")
|
||||
* @returns {string} Joined URL
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function joinURL(base: string, path: string): string {
|
||||
if (!path || path === '/') return base;
|
||||
|
||||
const baseEnds = base.endsWith('/');
|
||||
const pathStarts = path.startsWith('/');
|
||||
|
||||
if (baseEnds && pathStarts) return `${base}${path.slice(1)}`;
|
||||
if (!baseEnds && !pathStarts) return `${base}/${path}`;
|
||||
return `${base}${path}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Options resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @name resolveFetchOptions
|
||||
* @category Fetch
|
||||
* @description Merges per-request options with global defaults
|
||||
*
|
||||
* V8: the returned object always has the same property set (fixed shape),
|
||||
* which lets V8 reuse its hidden class across all calls.
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function resolveFetchOptions<R extends ResponseType = 'json', T = unknown>(
|
||||
request: FetchRequest,
|
||||
input: FetchOptions<R, T> | undefined,
|
||||
defaults: FetchOptions<R, T> | undefined,
|
||||
): ResolvedFetchOptions<R, T> {
|
||||
const headers = mergeHeaders(
|
||||
input?.headers ?? (request as Request)?.headers,
|
||||
defaults?.headers,
|
||||
);
|
||||
|
||||
let query: Record<string, string | number | boolean | null | undefined> | undefined;
|
||||
if (
|
||||
defaults?.query !== undefined
|
||||
|| defaults?.params !== undefined
|
||||
|| input?.params !== undefined
|
||||
|| input?.query !== undefined
|
||||
) {
|
||||
query = {
|
||||
...defaults?.params,
|
||||
...defaults?.query,
|
||||
...input?.params,
|
||||
...input?.query,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...input,
|
||||
query,
|
||||
params: query,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two HeadersInit sources into a single Headers instance.
|
||||
* Input headers override default headers.
|
||||
*
|
||||
* V8: avoids constructing an intermediate Headers when defaults are absent.
|
||||
*/
|
||||
function mergeHeaders(
|
||||
input: HeadersInit | undefined,
|
||||
defaults: HeadersInit | undefined,
|
||||
): Headers {
|
||||
if (defaults === undefined) {
|
||||
return new Headers(input);
|
||||
}
|
||||
|
||||
const merged = new Headers(defaults);
|
||||
|
||||
if (input !== undefined) {
|
||||
const src = input instanceof Headers ? input : new Headers(input);
|
||||
for (const [key, value] of src) {
|
||||
merged.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @name callHooks
|
||||
* @category Fetch
|
||||
* @description Invokes one or more lifecycle hooks with the given context
|
||||
*
|
||||
* V8: the single-hook path avoids Array creation; the Array path uses a
|
||||
* for-loop with a cached length to stay monomorphic inside the loop body.
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export async function callHooks<C extends FetchContext = FetchContext>(
|
||||
context: C,
|
||||
hooks: FetchHook<C> | readonly FetchHook<C>[] | undefined,
|
||||
): Promise<void> {
|
||||
if (hooks === undefined) return;
|
||||
|
||||
if (Array.isArray(hooks)) {
|
||||
const len = hooks.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
await (hooks as Array<FetchHook<C>>)[i]!(context);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await (hooks as FetchHook<C>)(context);
|
||||
}
|
||||
}
|
||||
3
core/fetch/tsconfig.json
Normal file
3
core/fetch/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.json"
|
||||
}
|
||||
7
core/fetch/tsdown.config.ts
Normal file
7
core/fetch/tsdown.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
import { sharedConfig } from '@robonen/tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
...sharedConfig,
|
||||
entry: ['src/index.ts'],
|
||||
});
|
||||
7
core/fetch/vitest.config.ts
Normal file
7
core/fetch/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -1 +1,23 @@
|
||||
# @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';
|
||||
```
|
||||
@@ -1,16 +0,0 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
entries: [
|
||||
'src/browsers',
|
||||
'src/multi',
|
||||
],
|
||||
clean: true,
|
||||
declaration: true,
|
||||
rollup: {
|
||||
emitCJS: true,
|
||||
esbuild: {
|
||||
// minify: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
15
core/platform/oxlint.config.ts
Normal file
15
core/platform/oxlint.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@robonen/platform",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.4",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Platform dependent utilities for javascript development",
|
||||
"keywords": [
|
||||
@@ -18,9 +18,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/platform"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"engines": {
|
||||
"node": ">=24.13.0"
|
||||
"node": ">=24.13.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -28,23 +28,39 @@
|
||||
],
|
||||
"exports": {
|
||||
"./browsers": {
|
||||
"types": "./dist/browsers.d.ts",
|
||||
"import": "./dist/browsers.mjs",
|
||||
"require": "./dist/browsers.cjs"
|
||||
"import": {
|
||||
"types": "./dist/browsers.d.mts",
|
||||
"default": "./dist/browsers.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/browsers.d.cts",
|
||||
"default": "./dist/browsers.cjs"
|
||||
}
|
||||
},
|
||||
"./multi": {
|
||||
"types": "./dist/multi.d.ts",
|
||||
"import": "./dist/multi.mjs",
|
||||
"require": "./dist/multi.cjs"
|
||||
"import": {
|
||||
"types": "./dist/multi.d.mts",
|
||||
"default": "./dist/multi.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/multi.d.cts",
|
||||
"default": "./dist/multi.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint:check": "oxlint -c oxlint.config.ts",
|
||||
"lint:fix": "oxlint -c oxlint.config.ts --fix",
|
||||
"test": "vitest run",
|
||||
"dev": "vitest dev",
|
||||
"build": "unbuild"
|
||||
"build": "tsdown"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/oxlint": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"unbuild": "catalog:"
|
||||
"@robonen/tsdown": "workspace:*",
|
||||
"@stylistic/eslint-plugin": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
139
core/platform/src/browsers/animationLifecycle/index.test.ts
Normal file
139
core/platform/src/browsers/animationLifecycle/index.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
getAnimationName,
|
||||
isAnimatable,
|
||||
shouldSuspendUnmount,
|
||||
dispatchAnimationEvent,
|
||||
onAnimationSettle,
|
||||
} from '.';
|
||||
|
||||
describe('getAnimationName', () => {
|
||||
it('returns "none" for undefined element', () => {
|
||||
expect(getAnimationName(undefined)).toBe('none');
|
||||
});
|
||||
|
||||
it('returns the animation name from inline style', () => {
|
||||
const el = document.createElement('div');
|
||||
el.style.animationName = 'fadeIn';
|
||||
document.body.appendChild(el);
|
||||
|
||||
expect(getAnimationName(el)).toBe('fadeIn');
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAnimatable', () => {
|
||||
it('returns false for undefined element', () => {
|
||||
expect(isAnimatable(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for element with no animation or transition', () => {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
|
||||
expect(isAnimatable(el)).toBe(false);
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSuspendUnmount', () => {
|
||||
it('returns false for undefined element', () => {
|
||||
expect(shouldSuspendUnmount(undefined, 'none')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for element with no animation/transition', () => {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
|
||||
expect(shouldSuspendUnmount(el, 'none')).toBe(false);
|
||||
|
||||
document.body.removeChild(el);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatchAnimationEvent', () => {
|
||||
it('dispatches a custom event on the element', () => {
|
||||
const el = document.createElement('div');
|
||||
const handler = vi.fn();
|
||||
|
||||
el.addEventListener('enter', handler);
|
||||
dispatchAnimationEvent(el, 'enter');
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not throw for undefined element', () => {
|
||||
expect(() => dispatchAnimationEvent(undefined, 'leave')).not.toThrow();
|
||||
});
|
||||
|
||||
it('dispatches non-bubbling event', () => {
|
||||
const el = document.createElement('div');
|
||||
const parent = document.createElement('div');
|
||||
const handler = vi.fn();
|
||||
|
||||
parent.appendChild(el);
|
||||
parent.addEventListener('enter', handler);
|
||||
dispatchAnimationEvent(el, 'enter');
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onAnimationSettle', () => {
|
||||
it('returns a cleanup function', () => {
|
||||
const el = document.createElement('div');
|
||||
const cleanup = onAnimationSettle(el, { onSettle: vi.fn() });
|
||||
|
||||
expect(typeof cleanup).toBe('function');
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('calls onSettle callback on transitionend', () => {
|
||||
const el = document.createElement('div');
|
||||
const callback = vi.fn();
|
||||
|
||||
onAnimationSettle(el, { onSettle: callback });
|
||||
el.dispatchEvent(new Event('transitionend'));
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onSettle callback on transitioncancel', () => {
|
||||
const el = document.createElement('div');
|
||||
const callback = vi.fn();
|
||||
|
||||
onAnimationSettle(el, { onSettle: callback });
|
||||
el.dispatchEvent(new Event('transitioncancel'));
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onStart callback on animationstart', () => {
|
||||
const el = document.createElement('div');
|
||||
const startCallback = vi.fn();
|
||||
|
||||
onAnimationSettle(el, {
|
||||
onSettle: vi.fn(),
|
||||
onStart: startCallback,
|
||||
});
|
||||
|
||||
el.dispatchEvent(new Event('animationstart'));
|
||||
|
||||
expect(startCallback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('removes all listeners on cleanup', () => {
|
||||
const el = document.createElement('div');
|
||||
const callback = vi.fn();
|
||||
|
||||
const cleanup = onAnimationSettle(el, { onSettle: callback });
|
||||
cleanup();
|
||||
|
||||
el.dispatchEvent(new Event('transitionend'));
|
||||
el.dispatchEvent(new Event('transitioncancel'));
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
139
core/platform/src/browsers/animationLifecycle/index.ts
Normal file
139
core/platform/src/browsers/animationLifecycle/index.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
export type AnimationLifecycleEvent = 'enter' | 'after-enter' | 'leave' | 'after-leave';
|
||||
|
||||
export interface AnimationSettleCallbacks {
|
||||
onSettle: () => void;
|
||||
onStart?: (animationName: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getAnimationName
|
||||
* @category Browsers
|
||||
* @description Returns the current CSS animation name(s) of an element
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function getAnimationName(el: HTMLElement | undefined): string {
|
||||
return el ? getComputedStyle(el).animationName || 'none' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* @name isAnimatable
|
||||
* @category Browsers
|
||||
* @description Checks whether an element has a running CSS animation or transition
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function isAnimatable(el: HTMLElement | undefined): boolean {
|
||||
if (!el) return false;
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
const animationName = style.animationName || 'none';
|
||||
const transitionProperty = style.transitionProperty || 'none';
|
||||
|
||||
const hasAnimation = animationName !== 'none' && animationName !== '';
|
||||
const hasTransition = transitionProperty !== 'none' && transitionProperty !== '' && transitionProperty !== 'all';
|
||||
|
||||
return hasAnimation || hasTransition;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name shouldSuspendUnmount
|
||||
* @category Browsers
|
||||
* @description Determines whether unmounting should be delayed due to a running animation/transition change
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function shouldSuspendUnmount(el: HTMLElement | undefined, prevAnimationName: string): boolean {
|
||||
if (!el) return false;
|
||||
|
||||
const style = getComputedStyle(el);
|
||||
|
||||
if (style.display === 'none') return false;
|
||||
|
||||
const animationName = style.animationName || 'none';
|
||||
const transitionProperty = style.transitionProperty || 'none';
|
||||
|
||||
const hasAnimation = animationName !== 'none' && animationName !== '';
|
||||
const hasTransition = transitionProperty !== 'none' && transitionProperty !== '' && transitionProperty !== 'all';
|
||||
|
||||
if (!hasAnimation && !hasTransition) return false;
|
||||
|
||||
return prevAnimationName !== animationName || hasTransition;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name dispatchAnimationEvent
|
||||
* @category Browsers
|
||||
* @description Dispatches a non-bubbling custom event on an element for animation lifecycle tracking
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function dispatchAnimationEvent(el: HTMLElement | undefined, name: AnimationLifecycleEvent): void {
|
||||
el?.dispatchEvent(new CustomEvent(name, { bubbles: false, cancelable: false }));
|
||||
}
|
||||
|
||||
/**
|
||||
* @name onAnimationSettle
|
||||
* @category Browsers
|
||||
* @description Attaches animation/transition end listeners to an element with fill-mode flash prevention. Returns a cleanup function.
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function onAnimationSettle(el: HTMLElement, callbacks: AnimationSettleCallbacks): () => void {
|
||||
let fillModeTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const handleAnimationEnd = (event: AnimationEvent) => {
|
||||
const currentAnimationName = getAnimationName(el);
|
||||
const isCurrentAnimation = currentAnimationName.includes(CSS.escape(event.animationName));
|
||||
|
||||
if (event.target === el && isCurrentAnimation) {
|
||||
callbacks.onSettle();
|
||||
|
||||
if (fillModeTimeoutId !== undefined) {
|
||||
clearTimeout(fillModeTimeoutId);
|
||||
}
|
||||
|
||||
const currentFillMode = el.style.animationFillMode;
|
||||
el.style.animationFillMode = 'forwards';
|
||||
|
||||
fillModeTimeoutId = setTimeout(() => {
|
||||
if (el.style.animationFillMode === 'forwards') {
|
||||
el.style.animationFillMode = currentFillMode;
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (event.target === el && currentAnimationName === 'none') {
|
||||
callbacks.onSettle();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnimationStart = (event: AnimationEvent) => {
|
||||
if (event.target === el) {
|
||||
callbacks.onStart?.(getAnimationName(el));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTransitionEnd = (event: TransitionEvent) => {
|
||||
if (event.target === el) {
|
||||
callbacks.onSettle();
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener('animationstart', handleAnimationStart, { passive: true });
|
||||
el.addEventListener('animationcancel', handleAnimationEnd, { passive: true });
|
||||
el.addEventListener('animationend', handleAnimationEnd, { passive: true });
|
||||
el.addEventListener('transitioncancel', handleTransitionEnd, { passive: true });
|
||||
el.addEventListener('transitionend', handleTransitionEnd, { passive: true });
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('animationstart', handleAnimationStart);
|
||||
el.removeEventListener('animationcancel', handleAnimationEnd);
|
||||
el.removeEventListener('animationend', handleAnimationEnd);
|
||||
el.removeEventListener('transitioncancel', handleTransitionEnd);
|
||||
el.removeEventListener('transitionend', handleTransitionEnd);
|
||||
|
||||
if (fillModeTimeoutId !== undefined) {
|
||||
clearTimeout(fillModeTimeoutId);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
*
|
||||
* @since 0.0.3
|
||||
*/
|
||||
export function focusGuard(namespace: string = 'focus-guard') {
|
||||
export function focusGuard(namespace = 'focus-guard') {
|
||||
const guardAttr = `data-${namespace}`;
|
||||
|
||||
const createGuard = () => {
|
||||
@@ -29,7 +29,7 @@ export function focusGuard(namespace: string = 'focus-guard') {
|
||||
};
|
||||
|
||||
const removeGuard = () => {
|
||||
document.querySelectorAll(`[${guardAttr}]`).forEach((element) => element.remove());
|
||||
document.querySelectorAll(`[${guardAttr}]`).forEach(element => element.remove());
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -39,7 +39,7 @@ export function focusGuard(namespace: string = 'focus-guard') {
|
||||
};
|
||||
}
|
||||
|
||||
export function createGuardAttrs(namespace: string) {
|
||||
export function createGuardAttrs(namespace = 'focus-guard') {
|
||||
const element = document.createElement('span');
|
||||
|
||||
element.setAttribute(namespace, '');
|
||||
|
||||
262
core/platform/src/browsers/focusScope/index.test.ts
Normal file
262
core/platform/src/browsers/focusScope/index.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { afterEach, describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getActiveElement,
|
||||
getTabbableCandidates,
|
||||
getTabbableEdges,
|
||||
focusFirst,
|
||||
focus,
|
||||
isHidden,
|
||||
isSelectableInput,
|
||||
AUTOFOCUS_ON_MOUNT,
|
||||
AUTOFOCUS_ON_UNMOUNT,
|
||||
EVENT_OPTIONS,
|
||||
} from '.';
|
||||
|
||||
function createContainer(html: string): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
|
||||
container.innerHTML = html;
|
||||
document.body.appendChild(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('constants', () => {
|
||||
it('exports correct event names', () => {
|
||||
expect(AUTOFOCUS_ON_MOUNT).toBe('focusScope.autoFocusOnMount');
|
||||
expect(AUTOFOCUS_ON_UNMOUNT).toBe('focusScope.autoFocusOnUnmount');
|
||||
});
|
||||
|
||||
it('exports correct event options', () => {
|
||||
expect(EVENT_OPTIONS).toEqual({ bubbles: false, cancelable: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveElement', () => {
|
||||
it('returns document.body when nothing is focused', () => {
|
||||
const active = getActiveElement();
|
||||
expect(active).toBe(document.body);
|
||||
});
|
||||
|
||||
it('returns the focused element', () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
expect(getActiveElement()).toBe(input);
|
||||
|
||||
input.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTabbableCandidates', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('returns focusable elements with tabindex >= 0', () => {
|
||||
const container = createContainer(`
|
||||
<input type="text" />
|
||||
<button>Click</button>
|
||||
<a href="#">Link</a>
|
||||
<div tabindex="0">Div</div>
|
||||
`);
|
||||
|
||||
const candidates = getTabbableCandidates(container);
|
||||
expect(candidates.length).toBe(4);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('skips disabled elements', () => {
|
||||
const container = createContainer(`
|
||||
<button disabled>Disabled</button>
|
||||
<input type="text" />
|
||||
`);
|
||||
|
||||
const candidates = getTabbableCandidates(container);
|
||||
expect(candidates.length).toBe(1);
|
||||
expect(candidates[0]!.tagName).toBe('INPUT');
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('skips hidden inputs', () => {
|
||||
const container = createContainer(`
|
||||
<input type="hidden" />
|
||||
<input type="text" />
|
||||
`);
|
||||
|
||||
const candidates = getTabbableCandidates(container);
|
||||
expect(candidates.length).toBe(1);
|
||||
expect((candidates[0] as HTMLInputElement).type).toBe('text');
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('skips elements with hidden attribute', () => {
|
||||
const container = createContainer(`
|
||||
<input type="text" hidden />
|
||||
<input type="text" />
|
||||
`);
|
||||
|
||||
const candidates = getTabbableCandidates(container);
|
||||
expect(candidates.length).toBe(1);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('returns empty array for container with no focusable elements', () => {
|
||||
const container = createContainer(`
|
||||
<div>Just text</div>
|
||||
<span>More text</span>
|
||||
`);
|
||||
|
||||
const candidates = getTabbableCandidates(container);
|
||||
expect(candidates.length).toBe(0);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTabbableEdges', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('returns first and last tabbable elements', () => {
|
||||
const container = createContainer(`
|
||||
<input type="text" data-testid="first" />
|
||||
<button>Middle</button>
|
||||
<input type="text" data-testid="last" />
|
||||
`);
|
||||
|
||||
const { first, last } = getTabbableEdges(container);
|
||||
expect(first?.getAttribute('data-testid')).toBe('first');
|
||||
expect(last?.getAttribute('data-testid')).toBe('last');
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('returns undefined for both when no tabbable elements', () => {
|
||||
const container = createContainer(`<div>no focusable</div>`);
|
||||
|
||||
const { first, last } = getTabbableEdges(container);
|
||||
expect(first).toBeUndefined();
|
||||
expect(last).toBeUndefined();
|
||||
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('focusFirst', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('focuses the first element in the list', () => {
|
||||
const container = createContainer(`
|
||||
<input type="text" data-testid="a" />
|
||||
<input type="text" data-testid="b" />
|
||||
`);
|
||||
|
||||
const candidates = Array.from(container.querySelectorAll('input')) as HTMLElement[];
|
||||
focusFirst(candidates);
|
||||
|
||||
expect(document.activeElement).toBe(candidates[0]);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('returns true when focus changed', () => {
|
||||
const container = createContainer(`<input type="text" />`);
|
||||
const candidates = Array.from(container.querySelectorAll('input')) as HTMLElement[];
|
||||
|
||||
const result = focusFirst(candidates);
|
||||
expect(result).toBe(true);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('returns false when no candidate receives focus', () => {
|
||||
const result = focusFirst([]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus', () => {
|
||||
it('does nothing when element is null', () => {
|
||||
expect(() => focus(null)).not.toThrow();
|
||||
});
|
||||
|
||||
it('focuses the given element', () => {
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
focus(input);
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('calls select on input when select=true', () => {
|
||||
const input = document.createElement('input');
|
||||
input.value = 'hello';
|
||||
document.body.appendChild(input);
|
||||
|
||||
focus(input, { select: true });
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
input.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSelectableInput', () => {
|
||||
it('returns true for input elements', () => {
|
||||
const input = document.createElement('input');
|
||||
expect(isSelectableInput(input)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-input elements', () => {
|
||||
const div = document.createElement('div');
|
||||
expect(isSelectableInput(div)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHidden', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('detects elements with visibility: hidden', () => {
|
||||
const container = createContainer('');
|
||||
const el = document.createElement('div');
|
||||
el.style.visibility = 'hidden';
|
||||
container.appendChild(el);
|
||||
|
||||
expect(isHidden(el)).toBe(true);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('detects elements with display: none', () => {
|
||||
const container = createContainer('');
|
||||
const el = document.createElement('div');
|
||||
el.style.display = 'none';
|
||||
container.appendChild(el);
|
||||
|
||||
expect(isHidden(el)).toBe(true);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('returns false for visible elements', () => {
|
||||
const container = createContainer('');
|
||||
const el = document.createElement('div');
|
||||
container.appendChild(el);
|
||||
|
||||
expect(isHidden(el, container)).toBe(false);
|
||||
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
168
core/platform/src/browsers/focusScope/index.ts
Normal file
168
core/platform/src/browsers/focusScope/index.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export type FocusableTarget = HTMLElement | { focus: () => void };
|
||||
|
||||
export const AUTOFOCUS_ON_MOUNT = 'focusScope.autoFocusOnMount';
|
||||
export const AUTOFOCUS_ON_UNMOUNT = 'focusScope.autoFocusOnUnmount';
|
||||
export const EVENT_OPTIONS = { bubbles: false, cancelable: true };
|
||||
|
||||
/**
|
||||
* @name getActiveElement
|
||||
* @category Browsers
|
||||
* @description Returns the active element of the document (or shadow root)
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function getActiveElement(doc: Document | ShadowRoot = document): HTMLElement | null {
|
||||
let active = doc.activeElement as HTMLElement | null;
|
||||
|
||||
while (active?.shadowRoot)
|
||||
active = active.shadowRoot.activeElement as HTMLElement | null;
|
||||
|
||||
return active;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name isSelectableInput
|
||||
* @category Browsers
|
||||
* @description Checks if an element is an input element with a select method
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function isSelectableInput(element: unknown): element is FocusableTarget & { select: () => void } {
|
||||
return element instanceof HTMLInputElement && 'select' in element;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name focus
|
||||
* @category Browsers
|
||||
* @description Focuses an element without scrolling. Optionally calls select on input elements.
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function focus(element?: FocusableTarget | null, { select = false } = {}) {
|
||||
if (element && element.focus) {
|
||||
const previouslyFocused = getActiveElement();
|
||||
|
||||
element.focus({ preventScroll: true });
|
||||
|
||||
if (element !== previouslyFocused && isSelectableInput(element) && select) {
|
||||
element.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name focusFirst
|
||||
* @category Browsers
|
||||
* @description Attempts to focus the first element from a list of candidates. Stops when focus actually moves.
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function focusFirst(candidates: HTMLElement[], { select = false } = {}): boolean {
|
||||
const previouslyFocused = getActiveElement();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
focus(candidate, { select });
|
||||
|
||||
if (getActiveElement() !== previouslyFocused)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getTabbableCandidates
|
||||
* @category Browsers
|
||||
* @description Collects all tabbable candidates via TreeWalker (faster than querySelectorAll).
|
||||
* This is an approximate check — does not account for computed styles. Visibility is checked separately in `findFirstVisible`.
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function getTabbableCandidates(container: HTMLElement): HTMLElement[] {
|
||||
const nodes: HTMLElement[] = [];
|
||||
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
|
||||
acceptNode: (node: HTMLElement) => {
|
||||
const isHiddenInput = node.tagName === 'INPUT' && (node as HTMLInputElement).type === 'hidden';
|
||||
|
||||
if ((node as any).disabled || node.hidden || isHiddenInput)
|
||||
return NodeFilter.FILTER_SKIP;
|
||||
|
||||
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
|
||||
},
|
||||
});
|
||||
|
||||
while (walker.nextNode())
|
||||
nodes.push(walker.currentNode as HTMLElement);
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name isHidden
|
||||
* @category Browsers
|
||||
* @description Checks if an element is hidden via `visibility: hidden` or `display: none` up the DOM tree
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function isHidden(node: HTMLElement, upTo?: HTMLElement): boolean {
|
||||
const style = getComputedStyle(node);
|
||||
|
||||
if (style.visibility === 'hidden' || style.display === 'none')
|
||||
return true;
|
||||
|
||||
while (node.parentElement) {
|
||||
node = node.parentElement;
|
||||
|
||||
if (upTo !== undefined && node === upTo)
|
||||
return false;
|
||||
|
||||
if (getComputedStyle(node).display === 'none')
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name findFirstVisible
|
||||
* @category Browsers
|
||||
* @description Returns the first visible element from a list. Checks visibility up the DOM to `container` (exclusive).
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function findFirstVisible(elements: HTMLElement[], container: HTMLElement): HTMLElement | undefined {
|
||||
for (const element of elements) {
|
||||
if (!isHidden(element, container))
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name findLastVisible
|
||||
* @category Browsers
|
||||
* @description Returns the last visible element from a list. Checks visibility up the DOM to `container` (exclusive).
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function findLastVisible(elements: HTMLElement[], container: HTMLElement): HTMLElement | undefined {
|
||||
for (let i = elements.length - 1; i >= 0; i--) {
|
||||
if (!isHidden(elements[i]!, container))
|
||||
return elements[i];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getTabbableEdges
|
||||
* @category Browsers
|
||||
* @description Returns the first and last tabbable elements inside a container
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function getTabbableEdges(container: HTMLElement): { first: HTMLElement | undefined; last: HTMLElement | undefined } {
|
||||
const candidates = getTabbableCandidates(container);
|
||||
const first = findFirstVisible(candidates, container);
|
||||
const last = findLastVisible(candidates, container);
|
||||
|
||||
return { first, last };
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export * from './animationLifecycle';
|
||||
export * from './focusGuard';
|
||||
export * from './focusScope';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// eslint-disable
|
||||
|
||||
export interface DebounceOptions {
|
||||
/**
|
||||
* Call the function on the leading edge of the timeout, instead of waiting for the trailing edge
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export const _global =
|
||||
typeof globalThis !== 'undefined'
|
||||
export const _global
|
||||
= typeof globalThis !== 'undefined'
|
||||
? globalThis
|
||||
: typeof window !== 'undefined'
|
||||
? window
|
||||
|
||||
10
core/platform/tsdown.config.ts
Normal file
10
core/platform/tsdown.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
import { sharedConfig } from '@robonen/tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
...sharedConfig,
|
||||
entry: {
|
||||
browsers: 'src/browsers/index.ts',
|
||||
multi: 'src/multi/index.ts',
|
||||
},
|
||||
});
|
||||
7
core/platform/vitest.config.ts
Normal file
7
core/platform/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
});
|
||||
@@ -1 +1,32 @@
|
||||
# @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';
|
||||
```
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
rollup: {
|
||||
esbuild: {
|
||||
// minify: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
4
core/stdlib/oxlint.config.ts
Normal file
4
core/stdlib/oxlint.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { defineConfig } from 'oxlint';
|
||||
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
|
||||
|
||||
export default defineConfig(compose(base, typescript, imports, stylistic));
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@robonen/stdlib",
|
||||
"version": "0.0.7",
|
||||
"version": "0.0.9",
|
||||
"license": "Apache-2.0",
|
||||
"description": "A collection of tools, utilities, and helpers for TypeScript",
|
||||
"keywords": [
|
||||
@@ -18,9 +18,9 @@
|
||||
"url": "git+https://github.com/robonen/tools.git",
|
||||
"directory": "packages/stdlib"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"engines": {
|
||||
"node": ">=24.13.0"
|
||||
"node": ">=24.13.1"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -28,19 +28,29 @@
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.cjs"
|
||||
"import": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./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": "unbuild"
|
||||
"build": "tsdown"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/oxlint": "workspace:*",
|
||||
"@robonen/tsconfig": "workspace:*",
|
||||
"pathe": "catalog:",
|
||||
"unbuild": "catalog:"
|
||||
"@robonen/tsdown": "workspace:*",
|
||||
"@stylistic/eslint-plugin": "catalog:",
|
||||
"oxlint": "catalog:",
|
||||
"tsdown": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('sum', () => {
|
||||
});
|
||||
|
||||
it('return the sum of all elements using a getValue function', () => {
|
||||
const result = sum([{ value: 1 }, { value: 2 }, { value: 3 }], (item) => item.value);
|
||||
const result = sum([{ value: 1 }, { value: 2 }, { value: 3 }], item => item.value);
|
||||
|
||||
expect(result).toBe(6);
|
||||
});
|
||||
@@ -39,7 +39,7 @@ describe('sum', () => {
|
||||
});
|
||||
|
||||
it('handle arrays with a getValue function returning floating point numbers', () => {
|
||||
const result = sum([{ value: 1.5 }, { value: 2.5 }, { value: 3.5 }], (item) => item.value);
|
||||
const result = sum([{ value: 1.5 }, { value: 2.5 }, { value: 3.5 }], item => item.value);
|
||||
|
||||
expect(result).toBe(7.5);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('unique', () => {
|
||||
it('return an array with unique objects based on id', () => {
|
||||
const result = unique(
|
||||
[{ id: 1 }, { id: 2 }, { id: 1 }],
|
||||
(item) => item.id,
|
||||
item => item.id,
|
||||
);
|
||||
|
||||
expect(result).toEqual([{ id: 1 }, { id: 2 }]);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export type AsyncPoolOptions = {
|
||||
export interface AsyncPoolOptions {
|
||||
concurrency?: number;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// eslint-disable
|
||||
export interface RetryOptions {
|
||||
times?: number;
|
||||
delay?: number;
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
* @since 0.0.3
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -13,7 +13,9 @@ describe('tryIt', () => {
|
||||
});
|
||||
|
||||
it('handle synchronous functions with errors', () => {
|
||||
const syncFn = (): void => { throw new Error('Test error') };
|
||||
const syncFn = (): void => {
|
||||
throw new Error('Test error');
|
||||
};
|
||||
const wrappedSyncFn = tryIt(syncFn);
|
||||
|
||||
const [error, result] = wrappedSyncFn();
|
||||
@@ -34,7 +36,9 @@ describe('tryIt', () => {
|
||||
});
|
||||
|
||||
it('handle asynchronous functions with errors', async () => {
|
||||
const asyncFn = async () => { throw new Error('Test error') };
|
||||
const asyncFn = async () => {
|
||||
throw new Error('Test error');
|
||||
};
|
||||
const wrappedAsyncFn = tryIt(asyncFn);
|
||||
|
||||
const [error, result] = await wrappedAsyncFn();
|
||||
|
||||
@@ -30,11 +30,12 @@ export function tryIt<Args extends any[], Return>(
|
||||
|
||||
if (isPromise(result))
|
||||
return result
|
||||
.then((value) => [undefined, value])
|
||||
.catch((error) => [error, undefined]) as TryItReturn<Return>;
|
||||
.then(value => [undefined, value])
|
||||
.catch(error => [error, undefined]) as TryItReturn<Return>;
|
||||
|
||||
return [undefined, result] as TryItReturn<Return>;
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
return [error, undefined] as TryItReturn<Return>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { and, or, not, has, is, unset, toggle } from '.';
|
||||
|
||||
|
||||
describe('flagsAnd', () => {
|
||||
it('no effect on zero flags', () => {
|
||||
const result = and();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface BitVector {
|
||||
export interface BitVectorLike {
|
||||
getBit(index: number): boolean;
|
||||
setBit(index: number): void;
|
||||
clearBit(index: number): void;
|
||||
@@ -12,7 +12,7 @@ export interface BitVector {
|
||||
*
|
||||
* @since 0.0.3
|
||||
*/
|
||||
export class BitVector extends Uint8Array implements BitVector {
|
||||
export class BitVector extends Uint8Array implements BitVectorLike {
|
||||
constructor(size: number) {
|
||||
super(Math.ceil(size / 8));
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { type Collection, type Path } from '../../types';
|
||||
import type { Collection, Path } from '../../types';
|
||||
|
||||
export type ExtractFromObject<O extends Record<PropertyKey, unknown>, K> =
|
||||
K extends keyof O
|
||||
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 readonly (infer T)[]
|
||||
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 []
|
||||
export type ExtractFromCollection<O, K>
|
||||
= K extends []
|
||||
? O
|
||||
: K extends [infer Key, ...infer Rest]
|
||||
? O extends Record<PropertyKey, unknown>
|
||||
|
||||
@@ -8,4 +8,4 @@ export * from './structs';
|
||||
export * from './sync';
|
||||
export * from './text';
|
||||
export * from './types';
|
||||
export * from './utils'
|
||||
export * from './utils';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe,it, expect } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { clamp } from '.';
|
||||
|
||||
describe('clamp', () => {
|
||||
@@ -46,13 +46,13 @@ describe('clamp', () => {
|
||||
|
||||
it('handle NaN and Infinity', () => {
|
||||
// value is NaN
|
||||
expect(clamp(NaN, 0, 100)).toBe(NaN);
|
||||
expect(clamp(Number.NaN, 0, 100)).toBe(Number.NaN);
|
||||
|
||||
// min is NaN
|
||||
expect(clamp(50, NaN, 100)).toBe(NaN);
|
||||
expect(clamp(50, Number.NaN, 100)).toBe(Number.NaN);
|
||||
|
||||
// max is NaN
|
||||
expect(clamp(50, 0, NaN)).toBe(NaN);
|
||||
expect(clamp(50, 0, Number.NaN)).toBe(Number.NaN);
|
||||
|
||||
// value is Infinity
|
||||
expect(clamp(Infinity, 0, 100)).toBe(100);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, it, expect} from 'vitest';
|
||||
import {inverseLerp, lerp} from '.';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { inverseLerp, lerp } from '.';
|
||||
|
||||
describe('lerp', () => {
|
||||
it('interpolates between two values', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {remap} from '.';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { remap } from '.';
|
||||
|
||||
describe('remap', () => {
|
||||
it('map values from one range to another', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clamp } from '../clamp';
|
||||
import {inverseLerp, lerp} from '../lerp';
|
||||
import { inverseLerp, lerp } from '../lerp';
|
||||
|
||||
/**
|
||||
* @name remap
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user