diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c91473e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: voidzero-dev/setup-vp@v1 + with: + node-version: "24" + cache: true + + - run: vp install + - run: vp check # format, lint, and type checks + - run: vp test run + - run: vp pack # build the library + - run: vp dlx publint diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7535211 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.log +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a393822 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# @b2m9/reversible — agent guide + +`@b2m9/reversible` is a headless, synchronous undo/redo engine that stores +inverse operations (`do`/`undo`), not snapshots, and never owns your state. The +public contract is in `src/types.ts` (JSDoc) and the model is explained in +`README.md` — read those first. This file lists only what those don't: the +constraints the compiler won't enforce, and how we make changes here. + +## Toolchain + +ESM-only, Node ≥22. Toolchain is Vite+ (`vp`), not plain npm: +`vp check` (format/lint/types), `vp test run`, `vp pack` (build). + +## Constraints to preserve + +- **Zero runtime dependencies.** The core ships only its own code. +- **Synchronous only.** `do`, `undo`, and the transaction builder must not + return thenables — the engine throws if they do. +- **Headless.** The engine runs callbacks and moves a cursor; it never reads or + writes application state. +- **No reentrancy.** Public mutations throw if called from inside a + `do`/`undo`/rollback (`assertIdle`). +- **Notify only on real change.** A failed `commit`, an empty `transaction`, and + a no-op `undo`/`redo`/`jump` notify _nobody_. +- **Checkpoints anchor to entries.** Pruned when their entry is evicted (`limit` + overflow) or dropped (redo branch truncated by a fresh commit); `revertTo` + throws on an unknown/pruned name. +- **Rollback is reverse-order and best-effort.** A throwing inverse stops + rollback and surfaces. + +## Design principles + +This library's value is what it _doesn't_ do. Hold the line: + +- Prefer removing code to adding it. A new option, parameter, or branch must + earn its place — if a behavior composes from existing primitives, don't add a + primitive for it. +- Every public API entry is a lifetime maintenance cost. Default to "no"; make + the use case prove the surface is necessary. +- A feature change that only adds public surface area is suspect. When + proposing one, say what it lets us delete or simplify. + +## Comments + +Match the comments already in `src/` — they are the style guide. The pattern: + +- Explain _why_, not _what_: the invariant being held or the failure being + guarded, never a paraphrase of the code below. +- Only at decision points — a guard, a non-obvious ordering, a deliberate + no-op. Self-evident lines get nothing. +- Terse, full sentences, present tense. When in doubt, imitate the nearest + existing comment. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..459513c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,6 @@ +# @b2m9/reversible + +The agent guide for this project is maintained in a single source of truth, +`AGENTS.md` (the cross-tool standard). Claude Code reads it via this import: + +@AGENTS.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b0629d --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +# @b2m9/reversible + +Headless, framework-agnostic undo/redo for any state. You give it reversible +operations; it gives you `undo`/`redo`/`jump` traversal, grouped transactions, +checkpoints, and timeline scrubbing. It does **not** own your state, but composes +with whatever store you already have. + +```bash +npm install @b2m9/reversible +``` + +ESM-only. Zero runtime dependencies. + +## The model: command, not snapshot + +Most undo libraries snapshot your whole state into a `past`/`future` stack. +`reversible` stores **inverse operations** instead. You `commit` a `do`/`undo` +pair; the engine runs `do` and remembers how to reverse it. + +| | Snapshot-based | **@b2m9/reversible** | +| ---------------------- | ----------------------------------- | ------------------------------------------- | +| Coupling | Higher-order reducer / store plugin | Headless, zero-dep, any store or none | +| History model | Full copies of `present` | Inverse operations (`do`/`undo`) | +| Memory cost | State size × history depth | Inverse-payload size — usually ≪ a snapshot | +| Non-serializable state | Effectively unsupported | Supported — you write the inverse | +| Grouping | Auto-capture heuristics | Explicit `transaction()` boundary | +| What's undoable | Filter config | Caller decides — just don't `commit()` it | + +The engine holds one ordered list of entries and a **cursor**: + +``` + committed entries + ┌──────┬──────┬──────┐ ┌──────┐ + │ e1 │ e2 │ e3 │ cursor │ e4 │ + └──────┴──────┴──────┘ ▲ └──────┘ + undoable here redoable +``` + +Everything left of the cursor can be undone; everything right can be redone. +"Present state" is **your app's**, not the engine's and it only knows how to move +along the timeline of effects. + +> **The one responsibility you own:** `do` and `undo` must be true inverses. +> The engine keeps its own bookkeeping consistent; it cannot fix a broken inverse. + +## One tiny example + +The external `count` is the point: your state, wherever it lives. + +```ts +import { createHistory } from "@b2m9/reversible"; + +const history = createHistory(); +let count = 0; + +history.commit({ + label: "increment", + do: () => { + count += 1; + }, + undo: () => { + count -= 1; + }, +}); + +history.undo(); // count === 0 +history.redo(); // count === 1 +history.jump(-1); // count === 0 — scrub the whole timeline +``` + +## One transaction example + +Group many operations into one atomic entry — undone and redone as a unit. + +```ts +history.transaction('type "hello"', (tx) => { + for (const ch of "hello") { + tx.commit({ + do: () => doc.append(ch), + undo: () => doc.trimEnd(1), + }); + } +}); + +history.undo(); // removes the whole word, not one letter +``` + +Wire it to UI with `subscribe`, which hands each listener a meta snapshot: + +```ts +const off = history.subscribe((m) => { + undoBtn.disabled = !m.canUndo; + undoBtn.title = m.undoLabel ? `Undo: ${m.undoLabel}` : "Undo"; +}); +``` + +## Failure rules + +- **Synchronous only.** `do`/`undo` must run synchronously. If one returns a + thenable, the engine throws loudly rather than fire-and-forget a promise it + can't reverse. +- **Atomic transactions (best-effort).** If anything throws before a + `transaction` builder returns, the applied operations roll back in reverse and + the entry never enters history. "Best-effort" because atomicity holds only as + far as your inverses allow. +- **Failed `commit` is a no-op.** If `do` throws, nothing is recorded, the cursor + doesn't move, and the redo branch is left intact. +- **Boundaries clamp.** `undo()`/`redo()` return `false` at the ends. `jump(n)` + clamps and returns the signed count of steps actually moved (`jump(n) === n` + means fully moved). A throw mid-`jump` stops at the last successful step. +- **A new commit after undo clears the redo branch.** Traversal never does. +- **Reentrancy throws.** Calling back into the engine from inside a running + `do`/`undo`/rollback throws. `subscribe` listeners fire after the operation + settles, so calling back from a listener is fine. + +## API + +```ts +const history = createHistory({ limit: 100 }); // limit is optional + +history.commit({ label?, do, undo }); +history.transaction(label, (tx) => tx.commit({ do, undo })); + +history.undo(); // boolean +history.redo(); // boolean +history.jump(n); // signed steps moved + +history.canUndo; // booleans / derived meta +history.canRedo; +history.undoLabel; // string | undefined +history.redoLabel; +history.position; // cursor in [0, length] +history.length; + +history.checkpoint(name); +history.hasCheckpoint(name); // gate revertTo the way canUndo gates undo +history.revertTo(name); // throws if the name is unknown or was pruned + +history.clear(); +history.subscribe((meta) => { /* ... */ }); // returns unsubscribe +``` + +With `limit`, the oldest entries are evicted past the cap. Checkpoints anchor to +the entry they sit after, so they stay correct as eviction renumbers positions; a +checkpoint whose anchor is evicted (or dropped when a commit clears a redo branch) +is pruned, and `revertTo` on it throws. + +## Non-goals + +- **It doesn't own your state.** No `present`, no store, no deep-clone. A thin + state-owning convenience wrapper may land later, layered on top. +- **No async.** Reversing remote or async effects is a compensation-and-idempotency + problem this package deliberately does not solve. +- **No branching timelines.** Checkpoints are markers on one line, not forks. + +## License + +MIT © 2026 Bob Massarczyk diff --git a/package.json b/package.json new file mode 100644 index 0000000..041aa17 --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "@b2m9/reversible", + "version": "0.1.0", + "description": "Headless, framework-agnostic undo/redo for any state — a small command stack of reversible operations with grouped transactions, checkpoints, and timeline scrubbing.", + "keywords": [ + "checkpoint", + "command-pattern", + "headless", + "history", + "redo", + "state", + "time-travel", + "transaction", + "undo" + ], + "homepage": "https://github.com/b2m9/reversible#readme", + "bugs": { + "url": "https://github.com/b2m9/reversible/issues" + }, + "license": "MIT", + "author": "Bob Massarczyk ", + "repository": { + "type": "git", + "url": "git+https://github.com/b2m9/reversible.git" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "vp pack", + "dev": "vp pack --watch", + "test": "vp test run", + "check": "vp check", + "check:exports": "vp pack && vp dlx publint && vp dlx @arethetypeswrong/cli --pack --profile esm-only", + "prepublishOnly": "vp run build" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "@typescript/native-preview": "7.0.0-dev.20260509.2", + "bumpp": "^11.1.0", + "typescript": "^6.0.3", + "vite-plus": "catalog:" + }, + "engines": { + "node": ">=22" + }, + "packageManager": "pnpm@11.7.0" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..f224447 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1607 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + vite-plus: + specifier: latest + version: 0.1.24 + +overrides: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: npm:@voidzero-dev/vite-plus-test@latest + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.9.3 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260509.2 + version: 7.0.0-dev.20260509.2 + bumpp: + specifier: ^11.1.0 + version: 11.1.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite-plus: + specifier: 'catalog:' + version: 0.1.24(@types/node@25.9.3)(jiti@2.7.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0) + +packages: + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/runtime@0.133.0': + resolution: {integrity: sha512-PkvjA1Lq5++V5S1E6Patr92ZVcieE6EalDr1VJTqv4BnjZdOUC4W3p8k1wMXSd5/2aFP4b/A6N5sg2Bkzcr9vQ==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@oxfmt/binding-android-arm-eabi@0.52.0': + resolution: {integrity: sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.52.0': + resolution: {integrity: sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.52.0': + resolution: {integrity: sha512-f9+bLvOYxy7NttCLFTvQ7afmqDOWY4wIP9xdvfj5trQ1qj6f2UFAGwZESlfsMjvJNTyRpXfIlOanCI9FOvoeQA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.52.0': + resolution: {integrity: sha512-YSTB9sJ5nnQd/Q0ddHkgof0ZCHPAnWZT1IW2SJ8omz7CP7KluJhO1fNHrpqdxCtpztJwSs4hY1uAee35wKxxaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.52.0': + resolution: {integrity: sha512-NIrRNTTPCs4UbmVs0bxLSCDlLCtIRMJIXklNKaXa5Oj2/K1UIMBvgE8+uPVo01Io3N9HF0+GAX+aAHjUgZS7vA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.52.0': + resolution: {integrity: sha512-JXUCde8mn3GpgQouz2PXUokgy/uT1QrRJBL2s983VWcSQp62wTFYiNXgTKdeo1Jgbr0IgUnKKvzIk/YBlj/nVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.52.0': + resolution: {integrity: sha512-psbUXaRZ+V8DaXz10Qf7LSHtdtdKAmC8fxXgeU608jjzrmWK4quamZMOpl6sf+dikoFHA85uE93Q0BqxrCdQrQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.52.0': + resolution: {integrity: sha512-Jw7MgWUU9lcLCcy82updISP3EthTlfvAwR6gWNxPzqly7+fLvOi2gHQE9xXQjpqaVLm/8P+gOzlv9ODuoVlaaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.52.0': + resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.52.0': + resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.52.0': + resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.52.0': + resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.52.0': + resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.52.0': + resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.52.0': + resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.52.0': + resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.52.0': + resolution: {integrity: sha512-k2sz6gWQdMfh5HPpIS+Bw/0UEV/kaK2xuqJRrWL233sEHx9WLlsmvlPFM4HUNThkYbSN0U0vPW7LVKZWDS8hPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.52.0': + resolution: {integrity: sha512-rhke69GTcArodLHpjMTfNnvjTEBryDeZcUCKK/VjXDMtfTULl6QRh0ymX5/hbCUv2WjYm9h/QbW++q2vE15gWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.52.0': + resolution: {integrity: sha512-q5xL7oeXkZdEtNZWBdvehJcmt+GRu9l2bK40yJs1jJXlqq+r0Hygb1rTjq+FM2o/2xyt4cufH6KRplHp3Jjsvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint-tsgolint/darwin-arm64@0.23.0': + resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@0.23.0': + resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@0.23.0': + resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@0.23.0': + resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@0.23.0': + resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@0.23.0': + resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.67.0': + resolution: {integrity: sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.67.0': + resolution: {integrity: sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.67.0': + resolution: {integrity: sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.67.0': + resolution: {integrity: sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.67.0': + resolution: {integrity: sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.67.0': + resolution: {integrity: sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.67.0': + resolution: {integrity: sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.67.0': + resolution: {integrity: sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.67.0': + resolution: {integrity: sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.67.0': + resolution: {integrity: sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.67.0': + resolution: {integrity: sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.67.0': + resolution: {integrity: sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.67.0': + resolution: {integrity: sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.67.0': + resolution: {integrity: sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.67.0': + resolution: {integrity: sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.67.0': + resolution: {integrity: sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.67.0': + resolution: {integrity: sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.67.0': + resolution: {integrity: sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.67.0': + resolution: {integrity: sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/plugins@1.61.0': + resolution: {integrity: sha512-nkOyZEF1vH527CkdQtOp1HMrVFEM4ResURvI2JFeGoup+h+43J/k/FgdOR9b9Isxg+Yae7qVDa7y3nssE8b3TQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/node@25.9.3': + resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-oG9KahiCpx4q70Ood/rRJhYio4oIMHEHfX0g0LhfenlSIjIonitZWjUmUVG9N9q1ev9QWcM8pWpDrGGP0Osp3Q==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-xdEkp23Gu8I7PJCMmSMYtSLX76NKODWj74AoWFPi6MM59ICsjnTSqZf/HmXKSvuNZ5MGb4CMpP3c40dLjGB2PQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-rd+bMRtUAFBClOAKi9p2rOu6jPmnrjZVljoFyxHw+6bIRLerEQlxP+nIH1olC3HOZPyZ6/x75WtfzTHYeqffiQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-ar5HN/V/4HLF4FZCoVVFj+ET1Soi758hb4WhhzYQfSUXQ/bpVGUGP86JAy8EhVMoeN6qxqWet93MkLSszJOIVg==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-lB26mGzdolYIZiOdBII8roVJCxCUR8zkYszvvHyjB1IPs7d5fmOhT6OzI1zYPYujiSRJi4HVYM1iXTcIfp7KDg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-gH3UmtyxHiRNEP0LgQXCVlB5+ZN/U+/Z7jM/zULQtTOxIIFK3Y4b8gbGLvP7uW3u2cqYOg2hc2nuN8OdsCmOig==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-kZV0Vh64hp10saOghPlFZE1qahonqvRgU3iubt8pUY4XLe8IQIofwWCN5vzNNeULE4W4mRtAJbHuvP/muOFomw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260509.2': + resolution: {integrity: sha512-JAJpEX0yBaEle2zzbX5z9QAhmEfML1SyQafLwbKCdcOtnkGdk5xD8NKIVxq+nTwYjRwuV7kKnQ+fqU3gpWY0qQ==} + engines: {node: '>=16.20.0'} + hasBin: true + + '@voidzero-dev/vite-plus-core@0.1.24': + resolution: {integrity: sha512-iXPGBABnQnrDMx89H6MOCGcTZp+QW+3rY4YMVKdE6ydchSvPk2O3MI2vgaRVfOtWJ2IjnxSnf1n2yjP67ZBRFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.1 + '@tsdown/exe': 0.22.1 + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + publint: ^0.3.8 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + typescript: ^5.0.0 || ^6.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + yaml: ^2.4.2 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + publint: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + yaml: + optional: true + + '@voidzero-dev/vite-plus-darwin-arm64@0.1.24': + resolution: {integrity: sha512-Hpo9W9piSFlEsJzGkwzfDXhJGrnYByxHXF7NVQZ7g+SLOprddtlfTeM8t+gq9dxcuq0RzM8ddMAhDQP/K3fZQA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@voidzero-dev/vite-plus-darwin-x64@0.1.24': + resolution: {integrity: sha512-SwnnnZrEFBiU5iKlh/CZAVwn0RFt/Udrvt3kFLtdRxMtN5bKaqTFVA2H8Y/FPCWp1QX9bs4V9ZIAeXAk06zLkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.24': + resolution: {integrity: sha512-ImM3eqDki4DpRuHjW6dEh4St8zvbcfOMR7KQZJX42ArriCLQ/QdaYhDRRbcDi27XsOBqRxm2eqUUEymPrYIHpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.24': + resolution: {integrity: sha512-gj4mzbob/ls8Zs7iTuF9Gr0EFFF7tdpDiPxDPBkH8tJP5OkHABlzWUwJhU+9xxcUbTaXqpHDw68Mil7jm5dpMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.24': + resolution: {integrity: sha512-x7IYK7lI+WuF1n3jSzEYU6FgJxPX/R0rDmTTsOutooGGCU7uShZvfZqIoiTXK0eFnJU5ij5BfBgenenUfsaT/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-x64-musl@0.1.24': + resolution: {integrity: sha512-JCy2w0eSVUlWQlggK5T47MnL+j0o4EY7hLskINVI8gi+aixQF4xnYBDobz0lbxkqz3/IfiLyXUx6TcU3thcsGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-test@0.1.24': + resolution: {integrity: sha512-9NiG6UadG0iOaPL1AMsO5sDKkx6MADHw4/mMOmHWZUhhUwqzfVtnnptMK37vD71e6KyR7yAscx19FrtOWWtjvA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.24': + resolution: {integrity: sha512-G+/lhLKVjyn3FmgXX8jeWgq7RcE5O1kdR7QyFayQOdlMX/ZRkvUwQD7bFaqhKzgJM6Oj3a1FH3HQPYk5QOYuCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.24': + resolution: {integrity: sha512-b0e5XohEV1w/RdzAtv8/Hm6tvHPXouPtBNsljjW/lDJZq3NCLND5s6lqe8H4IenrgmKSoqakHWtlqJqM36cFbw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + args-tokenizer@0.3.0: + resolution: {integrity: sha512-xXAd7G2Mll5W8uo37GETpQ2VrE84M181Z7ugHFGQnJZ50M2mbOv0osSZ9VsSgPfJQ+LVG0prSi0th+ELMsno7Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bumpp@11.1.0: + resolution: {integrity: sha512-jdwOGMyX8JIqpQ0N2RMRR87DHZaoJnUtui5lU9LqFfFK5JC0H8qY9uWqXoa+dEWt/K7rOmmsoyiZB8RBM7RPBQ==} + engines: {node: '>=20.19.0'} + hasBin: true + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + oxfmt@0.52.0: + resolution: {integrity: sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint-tsgolint@0.23.0: + resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==} + hasBin: true + + oxlint@1.67.0: + resolution: {integrity: sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pixelmatch@7.2.0: + resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + unconfig@7.5.0: + resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + vite-plus@0.1.24: + resolution: {integrity: sha512-b3fr6WtCiEhetjuzW/4KcEMOAMuZxoxZATWaXKmPzOLf1upG+pzKJOFZTb94D6wiPBlwcjxoaUtF7C3uAN+VjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/runtime@0.133.0': {} + + '@oxc-project/types@0.133.0': {} + + '@oxfmt/binding-android-arm-eabi@0.52.0': + optional: true + + '@oxfmt/binding-android-arm64@0.52.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.52.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.52.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.52.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.52.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.52.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.52.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.52.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.52.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.52.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.52.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.52.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.52.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.52.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.52.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.52.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.52.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.52.0': + optional: true + + '@oxlint-tsgolint/darwin-arm64@0.23.0': + optional: true + + '@oxlint-tsgolint/darwin-x64@0.23.0': + optional: true + + '@oxlint-tsgolint/linux-arm64@0.23.0': + optional: true + + '@oxlint-tsgolint/linux-x64@0.23.0': + optional: true + + '@oxlint-tsgolint/win32-arm64@0.23.0': + optional: true + + '@oxlint-tsgolint/win32-x64@0.23.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.67.0': + optional: true + + '@oxlint/binding-android-arm64@1.67.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.67.0': + optional: true + + '@oxlint/binding-darwin-x64@1.67.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.67.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.67.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.67.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.67.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.67.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.67.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.67.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.67.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.67.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.67.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.67.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.67.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.67.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.67.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.67.0': + optional: true + + '@oxlint/plugins@1.61.0': {} + + '@polka/url@1.0.0-next.29': {} + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/node@25.9.3': + dependencies: + undici-types: 7.24.6 + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260509.2': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260509.2': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260509.2': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260509.2': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260509.2': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260509.2': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260509.2': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260509.2': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260509.2 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260509.2 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260509.2 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260509.2 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260509.2 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260509.2 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260509.2 + + '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.3)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0)': + dependencies: + '@oxc-project/runtime': 0.133.0 + '@oxc-project/types': 0.133.0 + lightningcss: 1.32.0 + postcss: 8.5.15 + optionalDependencies: + '@types/node': 25.9.3 + fsevents: 2.3.3 + jiti: 2.7.0 + typescript: 6.0.3 + yaml: 2.9.0 + + '@voidzero-dev/vite-plus-darwin-arm64@0.1.24': + optional: true + + '@voidzero-dev/vite-plus-darwin-x64@0.1.24': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.24': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.24': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.24': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-musl@0.1.24': + optional: true + + '@voidzero-dev/vite-plus-test@0.1.24(@types/node@25.9.3)(jiti@2.7.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@25.9.3)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) + es-module-lexer: 1.7.0 + obug: 2.1.3 + pixelmatch: 7.2.0 + pngjs: 7.0.0 + sirv: 3.0.2 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + vite: 8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0) + ws: 8.21.0 + optionalDependencies: + '@types/node': 25.9.3 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@tsdown/css' + - '@tsdown/exe' + - '@vitejs/devtools' + - bufferutil + - esbuild + - jiti + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.24': + optional: true + + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.24': + optional: true + + args-tokenizer@0.3.0: {} + + assertion-error@2.0.1: {} + + bumpp@11.1.0: + dependencies: + args-tokenizer: 0.3.0 + cac: 7.0.0 + jsonc-parser: 3.3.1 + package-manager-detector: 1.6.0 + semver: 7.8.4 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + unconfig: 7.5.0 + yaml: 2.9.0 + + cac@7.0.0: {} + + defu@6.1.7: {} + + detect-libc@2.1.2: {} + + es-module-lexer@1.7.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + jiti@2.7.0: {} + + jsonc-parser@3.3.1: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + mrmime@2.0.1: {} + + nanoid@3.3.12: {} + + obug@2.1.3: {} + + oxfmt@0.52.0(vite-plus@0.1.24(@types/node@25.9.3)(jiti@2.7.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)): + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.52.0 + '@oxfmt/binding-android-arm64': 0.52.0 + '@oxfmt/binding-darwin-arm64': 0.52.0 + '@oxfmt/binding-darwin-x64': 0.52.0 + '@oxfmt/binding-freebsd-x64': 0.52.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.52.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.52.0 + '@oxfmt/binding-linux-arm64-gnu': 0.52.0 + '@oxfmt/binding-linux-arm64-musl': 0.52.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-musl': 0.52.0 + '@oxfmt/binding-linux-s390x-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-musl': 0.52.0 + '@oxfmt/binding-openharmony-arm64': 0.52.0 + '@oxfmt/binding-win32-arm64-msvc': 0.52.0 + '@oxfmt/binding-win32-ia32-msvc': 0.52.0 + '@oxfmt/binding-win32-x64-msvc': 0.52.0 + vite-plus: 0.1.24(@types/node@25.9.3)(jiti@2.7.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0) + + oxlint-tsgolint@0.23.0: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 0.23.0 + '@oxlint-tsgolint/darwin-x64': 0.23.0 + '@oxlint-tsgolint/linux-arm64': 0.23.0 + '@oxlint-tsgolint/linux-x64': 0.23.0 + '@oxlint-tsgolint/win32-arm64': 0.23.0 + '@oxlint-tsgolint/win32-x64': 0.23.0 + + oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(jiti@2.7.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.67.0 + '@oxlint/binding-android-arm64': 1.67.0 + '@oxlint/binding-darwin-arm64': 1.67.0 + '@oxlint/binding-darwin-x64': 1.67.0 + '@oxlint/binding-freebsd-x64': 1.67.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.67.0 + '@oxlint/binding-linux-arm-musleabihf': 1.67.0 + '@oxlint/binding-linux-arm64-gnu': 1.67.0 + '@oxlint/binding-linux-arm64-musl': 1.67.0 + '@oxlint/binding-linux-ppc64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-musl': 1.67.0 + '@oxlint/binding-linux-s390x-gnu': 1.67.0 + '@oxlint/binding-linux-x64-gnu': 1.67.0 + '@oxlint/binding-linux-x64-musl': 1.67.0 + '@oxlint/binding-openharmony-arm64': 1.67.0 + '@oxlint/binding-win32-arm64-msvc': 1.67.0 + '@oxlint/binding-win32-ia32-msvc': 1.67.0 + '@oxlint/binding-win32-x64-msvc': 1.67.0 + oxlint-tsgolint: 0.23.0 + vite-plus: 0.1.24(@types/node@25.9.3)(jiti@2.7.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0) + + package-manager-detector@1.6.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pixelmatch@7.2.0: + dependencies: + pngjs: 7.0.0 + + pngjs@7.0.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + quansync@1.0.0: {} + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + semver@7.8.4: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + std-env@4.1.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@2.1.0: {} + + totalist@3.0.1: {} + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + unconfig@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + defu: 6.1.7 + jiti: 2.7.0 + quansync: 1.0.0 + unconfig-core: 7.5.0 + + undici-types@7.24.6: {} + + vite-plus@0.1.24(@types/node@25.9.3)(jiti@2.7.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0): + dependencies: + '@oxc-project/types': 0.133.0 + '@oxlint/plugins': 1.61.0 + '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@25.9.3)(jiti@2.7.0)(typescript@6.0.3)(yaml@2.9.0) + '@voidzero-dev/vite-plus-test': 0.1.24(@types/node@25.9.3)(jiti@2.7.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0) + oxfmt: 0.52.0(vite-plus@0.1.24(@types/node@25.9.3)(jiti@2.7.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)) + oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@25.9.3)(jiti@2.7.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0))(yaml@2.9.0)) + oxlint-tsgolint: 0.23.0 + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 0.1.24 + '@voidzero-dev/vite-plus-darwin-x64': 0.1.24 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.24 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.24 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.24 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.24 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.24 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.24 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vite + - yaml + + vite@8.0.16(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.3 + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 + + ws@8.21.0: {} + + yaml@2.9.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..6a049a0 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,14 @@ +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: npm:@voidzero-dev/vite-plus-test@latest + vite-plus: latest +overrides: + vite: "catalog:" + vitest: "catalog:" +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: "*" + vitest: "*" diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..9497dd6 --- /dev/null +++ b/src/core.ts @@ -0,0 +1,233 @@ +import type { History, HistoryMeta, HistoryOptions, Reversible } from "./types.ts"; +import { type Entry, type EngineInternals, isThenable, runTransaction } from "./transaction.ts"; + +/** Sentinel anchor for a checkpoint at position 0 (before all entries). */ +const START = Symbol("reversible.start"); +type Anchor = Entry | typeof START; + +/** + * Create a headless undo/redo engine. It tracks only the undo/redo stacks and a + * cursor — your application state lives wherever it already does. See the + * package brief for the full design. + */ +export function createHistory(options: HistoryOptions = {}): History { + const { limit } = options; + if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) { + throw new TypeError("reversible: limit must be a positive integer"); + } + + const entries: Entry[] = []; + let cursor = 0; + const checkpoints = new Map(); + const listeners = new Set<(meta: HistoryMeta) => void>(); + let mutating = false; + + /** Reentrancy guard: reject any public mutation while user code is running. */ + const assertIdle = (): void => { + if (mutating) { + throw new Error("reversible: reentrant engine call from inside a do/undo/rollback"); + } + }; + + /** Run a user operation, throwing loudly if it returns a thenable. */ + const runGuarded = (fn: () => void): void => { + const result = (fn as () => unknown)(); + if (isThenable(result)) { + throw new TypeError("reversible: do/undo must be synchronous (it returned a thenable)"); + } + }; + + const buildMeta = (): HistoryMeta => + Object.freeze({ + canUndo: cursor > 0, + canRedo: cursor < entries.length, + undoLabel: cursor > 0 ? entries[cursor - 1].label : undefined, + redoLabel: cursor < entries.length ? entries[cursor].label : undefined, + position: cursor, + length: entries.length, + }); + + const notify = (): void => { + if (listeners.size === 0) return; + const meta = buildMeta(); + for (const listener of listeners) listener(meta); + }; + + const pruneCheckpoints = (removed: Entry[]): void => { + if (checkpoints.size === 0 || removed.length === 0) return; + const removedSet = new Set(removed); + for (const [name, anchor] of checkpoints) { + if (anchor !== START && removedSet.has(anchor)) { + checkpoints.delete(name); + } + } + }; + + const append = (entry: Entry): void => { + // A fresh commit drops the redo branch (entries right of the cursor). + if (cursor < entries.length) { + pruneCheckpoints(entries.splice(cursor)); + } + entries.push(entry); + cursor += 1; + // Evict the oldest entries past the cap. + if (limit !== undefined && entries.length > limit) { + const overflow = entries.length - limit; + pruneCheckpoints(entries.splice(0, overflow)); + cursor -= overflow; + } + }; + + /** + * Walk the cursor `n` steps, running each inverse/forward op. Clamps at the + * ends. May throw mid-way, leaving the cursor at the last successful step. + */ + const traverse = (n: number): void => { + const dir = n < 0 ? -1 : 1; + let remaining = Math.abs(n); + while (remaining > 0) { + if (dir < 0) { + if (cursor === 0) break; + runGuarded(entries[cursor - 1].undo); + cursor -= 1; + } else { + if (cursor === entries.length) break; + runGuarded(entries[cursor].do); + cursor += 1; + } + remaining -= 1; + } + }; + + /** Guarded traversal shared by jump/undo/redo/revertTo. Returns steps moved (signed). */ + const move = (n: number): number => { + const before = cursor; + mutating = true; + let caught: { error: unknown } | undefined; + try { + traverse(n); + } catch (error) { + caught = { error }; + } finally { + mutating = false; + } + const moved = cursor - before; + if (moved !== 0) notify(); + if (caught) throw caught.error; + return moved; + }; + + const commit = (action: Reversible): void => { + assertIdle(); + mutating = true; + try { + runGuarded(action.do); + } catch (error) { + // A failed commit records nothing and notifies no subscriber. + mutating = false; + throw error; + } + mutating = false; + append({ label: action.label, do: action.do, undo: action.undo }); + notify(); + }; + + const undo = (): boolean => { + assertIdle(); + if (cursor === 0) return false; + move(-1); + return true; + }; + + const redo = (): boolean => { + assertIdle(); + if (cursor === entries.length) return false; + move(1); + return true; + }; + + const jump = (n: number): number => { + assertIdle(); + if (n === 0) return 0; + return move(n); + }; + + const checkpoint = (name: string): void => { + assertIdle(); + checkpoints.set(name, cursor === 0 ? START : entries[cursor - 1]); + }; + + const hasCheckpoint = (name: string): boolean => checkpoints.has(name); + + const revertTo = (name: string): void => { + assertIdle(); + const anchor = checkpoints.get(name); + if (anchor === undefined) { + throw new Error(`reversible: unknown checkpoint "${name}"`); + } + const target = anchor === START ? 0 : entries.indexOf(anchor) + 1; + move(target - cursor); + }; + + const clear = (): void => { + assertIdle(); + if (entries.length === 0 && checkpoints.size === 0) return; + entries.length = 0; + cursor = 0; + checkpoints.clear(); + notify(); + }; + + const subscribe = (listener: (meta: HistoryMeta) => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + + const internals: EngineInternals = { + assertIdle, + beginMutation: () => { + mutating = true; + }, + endMutation: () => { + mutating = false; + }, + runGuarded, + append, + notify, + }; + + return { + commit, + transaction: (label, build) => { + runTransaction(internals, label, build); + }, + undo, + redo, + jump, + checkpoint, + hasCheckpoint, + revertTo, + clear, + subscribe, + get canUndo() { + return cursor > 0; + }, + get canRedo() { + return cursor < entries.length; + }, + get undoLabel() { + return cursor > 0 ? entries[cursor - 1].label : undefined; + }, + get redoLabel() { + return cursor < entries.length ? entries[cursor].label : undefined; + }, + get position() { + return cursor; + }, + get length() { + return entries.length; + }, + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f129b87 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,8 @@ +export { createHistory } from "./core.ts"; +export type { + History, + HistoryMeta, + HistoryOptions, + Reversible, + TransactionBuilder, +} from "./types.ts"; diff --git a/src/transaction.ts b/src/transaction.ts new file mode 100644 index 0000000..9a4c0f1 --- /dev/null +++ b/src/transaction.ts @@ -0,0 +1,114 @@ +import type { Reversible, TransactionBuilder } from "./types.ts"; + +/** An internal history entry. A transaction collapses to exactly one of these. */ +export interface Entry { + label?: string; + do: () => void; + undo: () => void; +} + +/** + * The slice of engine internals the transaction builder needs. Kept tiny so the + * builder owns rollback without reaching into the rest of the core. + */ +export interface EngineInternals { + assertIdle(): void; + beginMutation(): void; + endMutation(): void; + runGuarded(fn: () => void): void; + append(entry: Entry): void; + notify(): void; +} + +/** + * True if `value` is a thenable (exposes a callable `then`). The single source of + * truth for the engine's synchronous-only contract: a `do`/`undo` or transaction + * builder that returns one has detached work the engine can never reverse. + */ +export function isThenable(value: unknown): boolean { + return ( + value !== null && + value !== undefined && + typeof (value as { then?: unknown }).then === "function" + ); +} + +/** + * Run a transaction: apply each committed operation eagerly, and on success + * collapse them into one entry whose `do` replays forward and whose `undo` + * replays the inverses in strict reverse order. + * + * If anything throws before `build` returns — a committed op or the build + * callback itself — the applied operations roll back in reverse and no entry is + * recorded. Best-effort: if a rollback inverse itself throws, that error + * surfaces and rollback stops. + */ +export function runTransaction( + engine: EngineInternals, + label: string, + build: (tx: TransactionBuilder) => void, +): void { + engine.assertIdle(); + engine.beginMutation(); + + const ops: Reversible[] = []; + // The builder is valid only for the synchronous span of `build`. Once that + // returns (or throws), `closed` rejects any captured/escaped `tx.commit` so a + // late op can neither mutate state untracked nor push onto the live `ops` array + // the recorded entry closes over. + let closed = false; + const tx: TransactionBuilder = { + commit(action) { + if (closed) { + throw new Error("reversible: transaction builder used after the transaction finished"); + } + engine.runGuarded(action.do); + // Copy the op; don't retain the caller's mutable action object. + ops.push({ do: action.do, undo: action.undo }); + }, + }; + + try { + const result = (build as (tx: TransactionBuilder) => unknown)(tx); + closed = true; + // An async builder records only its synchronous prefix and lets later commits + // escape; reject it loudly, matching the sync-only contract on do/undo. + if (isThenable(result)) { + // A real promise's detached continuation hits `closed`, rejects, and would + // crash the host; swallow that rejection. Narrow to actual promises so we + // never assimilate (and thereby run) an arbitrary thenable's `then`. + if (result instanceof Promise) void result.catch(() => {}); + throw new TypeError( + "reversible: transaction builder must be synchronous (it returned a thenable)", + ); + } + } catch (error) { + closed = true; + try { + for (let i = ops.length - 1; i >= 0; i -= 1) { + engine.runGuarded(ops[i].undo); + } + } finally { + engine.endMutation(); + } + throw error; + } + + engine.endMutation(); + + // An empty transaction creates no entry and notifies no subscriber. + if (ops.length === 0) return; + + engine.append({ + label, + do() { + for (const op of ops) engine.runGuarded(op.do); + }, + undo() { + for (let i = ops.length - 1; i >= 0; i -= 1) { + engine.runGuarded(ops[i].undo); + } + }, + }); + engine.notify(); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3cb503f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,83 @@ +/** + * A reversible operation: a `do`/`undo` pair the engine runs and remembers how + * to reverse. Both must be **synchronous** and **true inverses** of each other — + * that is the one responsibility the caller owns. + */ +export interface Reversible { + /** Optional label, surfaced as `undoLabel`/`redoLabel` for UI tooltips. */ + label?: string; + /** Run now and on every redo. Must be synchronous. */ + do: () => void; + /** Run on undo. Must be synchronous. */ + undo: () => void; +} + +/** The builder handed to a {@link History.transaction} callback. */ +export interface TransactionBuilder { + commit(action: Reversible): void; +} + +/** A read-only snapshot of the engine's derived state, delivered to subscribers. */ +export interface HistoryMeta { + readonly canUndo: boolean; + readonly canRedo: boolean; + readonly undoLabel?: string; + readonly redoLabel?: string; + readonly position: number; + readonly length: number; +} + +export interface History { + /** Run an operation now and append it as a single undoable entry. */ + commit(action: Reversible): void; + /** + * Group many operations into one atomic entry. Operations apply eagerly; if + * anything throws before `build` returns, the applied operations roll back in + * reverse, the entry never enters history, and the error rethrows. + */ + transaction(label: string, build: (tx: TransactionBuilder) => void): void; + + /** Undo one entry. Returns `false` (a no-op) at the start of history. */ + undo(): boolean; + /** Redo one entry. Returns `false` (a no-op) at the end of history. */ + redo(): boolean; + /** + * Move `n` steps (`n < 0` undo, `n > 0` redo), clamped at the ends. Returns + * the signed number of steps actually moved, so `jump(n) === n` means "fully + * moved". A throw mid-jump stops at the last successful step and rethrows. + */ + jump(n: number): number; + + readonly canUndo: boolean; + readonly canRedo: boolean; + readonly undoLabel?: string; + readonly redoLabel?: string; + /** + * Cursor index in `[0, length]`. `canUndo === position > 0` and + * `canRedo === position < length`. + */ + readonly position: number; + readonly length: number; + + /** Tag the current position with a name. Re-tagging an existing name moves it. */ + checkpoint(name: string): void; + hasCheckpoint(name: string): boolean; + /** Jump to a checkpoint. Throws if the name is unknown or was pruned. */ + revertTo(name: string): void; + + /** Drop all entries and checkpoints; reset the cursor to 0. */ + clear(): void; + /** + * Subscribe to history changes for UI binding; the listener reads its own + * application state. Returns an unsubscribe function. + */ + subscribe(listener: (meta: HistoryMeta) => void): () => void; +} + +export interface HistoryOptions { + /** + * Cap on retained entries. When exceeded, the oldest entries are evicted + * (observable through `length`/`canUndo`). Must be a positive integer. + */ + limit?: number; +} diff --git a/tests/checkpoint.test.ts b/tests/checkpoint.test.ts new file mode 100644 index 0000000..00e18ef --- /dev/null +++ b/tests/checkpoint.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "vite-plus/test"; +import { createHistory } from "../src/index.ts"; + +const noop = { do: () => {}, undo: () => {} }; + +describe("checkpoints", () => { + test("revertTo jumps to the marked position", () => { + const history = createHistory(); + const form: Record = {}; + const set = (key: string, value: string, prev: string) => ({ + do: () => { + form[key] = value; + }, + undo: () => { + form[key] = prev; + }, + }); + + history.commit(set("name", "Bob", "")); + history.checkpoint("saved"); + history.commit(set("email", "typo@", "")); + history.commit(set("email", "oops", "typo@")); + expect(form).toEqual({ name: "Bob", email: "oops" }); + + history.revertTo("saved"); + expect(form).toEqual({ name: "Bob", email: "" }); + expect(history.position).toBe(1); + }); + + test("revertTo lands exactly even after eviction renumbers indices", () => { + const history = createHistory({ limit: 4 }); + const log: string[] = []; + const op = (id: string) => ({ + do: () => log.push(`do:${id}`), + undo: () => log.push(`undo:${id}`), + }); + + history.commit(op("a")); + history.commit(op("b")); + history.checkpoint("cp"); // position 2, anchored to entry "b" + history.commit(op("c")); + history.commit(op("d")); + history.commit(op("e")); // length 5 > 4 -> evict "a"; cp renumbers 2 -> 1 + + expect(history.length).toBe(4); + expect(history.hasCheckpoint("cp")).toBe(true); + + log.length = 0; + history.revertTo("cp"); + expect(history.position).toBe(1); + expect(log).toEqual(["undo:e", "undo:d", "undo:c"]); + }); + + test("re-tagging a checkpoint name moves it", () => { + const history = createHistory(); + history.commit(noop); + history.checkpoint("cp"); // position 1 + history.commit(noop); + history.checkpoint("cp"); // re-tag at position 2 + + history.undo(); + history.undo(); + expect(history.position).toBe(0); + + history.revertTo("cp"); + expect(history.position).toBe(2); + }); + + test("clear drops all entries and checkpoints", () => { + const history = createHistory(); + history.commit(noop); + history.checkpoint("cp"); + + history.clear(); + expect(history.length).toBe(0); + expect(history.position).toBe(0); + expect(history.hasCheckpoint("cp")).toBe(false); + expect(() => history.revertTo("cp")).toThrow(); + }); + + test("a checkpoint whose anchor is evicted is pruned", () => { + const history = createHistory({ limit: 2 }); + history.commit(noop); + history.checkpoint("first"); // anchored to entry 0 + history.commit(noop); + history.commit(noop); // evicts entry 0 -> "first" pruned + + expect(history.hasCheckpoint("first")).toBe(false); + expect(() => history.revertTo("first")).toThrow(); + }); + + test("a checkpoint in a dropped redo branch is pruned", () => { + const history = createHistory(); + history.commit(noop); // e1 + history.commit(noop); // e2 + history.checkpoint("end"); // position 2, anchored to e2 + history.undo(); // position 1 + history.commit(noop); // drops e2 -> "end" pruned + + expect(history.hasCheckpoint("end")).toBe(false); + }); + + test("revertTo on an unknown name throws", () => { + const history = createHistory(); + expect(() => history.revertTo("nope")).toThrow(); + }); + + test("hasCheckpoint reflects existence", () => { + const history = createHistory(); + expect(history.hasCheckpoint("x")).toBe(false); + history.checkpoint("x"); + expect(history.hasCheckpoint("x")).toBe(true); + }); +}); diff --git a/tests/commit.test.ts b/tests/commit.test.ts new file mode 100644 index 0000000..ed79953 --- /dev/null +++ b/tests/commit.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vite-plus/test"; +import { createHistory } from "../src/index.ts"; + +describe("commit", () => { + test("commit then undo then redo returns the target to its post-commit value", () => { + const history = createHistory(); + let count = 0; + + history.commit({ + do: () => { + count += 1; + }, + undo: () => { + count -= 1; + }, + }); + expect(count).toBe(1); + + history.undo(); + expect(count).toBe(0); + + history.redo(); + expect(count).toBe(1); + }); + + test("carries labels for undoLabel / redoLabel", () => { + const history = createHistory(); + history.commit({ label: "increment", do: () => {}, undo: () => {} }); + + expect(history.undoLabel).toBe("increment"); + expect(history.redoLabel).toBeUndefined(); + + history.undo(); + expect(history.undoLabel).toBeUndefined(); + expect(history.redoLabel).toBe("increment"); + }); + + test("a failed commit records nothing and notifies no subscriber", () => { + const history = createHistory(); + let calls = 0; + history.subscribe(() => { + calls += 1; + }); + + expect(() => + history.commit({ + do: () => { + throw new Error("boom"); + }, + undo: () => {}, + }), + ).toThrow("boom"); + + expect(history.length).toBe(0); + expect(history.canUndo).toBe(false); + expect(calls).toBe(0); + }); + + test("committing after an undo clears the redo branch", () => { + const history = createHistory(); + const log: string[] = []; + const op = (id: string) => ({ + do: () => log.push(`do:${id}`), + undo: () => log.push(`undo:${id}`), + }); + + history.commit(op("a")); + history.commit(op("b")); + history.undo(); // undo b + expect(history.canRedo).toBe(true); + + history.commit(op("c")); // drops b's redo branch + expect(history.canRedo).toBe(false); + expect(history.length).toBe(2); // a, c + }); +}); diff --git a/tests/limit.test.ts b/tests/limit.test.ts new file mode 100644 index 0000000..1d0fdb3 --- /dev/null +++ b/tests/limit.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "vite-plus/test"; +import { createHistory } from "../src/index.ts"; + +const noop = { do: () => {}, undo: () => {} }; + +describe("limit", () => { + test("the oldest entries are evicted past the limit", () => { + const history = createHistory({ limit: 3 }); + for (let i = 0; i < 5; i += 1) history.commit(noop); + + expect(history.length).toBe(3); + + let undone = 0; + while (history.undo()) undone += 1; + expect(undone).toBe(3); + expect(history.canUndo).toBe(false); + }); + + test("position stays within [0, length] under eviction", () => { + const history = createHistory({ limit: 2 }); + for (let i = 0; i < 4; i += 1) history.commit(noop); + + expect(history.length).toBe(2); + expect(history.position).toBe(2); + expect(history.position).toBeLessThanOrEqual(history.length); + }); + + test("createHistory rejects a non-positive-integer limit", () => { + expect(() => createHistory({ limit: 0 })).toThrow(); + expect(() => createHistory({ limit: -1 })).toThrow(); + expect(() => createHistory({ limit: 1.5 })).toThrow(); + }); +}); diff --git a/tests/reentrancy.test.ts b/tests/reentrancy.test.ts new file mode 100644 index 0000000..a1b8edb --- /dev/null +++ b/tests/reentrancy.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "vite-plus/test"; +import { createHistory } from "../src/index.ts"; +import type { History } from "../src/index.ts"; + +describe("reentrancy & synchronous guards", () => { + test("every public mutation is rejected from inside a do", () => { + const reentrantCalls: Array<(h: History) => void> = [ + (h) => h.commit({ do: () => {}, undo: () => {} }), + (h) => h.undo(), + (h) => h.redo(), + (h) => h.jump(1), + (h) => h.transaction("x", () => {}), + (h) => h.clear(), + (h) => h.revertTo("cp"), + (h) => h.checkpoint("y"), + ]; + + for (const reentrant of reentrantCalls) { + const history = createHistory(); + history.checkpoint("cp"); // give revertTo a target + expect(() => + history.commit({ + do: () => reentrant(history), + undo: () => {}, + }), + ).toThrow(); + expect(history.length).toBe(0); // the failed commit recorded nothing + } + }); + + test("a public mutation called from inside an undo throws", () => { + const history = createHistory(); + let armed = false; + history.commit({ + do: () => {}, + undo: () => { + if (armed) history.commit({ do: () => {}, undo: () => {} }); + }, + }); + + armed = true; + expect(() => history.undo()).toThrow(); + expect(history.position).toBe(1); // cursor did not move + }); + + test("a do that returns a thenable throws instead of running detached", () => { + const history = createHistory(); + expect(() => history.commit({ do: () => Promise.resolve(), undo: () => {} })).toThrow(); + expect(history.length).toBe(0); + }); + + test("an undo that returns a thenable throws", () => { + const history = createHistory(); + history.commit({ do: () => {}, undo: () => Promise.resolve() }); + expect(() => history.undo()).toThrow(); + expect(history.position).toBe(1); // cursor did not move + }); + + test("a subscriber may safely call back into the engine", () => { + const history = createHistory(); + let reentered = false; + const off = history.subscribe(() => { + if (!reentered) { + reentered = true; + // After the operation settles, mutations from a listener are allowed. + history.clear(); + } + }); + + history.commit({ do: () => {}, undo: () => {} }); + off(); + expect(reentered).toBe(true); + expect(history.length).toBe(0); // the listener's clear() ran + }); +}); diff --git a/tests/subscribe.test.ts b/tests/subscribe.test.ts new file mode 100644 index 0000000..215e9bf --- /dev/null +++ b/tests/subscribe.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "vite-plus/test"; +import { createHistory } from "../src/index.ts"; +import type { HistoryMeta } from "../src/index.ts"; + +const noop = { do: () => {}, undo: () => {} }; + +describe("subscribe", () => { + test("fires on every change with correct meta", () => { + const history = createHistory(); + const metas: HistoryMeta[] = []; + history.subscribe((m) => metas.push(m)); + + history.commit({ label: "a", do: () => {}, undo: () => {} }); + expect(metas).toHaveLength(1); + expect(metas[0]).toMatchObject({ + canUndo: true, + canRedo: false, + undoLabel: "a", + position: 1, + length: 1, + }); + + history.undo(); + expect(metas).toHaveLength(2); + expect(metas[1]).toMatchObject({ + canUndo: false, + canRedo: true, + redoLabel: "a", + position: 0, + length: 1, + }); + }); + + test("a no-op does not fire the subscriber", () => { + const history = createHistory(); + let calls = 0; + history.subscribe(() => { + calls += 1; + }); + + expect(history.undo()).toBe(false); // boundary no-op + expect(() => + history.commit({ + do: () => { + throw new Error("x"); + }, + undo: () => {}, + }), + ).toThrow(); + history.clear(); // already empty -> no change + + expect(calls).toBe(0); + }); + + test("the returned unsubscribe stops notifications", () => { + const history = createHistory(); + let calls = 0; + const off = history.subscribe(() => { + calls += 1; + }); + + history.commit(noop); + expect(calls).toBe(1); + + off(); + history.commit(noop); + expect(calls).toBe(1); + }); + + test("the delivered meta is frozen", () => { + const history = createHistory(); + let received: HistoryMeta | undefined; + history.subscribe((m) => { + received = m; + }); + history.commit(noop); + + expect(Object.isFrozen(received)).toBe(true); + }); +}); diff --git a/tests/transaction.test.ts b/tests/transaction.test.ts new file mode 100644 index 0000000..8344d61 --- /dev/null +++ b/tests/transaction.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from "vite-plus/test"; +import { createHistory } from "../src/index.ts"; +import type { TransactionBuilder } from "../src/index.ts"; + +describe("transaction", () => { + test("one undo reverts an entire transaction, inverses in reverse order", () => { + const history = createHistory(); + const log: string[] = []; + + history.transaction("type abc", (tx) => { + for (const ch of "abc") { + tx.commit({ + do: () => log.push(`do:${ch}`), + undo: () => log.push(`undo:${ch}`), + }); + } + }); + expect(log).toEqual(["do:a", "do:b", "do:c"]); + expect(history.length).toBe(1); + + history.undo(); + expect(log).toEqual(["do:a", "do:b", "do:c", "undo:c", "undo:b", "undo:a"]); + expect(history.canUndo).toBe(false); + + log.length = 0; + history.redo(); + expect(log).toEqual(["do:a", "do:b", "do:c"]); + }); + + test("a throwing operation rolls back applied operations and discards the entry", () => { + const history = createHistory(); + const log: string[] = []; + + expect(() => + history.transaction("partial", (tx) => { + tx.commit({ + do: () => log.push("do:a"), + undo: () => log.push("undo:a"), + }); + tx.commit({ + do: () => log.push("do:b"), + undo: () => log.push("undo:b"), + }); + tx.commit({ + do: () => { + throw new Error("boom"); + }, + undo: () => {}, + }); + }), + ).toThrow("boom"); + + expect(log).toEqual(["do:a", "do:b", "undo:b", "undo:a"]); + expect(history.length).toBe(0); + expect(history.canUndo).toBe(false); + }); + + test("a throw in the build callback rolls back and discards the entry", () => { + const history = createHistory(); + const log: string[] = []; + + expect(() => + history.transaction("bug", (tx) => { + tx.commit({ + do: () => log.push("do:a"), + undo: () => log.push("undo:a"), + }); + throw new Error("user bug"); + }), + ).toThrow("user bug"); + + expect(log).toEqual(["do:a", "undo:a"]); + expect(history.length).toBe(0); + }); + + test("an empty transaction creates no entry and notifies no subscriber", () => { + const history = createHistory(); + let calls = 0; + history.subscribe(() => { + calls += 1; + }); + + history.transaction("nothing", () => {}); + expect(history.length).toBe(0); + expect(calls).toBe(0); + }); + + test("a nested transaction throws and the outer one rolls back", () => { + const history = createHistory(); + const log: string[] = []; + + expect(() => + history.transaction("outer", (tx) => { + tx.commit({ + do: () => log.push("do:a"), + undo: () => log.push("undo:a"), + }); + history.transaction("inner", () => {}); + }), + ).toThrow(); + + expect(log).toEqual(["do:a", "undo:a"]); + expect(history.length).toBe(0); + }); + + test("the builder is rejected once the transaction has finished", () => { + const history = createHistory(); + const log: string[] = []; + let escaped: TransactionBuilder | undefined; + + history.transaction("capture", (tx) => { + escaped = tx; + tx.commit({ + do: () => log.push("do:a"), + undo: () => log.push("undo:a"), + }); + }); + expect(history.length).toBe(1); + + // An escaped builder cannot mutate state or corrupt the recorded entry. + expect(() => + escaped?.commit({ + do: () => log.push("do:late"), + undo: () => {}, + }), + ).toThrow(); + expect(log).toEqual(["do:a"]); + expect(history.length).toBe(1); + }); + + test("a real async builder throws synchronously, rolls back, and does not crash the host", async () => { + const history = createHistory(); + const log: string[] = []; + let calls = 0; + history.subscribe(() => { + calls += 1; + }); + + let unhandled: unknown; + const onUnhandled = (reason: unknown): void => { + unhandled = reason; + }; + process.on("unhandledRejection", onUnhandled); + try { + // A genuine async builder: it records its synchronous prefix, suspends, then + // tries to commit again from its detached continuation. + expect(() => + history.transaction("async", async (tx) => { + tx.commit({ + do: () => log.push("do:a"), + undo: () => log.push("undo:a"), + }); + await Promise.resolve(); + tx.commit({ + do: () => log.push("do:b"), + undo: () => {}, + }); + }), + ).toThrow("synchronous"); + + // The synchronous prefix applied, then rolled back; nothing recorded or notified. + expect(log).toEqual(["do:a", "undo:a"]); + expect(history.length).toBe(0); + expect(calls).toBe(0); + + // Let the detached continuation run: its late commit is rejected, never applies, + // and its rejection is swallowed rather than taking the host process down. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(log).toEqual(["do:a", "undo:a"]); + expect(history.length).toBe(0); + expect(unhandled).toBeUndefined(); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + + test("a returned custom thenable is rejected without being assimilated", async () => { + const history = createHistory(); + let thenInvoked = false; + + // A lazy thenable, not a real promise. The engine must reject it as a sync + // violation without ever invoking its `then` — doing so would run the detached + // code the guard forbids. + const thenable: Record = {}; + // eslint-disable-next-line unicorn/no-thenable -- assigning `then` is the test's whole point + thenable.then = () => { + thenInvoked = true; + }; + + expect(() => history.transaction("thenable", () => thenable)).toThrow("synchronous"); + expect(history.length).toBe(0); + + // Give any accidental assimilation a microtask to fire; it must not. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(thenInvoked).toBe(false); + }); +}); diff --git a/tests/traverse.test.ts b/tests/traverse.test.ts new file mode 100644 index 0000000..2dd1d27 --- /dev/null +++ b/tests/traverse.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "vite-plus/test"; +import { createHistory } from "../src/index.ts"; + +const noop = { do: () => {}, undo: () => {} }; + +describe("undo / redo / jump", () => { + test("undo at the start and redo at the end are no-ops returning false", () => { + const history = createHistory(); + expect(history.undo()).toBe(false); + + history.commit(noop); + expect(history.redo()).toBe(false); + + history.undo(); + expect(history.undo()).toBe(false); + }); + + test("jump clamps at the ends and returns the signed steps moved", () => { + const history = createHistory(); + for (let i = 0; i < 3; i += 1) history.commit(noop); + + expect(history.jump(-5)).toBe(-3); // clamps to the start + expect(history.position).toBe(0); + + expect(history.jump(10)).toBe(3); // clamps to the end + expect(history.position).toBe(3); + + expect(history.jump(0)).toBe(0); + }); + + test("a throw mid-jump stops at the last successful step and rethrows", () => { + const history = createHistory(); + let value = 0; + + history.commit({ + do: () => { + value = 1; + }, + undo: () => { + value = 0; + }, + }); + history.commit({ + do: () => { + value = 2; + }, + undo: () => { + throw new Error("cannot undo step 2"); + }, + }); + history.commit({ + do: () => { + value = 3; + }, + undo: () => { + value = 2; + }, + }); + + // jump(-3): undo step 3 (value -> 2), then step 2's undo throws. + expect(() => history.jump(-3)).toThrow("cannot undo step 2"); + expect(history.position).toBe(2); + expect(value).toBe(2); + }); + + test("position tracks the cursor across commit/undo/redo/jump", () => { + const history = createHistory(); + expect(history.position).toBe(0); + + history.commit(noop); + history.commit(noop); + expect(history.position).toBe(2); + + history.undo(); + expect(history.position).toBe(1); + + history.jump(-1); + expect(history.position).toBe(0); + + history.jump(2); + expect(history.position).toBe(2); + expect(history.length).toBe(2); + }); + + test("interleaved commit/undo/redo never reorder operations", () => { + const history = createHistory(); + const log: string[] = []; + const op = (id: string) => ({ + do: () => log.push(`do:${id}`), + undo: () => log.push(`undo:${id}`), + }); + + history.commit(op("a")); + history.commit(op("b")); + history.undo(); // undo b + history.undo(); // undo a + history.redo(); // do a + history.commit(op("c")); // drops b + history.undo(); // undo c + history.redo(); // do c + + expect(log).toEqual(["do:a", "do:b", "undo:b", "undo:a", "do:a", "do:c", "undo:c", "do:c"]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ff4adab --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["es2023"], + "moduleDetection": "force", + "module": "nodenext", + "moduleResolution": "nodenext", + "resolveJsonModule": true, + "types": ["node"], + "strict": true, + "noUnusedLocals": true, + "declaration": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..bffbbc1 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + pack: { + dts: { + tsgo: true, + }, + exports: true, + }, + lint: { + options: { + typeAware: true, + typeCheck: true, + }, + }, + fmt: {}, +});