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

341 Commits

Author SHA1 Message Date
a996eb74b9 feat: update package.json exports to support new module formats and types 2026-03-08 08:19:01 +07:00
bcc9cb2915 feat(vue/primitives): implement pagination components with accessibility and testing 2026-03-08 04:18:10 +07:00
41d5e18f6b feat(monorepo): migrate vue packages and apply oxlint refactors 2026-03-07 18:07:22 +07:00
abd6605db3 feat(docs): add document generator 2026-02-15 16:49:37 +07:00
a83e2bb797 Merge pull request #128 from robonen/vue-0.0.13
Vue 0.0.13
2026-02-15 05:32:31 +07:00
9bece480ca test(web/vue): update event listener tests to use globalThis and improve assertions 2026-02-15 05:30:58 +07:00
c48de9a3d1 feat(web/vue): update version to 0.0.13 and add useTabLeader composable with tests 2026-02-15 05:29:08 +07:00
624e12ed96 Merge pull request #127 from robonen/stdlib-fix-reusing
refactor(core/stdlib): update state machine classes to use consistent property names and improve type safety
2026-02-15 03:28:09 +07:00
3380d90cee refactor(core/stdlib): update state machine classes to use consistent property names and improve type safety 2026-02-15 03:26:42 +07:00
bb644579ca Merge pull request #126 from robonen/shared-build-config
chore: update package versions and integrate shared tsdown configuration
2026-02-15 03:18:28 +07:00
e7d1021d27 chore: remove tsdown dependency from importers in pnpm-lock.yaml 2026-02-15 03:15:36 +07:00
1782184761 chore: update package versions and integrate shared tsdown configuration 2026-02-15 03:13:49 +07:00
70d96b7f39 Merge pull request #122 from robonen/renovate/node-24.x
chore(deps): update node.js to v24
2026-02-15 02:55:35 +07:00
renovate[bot]
9587c92e50 chore(deps): update node.js to v24 2026-02-14 19:53:12 +00:00
678c18a08d Merge pull request #125 from robonen/stdlib-updates
feat(core/stdlib): implement LinkedList, PriorityQueue, and Queue dat…
2026-02-15 02:52:11 +07:00
68afec40b7 chore(core/stdlib): fix lint 2026-02-15 02:50:54 +07:00
50b1498f3e fix(core/stdlib): rename dirs 2026-02-15 02:45:59 +07:00
7b5da22290 feat(core/stdlib): implement LinkedList, PriorityQueue, and Queue data structures 2026-02-15 02:36:41 +07:00
09fe8079c0 Merge pull request #124 from robonen/linter
feat(configs/oxlint): add linter
2026-02-14 22:53:14 +07:00
ab9f45f908 refactor(ci): separate build and lint steps in CI workflow 2026-02-14 22:52:00 +07:00
49b9f2aa79 feat(configs/oxlint): add linter 2026-02-14 22:49:47 +07:00
2a5412c3b8 Merge pull request #123 from robonen/vue-composable-categories
feat(web/vue): add useStorage and useStorageAsync, separate all composables by categories
2026-02-14 21:45:53 +07:00
5f9e0dc72d feat: add separate vitest configuration files for platform and stdlib environments 2026-02-14 21:44:54 +07:00
6565fa3de8 feat(web/vue): add useStorage and useStorageAsync, separate all composables by categories 2026-02-14 21:38:29 +07:00
7dce7ed482 Merge pull request #121 from robonen/tsdown
feat: update pnpm workspace and dependencies, migrate to tsdown for builds
2026-02-14 03:58:55 +07:00
df13f0b827 Merge branch 'master' into tsdown 2026-02-14 03:56:59 +07:00
3da393ed08 feat: update pnpm workspace and dependencies, migrate to tsdown for builds 2026-02-14 03:56:45 +07:00
efadb5fe28 feat: update pnpm workspace and dependencies, migrate to tsdown for builds 2026-02-14 03:49:10 +07:00
renovate[bot]
07e6d3eadc chore(deps): update all non-major dependencies (#119)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-12 01:36:35 +00:00
renovate[bot]
6fcc9d5a51 chore(deps): update all non-major dependencies (#116)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-31 01:44:27 +00:00
renovate[bot]
289d0d5af1 chore(deps): update all non-major dependencies (#115)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-17 01:52:33 +00:00
4bade839e7 Merge pull request #107 from robonen/renovate/renovate-42.x
chore(deps): update devdependency renovate to v42
2026-01-13 15:58:01 +03:00
renovate[bot]
c4321a2039 chore(deps): update devdependency renovate to v42 2026-01-13 12:56:13 +00:00
f6b3bfbca6 Merge pull request #109 from robonen/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6
2026-01-13 15:55:02 +03:00
renovate[bot]
7541e6aad4 chore(deps): update actions/checkout action to v6 2026-01-04 01:09:12 +00:00
renovate[bot]
a4d9b4c88a chore(deps): update all non-major dependencies (#114)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-04 01:08:54 +00:00
renovate[bot]
3b39f64734 chore(deps): update all non-major dependencies (#113)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-26 01:03:01 +00:00
renovate[bot]
6ab2d5cebf chore(deps): update all non-major dependencies (#112)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 01:02:35 +00:00
renovate[bot]
54f1facc4f chore(deps): update all non-major dependencies (#111)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-13 01:05:02 +00:00
renovate[bot]
717c41ef88 chore(deps): update all non-major dependencies (#110)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-28 01:51:25 +00:00
renovate[bot]
3747f5213e chore(deps): update all non-major dependencies (#108)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-16 01:48:53 +00:00
daf18871a0 Merge pull request #106 from robonen/renovate/node-24.x
chore(deps): update node.js to v24
2025-11-05 19:09:26 +03:00
renovate[bot]
8bf9943e9e chore(deps): update node.js to v24 2025-11-03 06:47:48 +00:00
renovate[bot]
0e67715d9e chore(deps): update all non-major dependencies (#105)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 01:29:17 +00:00
renovate[bot]
3e43e4db3d chore(deps): update all non-major dependencies (#103)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 01:44:03 +00:00
b8308e383c Merge pull request #99 from robonen/renovate/jsdom-27.x
chore(deps): update pnpm.catalog.default jsdom to v27
2025-10-23 09:58:09 +03:00
renovate[bot]
93c878cc35 chore(deps): update pnpm.catalog.default jsdom to v27 2025-10-23 06:56:36 +00:00
7653975fa4 Merge pull request #102 from robonen/renovate/actions-setup-node-6.x
chore(deps): update actions/setup-node action to v6
2025-10-23 09:52:09 +03:00
renovate[bot]
e2cb3f5a75 chore(deps): update actions/setup-node action to v6 2025-10-14 05:25:23 +00:00
renovate[bot]
67fbad8930 chore(deps): update all non-major dependencies (#101)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 01:41:58 +00:00
renovate[bot]
e49c49e320 chore(deps): update all non-major dependencies (#100)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-26 01:45:40 +00:00
renovate[bot]
43cdc3b5e6 chore(deps): update all non-major dependencies (#98)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 01:40:15 +00:00
a9a6c04176 Merge pull request #97 from robonen/renovate/actions-setup-node-5.x
chore(deps): update actions/setup-node action to v5
2025-09-06 15:37:33 +03:00
a6d3e8971f Merge branch 'master' into renovate/actions-setup-node-5.x 2025-09-06 15:36:20 +03:00
40dfdabd08 Merge pull request #96 from robonen/chore/deps
chore: update deps
2025-09-06 15:36:02 +03:00
renovate[bot]
876a815fd3 chore(deps): update actions/setup-node action to v5 2025-09-06 12:35:00 +00:00
b1b9889ad2 chore: update deps 2025-09-06 19:34:30 +07:00
renovate[bot]
9d2a393372 chore(deps): update all non-major dependencies (#95)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-22 01:07:02 +00:00
renovate[bot]
4071e49ad6 chore(deps): update all non-major dependencies (#92)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-16 01:13:12 +00:00
88bd87f9b0 Merge pull request #93 from robonen/renovate/actions-checkout-5.x
chore(deps): update actions/checkout action to v5
2025-08-15 00:45:05 +03:00
renovate[bot]
ac265c05a8 chore(deps): update actions/checkout action to v5 2025-08-14 21:44:00 +00:00
69e5ebc085 Merge pull request #94 from robonen/feat/vue/unrefElement
feat(web/vue): unrefElement
2025-08-15 00:41:59 +03:00
48a85dbae2 build(web/vue): bump version to 0.0.11 2025-08-15 04:39:37 +07:00
0cfdce7456 docs(web/vue): update examples in unrefElement documentation to include type parameters 2025-08-15 04:38:46 +07:00
e035d1abca docs(web/vue): update documentation for unrefElement function 2025-08-15 04:37:58 +07:00
1851d5c80c feat(web/vue): add unrefElement function and tests for element handling 2025-08-15 04:33:47 +07:00
48626a9fe5 Merge pull request #91 from robonen/refactor/web/vue
refactor(web/vue): reuse injection context composable in injection store
2025-08-07 05:28:14 +07:00
04aa9e4721 build(web/vue): bump version to 0.0.10 2025-08-07 05:26:02 +07:00
d55e3989f3 refactor(web/vue): reuse inject context composable in context state 2025-08-07 05:25:04 +07:00
renovate[bot]
acee7e4167 chore(deps): update all non-major dependencies (#90)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-02 01:50:33 +00:00
renovate[bot]
a633bd8da0 chore(deps): update all non-major dependencies (#88)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-27 01:09:23 +00:00
e194ba3883 Merge pull request #81 from robonen/renovate/renovate-41.x
chore(deps): update devdependency renovate to v41
2025-07-19 02:13:10 +07:00
renovate[bot]
d7c978bf9e chore(deps): update devdependency renovate to v41 2025-07-18 07:53:11 +00:00
renovate[bot]
5674095073 chore(deps): update all non-major dependencies (#84)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-18 01:10:46 +00:00
77bab6055c Merge pull request #87 from robonen/fix/web/vue/missing-export
fix(web/vue): missing useAsyncState export
2025-07-14 02:19:49 +07:00
7fcafae467 chore(web/vue): bump version to 0.0.9 2025-07-14 02:17:46 +07:00
52a5add405 fix(web/vue): add missing export useAsyncState from composables 2025-07-14 02:17:31 +07:00
bd5fdab6a0 Merge pull request #86 from robonen/chore/web/vue/0.0.8
chore(web/vue): bump version to 0.0.8
2025-07-14 01:01:39 +07:00
e8d7cccfe0 chore(web/vue): bump version to 0.0.8 2025-07-14 01:00:25 +07:00
be13ec7079 Merge pull request #85 from robonen/web/vue/useAsyncState
feat(web/vue): add useAsyncState
2025-07-14 00:51:53 +07:00
55438b63f9 test(web/vue): update useAsyncState to allow optional delay parameter and add tests 2025-07-14 00:42:50 +07:00
1e9859da83 feat(web/vue): enhance async state management for useAsyncState with improved error handling and loading states 2025-07-10 05:34:47 +07:00
renovate[bot]
aa8a0f00f3 chore(deps): update all non-major dependencies (#83)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 01:29:06 +00:00
renovate[bot]
e1e879ebbb chore(deps): update all non-major dependencies (#82)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 01:09:26 +00:00
renovate[bot]
6339b21f56 chore(deps): update all non-major dependencies (#80)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-17 01:11:50 +00:00
renovate[bot]
1d4f5c5512 chore(deps): update all non-major dependencies (#78)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 01:00:27 +00:00
2cc0efd556 Merge pull request #79 from robonen/feat/tsconfig-ts-imports
feat(configs/tsconfig): enable importing TypeScript extensions in tsc…
2025-05-27 16:10:51 +07:00
bef0aea14c build(configs/tsconfig): bump version to 0.0.2 2025-05-27 16:09:56 +07:00
40d1d6962b feat(configs/tsconfig): enable importing TypeScript extensions in tsconfig 2025-05-27 16:07:59 +07:00
renovate[bot]
eb8514fe89 chore(deps): update all non-major dependencies (#73)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-24 01:49:10 +00:00
b6200aa7a3 Merge pull request #77 from robonen/refactor/template
Refactor template
2025-05-20 19:42:49 +07:00
a67322ca66 test: temporary disable type checking in test configuration 2025-05-20 19:26:58 +07:00
8297e47086 Merge branch 'master' into refactor/template 2025-05-20 19:25:22 +07:00
95e1bcd0c4 feat: update vitest configuration and dependencies to version 3.2.0-beta.2 2025-05-20 19:20:38 +07:00
6d68246d16 refactor(core/stdlib): update test descriptions and improve placeholder handling 2025-05-20 19:20:26 +07:00
049b5b351a feat(core/stdlib): implement get function and remove getByPath 2025-05-20 19:20:03 +07:00
890d984aad feat(core/stdlib): add type definitions and tests for collections and union types 2025-05-20 19:19:41 +07:00
9d01b12160 Merge pull request #76 from robonen/fix/renovate
fix(infra/renovate): update renovate path
2025-05-20 14:34:08 +07:00
6f2311afeb fix(infra/renovate): update renovate configuration to extend correct default settings 2025-05-20 14:32:28 +07:00
f7312b1060 Merge pull request #74 from robonen/refactor/dir-struct
Refactor directory structure
2025-05-19 18:08:01 +07:00
3d15f7b3b2 chore: remove CHANGELOG.md file 2025-05-19 18:04:25 +07:00
32bf20899f chore: remove shebang from cli.ts 2025-05-19 17:52:26 +07:00
8355477e0e chore: remove unused cover image 2025-05-19 17:51:39 +07:00
968cf26fd0 refactor: simplify version check logic in publish workflow 2025-05-19 17:49:57 +07:00
78fb4da82a refactor: change separate tools by category 2025-05-19 17:43:42 +07:00
d55737df2f chore: dedupe deps 2025-05-19 04:46:08 +07:00
39ce28a5ef Merge pull request #70 from robonen/renovate/all-minor-patch
chore(deps): update all non-major dependencies
2025-05-19 04:44:01 +07:00
renovate[bot]
3d813d22b9 chore(deps): update all non-major dependencies 2025-05-18 21:43:01 +00:00
4f558270ce Merge pull request #72 from robonen/chore/config
Chore/vitest-config
2025-05-19 04:40:53 +07:00
4d6922e06a fix: update CI and publish workflows to use correct build and test commands 2025-05-19 04:36:39 +07:00
fa726eecc4 chore: add workspace vitest configuration for testing with jsdom and coverage 2025-05-19 04:34:13 +07:00
c5f34efe05 chore: remove obsolete documentation and configuration files 2025-05-19 03:34:50 +07:00
ead9c019cd Merge pull request #71 from robonen/refactor/update-cli
feat(cli): auto resolve latest packages for cli
2025-05-18 00:04:23 +07:00
8ee6970674 chore: update package manager version and remove unused dependencies 2025-05-18 00:03:19 +07:00
27c80d24ef feat(cli): update CLI tool for project creation with package.json and config generation 2025-05-17 23:55:52 +07:00
c596e8aa29 build: bump stdlib 0.0.7 2025-05-11 15:41:48 +07:00
f8b37cacd3 fix(packages/stdlib): fix pubsub types 2025-05-11 15:36:26 +07:00
40d8194134 ci: add private packages checking in publish action 2025-05-11 15:10:46 +07:00
renovate[bot]
11d1ac232e chore(deps): update all non-major dependencies (#69)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-11 01:32:48 +00:00
7c1d801c8e build: fix publish script, bump stdlib 0.0.6 and vue 0.0.7 2025-05-09 22:31:02 +07:00
de391fa80d build: bump stdlib 0.0.5 and vue 0.0.6 2025-05-09 14:22:13 +07:00
8ab58078ba build: revert stdlib and vue versions 2025-05-09 14:11:18 +07:00
88f6cec9b2 ci: add registry-url 2025-05-09 13:38:29 +07:00
09e72d904c build: bump stdlib 0.0.5 and vue 0.0.6 2025-05-09 13:32:45 +07:00
695647470b ci: fix branch for publish workflow 2025-05-09 13:22:32 +07:00
b2beb6a5fc Merge pull request #63 from robonen/feat/sync-mutex
fix(packages/stdlib): add SyncMutex primitive
2025-05-09 13:19:14 +07:00
c7048be9fb chore(packages/vue): rename startTime to renderStartTime in useRenderInfo 2025-05-09 13:18:12 +07:00
4ead7fb18c chore: add npm-publish gh action 2025-05-09 13:12:37 +07:00
3994f349f4 feat(packages/stdlib): add execute method for SyncMutex 2025-05-09 13:12:07 +07:00
8d6f08c332 fix(packages/vue): set render duration ref only after mounted and updated 2025-05-09 13:11:20 +07:00
3a2837c1a1 fix(packages/vue): revert to old version useRenderCount 2025-05-09 13:09:03 +07:00
renovate[bot]
82a0c0f746 chore(deps): update all non-major dependencies (#68)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-08 01:55:42 +00:00
renovate[bot]
e8667d6a0a chore(deps): update devdependency renovate to ^40.1.3 (#67)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-04 01:52:46 +00:00
6ed7d39a11 Merge pull request #65 from robonen/renovate/all-minor-patch
chore(deps): update all non-major dependencies
2025-05-01 07:58:37 +07:00
renovate[bot]
74c170e853 chore(deps): update all non-major dependencies 2025-05-01 00:57:28 +00:00
fa96b9ddee Merge pull request #57 from robonen/renovate/pnpm-10.x
chore(deps): update pnpm to v10
2025-05-01 07:56:47 +07:00
ff4a88b896 Merge pull request #66 from robonen/renovate/renovate-40.x
chore(deps): update devdependency renovate to v40
2025-05-01 07:56:31 +07:00
renovate[bot]
871e0cfad2 chore(deps): update devdependency renovate to v40 2025-04-30 23:32:34 +00:00
renovate[bot]
849d444172 chore(deps): update pnpm to v10 2025-04-28 02:03:30 +00:00
renovate[bot]
cea221ed57 chore(deps): update all non-major dependencies (#64)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-05 01:26:48 +00:00
renovate[bot]
49dacf071f chore(deps): update all non-major dependencies (#61)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-23 01:13:25 +00:00
a07ac35db9 fix(packages/vue): add mutex for useRenderCount to avoid infinite rerender 2025-02-23 00:44:58 +07:00
8c5252986e feat(packages/stdlib): add SyncMutex 2025-02-23 00:44:14 +07:00
fad1284cd3 Merge pull request #62 from robonen/feat/injection-store
feat(packages/vue): useInjectionStore
2025-02-22 23:40:54 +07:00
ca0a63ea38 build(packages/vue): bump v0.0.5 2025-02-22 23:39:50 +07:00
7bfbb8e52a chore: update deps 2025-02-22 23:39:31 +07:00
30b72fb2f0 refactor(packages/vue): use another way to provide state at app level in useContextFactory 2025-02-22 23:35:20 +07:00
5594cef31e feat(packages/vue): add useInjectionStore 2025-02-22 23:30:45 +07:00
caa7c4221a Merge pull request #60 from robonen/chore/drop-apps
chore(apps): drop apps workspace completely
2025-02-09 05:20:32 +07:00
6ae3c939d8 Merge branch 'master' into chore/drop-apps
# Conflicts:
#	apps/vhs/package.json
#	pnpm-workspace.yaml
2025-02-09 05:19:10 +07:00
1bada217e9 chore(apps): drop apps workspace completely 2025-02-09 05:13:18 +07:00
renovate[bot]
c813bd174c chore(deps): update all non-major dependencies (#58)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-07 01:13:10 +00:00
renovate[bot]
a2f49b6286 chore(deps): update pnpm.catalog.default vitest to v3.0.5 [security] (#59)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-05 01:04:57 +00:00
renovate[bot]
987b8d4abd chore(deps): update all non-major dependencies (#56)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-02 01:51:20 +00:00
c68436a36a feat(packages/stdlib): bump 0.0.4 2025-01-23 04:03:46 +07:00
renovate[bot]
cca2e2e798 chore(deps): update node.js to >=22.13.0 (#55)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-17 23:58:49 +07:00
2936c5a8d6 chore(deps): update all deps, bump vitest major version 2025-01-17 23:57:05 +07:00
renovate[bot]
d6bc42d568 chore(deps): update all non-major dependencies (#54)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-17 01:18:51 +00:00
renovate[bot]
552b6afc54 chore(deps): update all non-major dependencies (#52)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-31 01:46:19 +00:00
renovate[bot]
2eb4665f4f chore(deps): update devdependency renovate to v39 (#47)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-27 17:12:35 +07:00
renovate[bot]
4d5c05538a chore(deps): update pnpm to v9.15.1 (#51)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-25 01:30:15 +00:00
renovate[bot]
45bec99eb6 chore(deps): update all non-major dependencies (#50)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-19 01:47:40 +00:00
90dbdf4c88 feat(packages/stdlib): add promise types 2024-11-30 04:24:49 +07:00
41e3d90e41 refactor(packages/stdlib): fix new lines for types 2024-11-30 04:24:27 +07:00
e93b1ccb68 refactor(packages/stdlib): add test case for BitVector 2024-11-29 05:42:50 +07:00
f88a466262 build(packages/vue): bump 0.0.4 2024-11-26 16:14:50 +07:00
46ea487222 refactor(packages/vue): return object instead of tuple for useContextFactory 2024-11-26 16:10:59 +07:00
1823771b4a refactor(packages/vue): add app.provide() support for useContextFactory 2024-11-26 15:56:25 +07:00
3e2b88d871 Merge remote-tracking branch 'origin/master' 2024-11-26 15:07:17 +07:00
renovate[bot]
bb90892af9 chore(deps): update all non-major dependencies (#49)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-24 01:36:03 +00:00
4b91a425f7 build(packages/vue): bump 0.0.3 2024-11-23 13:36:01 +07:00
f49e85286c build(packages/vue): bump 0.0.2 2024-11-23 13:30:57 +07:00
1b1a34c63b feat(packages/ui): export useFocusGuard 2024-11-23 13:24:19 +07:00
a89723126e build(packages/vue): enable deps inlining 2024-11-23 13:21:09 +07:00
046ebbd172 chore(packages/vue): move workspace deps in dev deps 2024-11-23 13:16:59 +07:00
e4905ef87e feat(packages/vue): add focusGuard composable 2024-11-22 16:49:59 +07:00
50257463b7 feat(packages/platform): add focusGuard brwoser util 2024-11-22 16:49:24 +07:00
979fd6e6df build(packages/platform): setup test suit, add build config 2024-11-22 16:48:47 +07:00
bdc8fab071 ci(repo): update ci pipeline (#48)
* ci(repo): update ci pipeline

* ci(repo): remove pnpm version from ci

* ci(repo): make build and test single command

* ci(repo): remove matrix tests
2024-11-05 02:18:27 +07:00
0c87de4573 chore(packages/renovate): cs 2024-11-03 00:56:46 +07:00
f87ee85c0b fix(packages/renovate): fix php matchUpdateTypes syntax 2024-11-03 00:47:05 +07:00
9f2df92371 feat(pacakges/renovate): add php path versions ignoring 2024-11-03 00:39:35 +07:00
renovate[bot]
f385d497f4 chore(deps): update all non-major dependencies 2024-11-02 01:16:45 +00:00
276ba9736f build(release): bump version to 0.0.3 2024-10-31 00:54:43 +07:00
9ccde9b040 Merge pull request #46 from robonen/object-utils
Object utils
2024-10-31 00:51:43 +07:00
dd0f481e19 chore(packages/stdlib): fix code style, stack pop and peek jsdoc 2024-10-31 00:50:08 +07:00
6408c1b328 feat(packages/stdlib): add object tools exports 2024-10-31 00:48:55 +07:00
23541a5476 feat(packages/stdlib): add pick object tool 2024-10-31 00:48:24 +07:00
3daa47dc83 feat(packages/stdlib): add omit object tool 2024-10-31 00:48:01 +07:00
a1747ea535 Merge pull request #45 from robonen/renovate/node-22.x
chore(deps): update node.js to v22
2024-10-30 23:25:14 +07:00
renovate[bot]
cbffd80555 chore(deps): update node.js to v22 2024-10-30 16:23:51 +00:00
58cb287f93 chore(deps): update all deps 2024-10-30 23:22:36 +07:00
renovate[bot]
17a7cbb936 chore(deps): update all non-major dependencies 2024-10-26 01:35:31 +00:00
96749c8510 Merge pull request #43 from robonen/bits
Add bit vector and refactor stack struct
2024-10-26 06:49:48 +07:00
3cc500f22b chore(packages/stdlib): remove unused import 2024-10-26 06:47:59 +07:00
fc774bc1af refactor(packages/stdlib): add small margin to the expected value 2024-10-26 06:45:53 +07:00
c96213137e refactor(packages/stdlib): stack return undefined on peek if it empty 2024-10-26 06:37:27 +07:00
8080e7eafe feat(packages/stdlib): add bit vector tool 2024-10-26 06:22:42 +07:00
53c969370a refactor(packages/stdlib): separate flags and helpers in bit tools 2024-10-26 06:20:43 +07:00
22fc55ce01 refactor(packages/stdlib): replace type by any function helper 2024-10-24 07:42:30 +07:00
9b5eef04c7 feat(packages/stdlib): add sleep async util 2024-10-24 07:30:41 +07:00
5bc3dd5ee0 feat(packages/stdlib): add tryIt async util 2024-10-24 07:29:55 +07:00
29d8aa086c refactor(packages/stdlib): update dir names and imports 2024-10-24 07:29:37 +07:00
85eb28a5dc feat(packages/stdlib): add sum arrays util 2024-10-24 07:28:24 +07:00
759d418d88 feat(packages/stdlib): add last arrays util 2024-10-24 07:28:15 +07:00
ff71cfffac feat(packages/stdlib): add first arrays util 2024-10-24 07:28:01 +07:00
5419b0a479 feat(packages/stdlib): add cluster arrays util 2024-10-24 07:27:49 +07:00
0faafc1b52 feat(packages/stdlib): add unique arrays util 2024-10-24 07:27:37 +07:00
126bb7fa9d Merge pull request #41 from robonen/vue-tools
Vue tools
2024-10-23 08:02:23 +07:00
7c0dff595b chore(packages/platform): remove test scripts 2024-10-23 07:59:53 +07:00
c350c977d5 chore(packages/vue): update jsdoc, fill package.json 2024-10-23 07:48:40 +07:00
b6d5b5b92c chore(packages/stdlib): update license and bump version 0.0.2 2024-10-23 07:47:55 +07:00
dc5e45acda chore(packages): update jsr config 2024-10-23 07:46:45 +07:00
9e7d7d8fdb chore(packages/platform): update license, bump 0.0.2 version 2024-10-23 07:30:57 +07:00
ed76a867e6 chore(packages/platform): prepare for 0.0.1 release 2024-10-23 07:16:46 +07:00
95bfa4f0f1 chore(deps): bump unbuild to 3.0.0-rc.11 2024-10-23 07:16:01 +07:00
cc439019e9 refactor(packages/vue): clean test names for UseLastChanged, add new imports 2024-10-23 06:51:05 +07:00
5722494458 feat(packages/vue): init useEventListener composable 2024-10-23 06:49:57 +07:00
a5ba8ab13e feat(packages/vue): add useOffsetPagination composable 2024-10-23 06:48:47 +07:00
ae6154c4b6 feat(packages/vue): add useRenderInfo composable 2024-10-23 06:47:49 +07:00
0667f15f0c refactor(packages/vue): add counter on mounted for useRenderCount 2024-10-23 06:46:19 +07:00
8989701303 feat(packages/vue): add useContextFactory composable 2024-10-23 06:44:19 +07:00
aff1a95c2f feat(packages/vue): add useClamp composable 2024-10-23 06:43:29 +07:00
19d7a2ca76 refactor(packages/vue): fix import for useMounted 2024-10-23 06:42:53 +07:00
6814b16d4d feat(packages/vue): add useAppSharedState composable 2024-10-23 06:41:02 +07:00
7b4f2d0c0a feat(packages/vue): add tryOnScopeDispose composable 2024-10-23 06:40:32 +07:00
9cc8f08d43 feat(packages/vue): add tryOnMounted composable 2024-10-23 06:38:19 +07:00
29bbc6aa9c feat(packages/vue): add tryOnBeforeMount composable 2024-10-23 06:33:06 +07:00
b2b74d8e2d feat(packages/stdlib): add function and array types 2024-10-23 06:29:05 +07:00
679bced9f1 refactor(pacakages/stdlib): add base jsdoc for each function 2024-10-23 06:27:14 +07:00
eadf791942 feat(packages/stdlib): init async utils 2024-10-23 06:24:50 +07:00
d415e61ac0 feat(packages/platform): add global isClient util and build config 2024-10-23 06:21:12 +07:00
4e798acfdd chore(deps): update all deps and other packages stuff 2024-10-23 06:17:54 +07:00
6a89239a75 test(packages/vue): add test for tryOnMounted 2024-10-06 21:07:29 +07:00
4bbc3b45a2 feat(packages/vue): add build config 2024-10-06 07:31:29 +07:00
d48e6469a3 refactor(packages/vue): some fixes, add resumable type 2024-10-06 06:47:53 +07:00
2e5e477097 feat(packages/vue): add useAppSharedState composable 2024-10-06 06:47:27 +07:00
658d180a8a feat(packages/vue): add useLastChanged composable 2024-10-06 06:46:57 +07:00
e41c78cd1d feat(packages/stdlib): add AnyFunction type 2024-10-06 06:46:13 +07:00
e84187fa02 feat(packages/vue): add useContexFactory composable 2024-10-05 22:02:45 +07:00
b525d08363 feat(packages/vue): add custom error 2024-10-05 22:02:03 +07:00
2ff7196241 feat(packages/vue): add useSupported composable 2024-10-05 17:56:58 +07:00
5a91cd264f feat(packages/vue): add useMounted composable 2024-10-05 17:56:22 +07:00
11c099ab4a refactor(packages/stdlib): add symbol in EventsRecord 2024-10-05 05:44:35 +07:00
00fd5846aa feat(packages/stdlib): add timestamp and noop utils 2024-10-05 05:43:13 +07:00
8822325299 refactor(packages/vue): make getter param possible for useCached and useCounter 2024-10-05 05:00:27 +07:00
85fd7e34e0 chore(deps): update catalog packages 2024-10-05 04:20:09 +07:00
renovate[bot]
fcdc4e251f chore(deps): update all non-major dependencies 2024-10-05 04:16:19 +07:00
0d7b1de1b2 fix(app): change project in readme bage 2024-10-03 18:28:26 +07:00
9012929d86 chore(app): update readme 2024-10-03 18:20:58 +07:00
4fd4008caa chore(repo): replace image 2024-10-02 06:53:00 +07:00
9978c09cec chore(repo): update readme 2024-10-02 06:26:04 +07:00
a8292fa59c chore(repo): test readme 2024-10-02 06:13:34 +07:00
80db132e49 Merge pull request #39 from robonen/pubsub-edge-case-test
Pubsub edge case test
2024-10-01 07:14:08 +07:00
f8a684e91a chore(repo): add apps workspace 2024-10-01 07:13:34 +07:00
61c699381b test(packages/stdlib): add pubsub edge case check 2024-10-01 07:13:09 +07:00
307ec29787 Merge pull request #38 from robonen/platform
Platform
2024-09-30 06:57:10 +07:00
11734b96b8 chore(repo): add build all script, update ci tests 2024-09-30 06:55:47 +07:00
f32deb3cc6 chore(repo): add pnpm catalogs 2024-09-30 06:48:30 +07:00
174e1b02d8 Merge branch 'stdlib-types' into platform
# Conflicts:
#	packages/stdlib/package.json
#	packages/stdlib/src/math/basic/remap/index.ts
2024-09-30 06:30:24 +07:00
dba020efab fix(packages/platform): remove test script 2024-09-30 06:26:22 +07:00
067f4d370f chore(deps): update pnpm lock 2024-09-30 06:24:24 +07:00
469ef8cdc2 feat(packages): add version jsdoc tags 2024-09-30 06:20:45 +07:00
975ca98f9a feat(packages/stdlib): add bigint math utils 2024-09-30 06:20:09 +07:00
061d45f6fd refactor(packages/stdlib): separate math utils штещ basic and bigint versions 2024-09-30 06:19:16 +07:00
db7e35d152 Merge branch 'master' into platform
# Conflicts:
#	package.json
#	packages/renovate/package.json
#	packages/stdlib/package.json
#	packages/tsconfig/package.json
#	pnpm-lock.yaml
2024-09-29 21:21:17 +07:00
9805b30c75 Vhs (#37)
* chore(repo): update deps in cli

* refactor(packages/tsconfig): change target to ESNext

* chore(deps): update all deps

* feat(apps/vhs): add vhs app

* chore(deps): sync with master
2024-09-28 02:09:29 +07:00
65312d007e chore(deps): update all deps 2024-09-28 02:03:55 +07:00
renovate[bot]
746eae3a8e chore(deps): update all non-major dependencies to ^2.1.1 2024-09-19 01:49:07 +00:00
renovate[bot]
cf80ea4edd chore(deps): update all non-major dependencies 2024-09-13 01:44:40 +00:00
renovate[bot]
7ae3178277 chore(deps): update devdependency @types/node to ^20.16.5 2024-09-07 01:51:07 +00:00
renovate[bot]
1fabe0cf6a chore(deps): update all non-major dependencies 2024-08-31 01:53:31 +00:00
renovate[bot]
dd2cb68fc1 chore(deps): update all non-major dependencies 2024-08-15 01:29:36 +00:00
renovate[bot]
f848961a03 chore(deps): update all non-major dependencies 2024-08-06 01:17:17 +00:00
renovate[bot]
1a74a0eca4 chore(deps): update all non-major dependencies 2024-07-25 01:34:59 +00:00
renovate[bot]
7f32e50106 chore(deps): update all non-major dependencies (#27)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-07-20 05:43:55 +07:00
renovate[bot]
b3eacd8d99 chore(deps): update vitest monorepo to v2 (#26)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-07-20 05:42:12 +07:00
e243d55428 feat(packages/platform): init new package 2024-07-20 05:38:57 +07:00
renovate[bot]
e4b6ec6384 chore(deps): update all non-major dependencies 2024-07-09 01:25:02 +00:00
renovate[bot]
d3f00e0c20 chore(deps): update devdependency renovate to ^37.422.4 2024-07-04 01:43:29 +00:00
renovate[bot]
80378c46a1 chore(deps): update all non-major dependencies 2024-07-02 01:14:01 +00:00
renovate[bot]
b5dc1047af chore(deps): update all non-major dependencies 2024-06-21 01:30:16 +00:00
renovate[bot]
8201dd7331 chore(deps): update all non-major dependencies 2024-06-06 01:12:56 +00:00
renovate[bot]
b19ed7e60d chore(deps): update all non-major dependencies 2024-06-02 01:39:06 +00:00
4d52909804 feat(packages/vue): add useToggle composable 2024-05-31 01:19:56 +07:00
4ce8babde2 chore(docs): update vitepress configuration 2024-05-31 01:19:41 +07:00
220239400a chore(packages/vue): add @vue/test-utils as a devDependency 2024-05-31 01:18:55 +07:00
5566bdcf80 feat(packages/vue): add useCached composable 2024-05-31 01:18:10 +07:00
93065d46ca feat(packages/vue): update useCounter composable to include additional functionality 2024-05-31 01:17:35 +07:00
d9973af2ed feat(packages/vue): add useRenderCount composable 2024-05-31 01:16:33 +07:00
107e192b33 feat(packages/vue): add useSyncRefs composable 2024-05-31 01:15:30 +07:00
34c72146e2 chore(repo): update pnpm and node versions in cli tool 2024-05-30 04:17:57 +07:00
d7c32f2f45 feat(packages/vue): add useCounter composable 2024-05-30 04:16:45 +07:00
03245921da feat(packages/vue): new package with initial configuration files 2024-05-30 04:16:15 +07:00
c007a54522 chore(repo): update pnpm 2024-05-30 02:57:34 +07:00
d0c74be856 refactor(packages/stdlib): change getByPath type to string and add comments to template types 2024-05-30 02:51:08 +07:00
ba68e293b9 chore(packages/stdlib): update pnpm 2024-05-30 02:48:50 +07:00
925af11be4 Merge branch 'master' into stdlib-types 2024-05-30 02:44:37 +07:00
2a931bbeb9 chore(packages/tsconfig): update removeComments option to false 2024-05-28 00:24:13 +07:00
renovate[bot]
fe3311bdfb chore(deps): update all non-major dependencies (#17)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-27 14:05:59 +07:00
2b5d81420a chore(repo): updated the pnpm version to 9.1.0 and the Vite version to 5.2.11 in the CLI file. 2024-05-08 05:30:32 +07:00
renovate[bot]
a6d40a4482 chore(deps): update all non-major dependencies (#16)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-08 04:31:05 +07:00
dc48cbc44b feat(packages/stdlib): start getByPath and template tools 2024-05-04 06:59:35 +07:00
6931fc6f18 chore(packages/stdlib): add ts and js utility types 2024-05-04 06:57:46 +07:00
7091352be2 chore(packages/stdlib): update import statement to use single quotes in mapRange function, lowercase in test desciptions 2024-05-04 06:53:46 +07:00
30654d2fe6 Docs (#15)
* feat(docs): update Vitepress config to include @robonen/renovate and @robonen/stdlib packages

* chore(packages/renovate): mark package.json private to true
2024-05-01 05:41:34 +07:00
8a33f6945c chore(packages/renovate): mark package.json private to true 2024-05-01 01:23:07 +07:00
aa10ed0f13 feat(docs): update Vitepress config to include @robonen/renovate and @robonen/stdlib packages 2024-05-01 01:22:05 +07:00
renovate[bot]
a9975bd06b chore(deps): update all non-major dependencies (#14)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-30 22:47:03 +07:00
renovate[bot]
8d64b012a1 chore(deps): update all non-major dependencies (#13)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-28 17:02:43 +07:00
cafe245732 feat(packages/renovate): auto approve 2024-04-28 17:02:10 +07:00
5105c2374c chore(repo): update pnpm version to 9.0.6 and Node version to >=20.12.2 2024-04-27 06:34:53 +07:00
4b960a3ad3 chore(packages/stdlib): update description 2024-04-26 02:03:13 +07:00
a5f62c16a3 feat(packages/renovate): add @robonen/renovate package (#12) 2024-04-26 04:39:23 +07:00
26b2392f9b ops(repo): add CI workflow for running tests on pull requests (#11)
* ops(repo): add CI workflow for running tests on pull requests

* fix(repo): test command in CI workflow to use pnpm run all:test

* fix(repo): update Node version in CI workflow to 20.x

* fix(repo): update CI workflow to install dependencies using pnpm
2024-04-26 04:20:02 +07:00
f6a1e68d85 chore(repo): script to run tests from monorepo root 2024-04-26 01:18:44 +07:00
cfcf0818ad Merge pull request #10 from robonen/renovate/all-minor-patch
chore(deps): update all non-major dependencies
2024-04-26 01:32:21 +07:00
renovate[bot]
4e92ed18ed chore(deps): update all non-major dependencies 2024-04-25 18:30:47 +00:00
69f9ae0900 Merge pull request #8 from robonen/renovate/node-20.x
chore(deps): update node.js to v20
2024-04-26 01:28:04 +07:00
renovate[bot]
18c3e16bb2 chore(deps): update node.js to v20 2024-04-25 18:25:17 +00:00
4afbe234f8 Merge pull request #7 from robonen/renoveate-bot
feat(repo): add .github/CODEOWNERS file and renovate.json configuration
2024-04-26 01:24:39 +07:00
b2923964e5 feat(repo): add .github/CODEOWNERS file and renovate.json configuration 2024-04-25 22:28:04 +07:00
531e1721bb feat(packages/stdlib): change build system to unbuild 2024-04-19 02:29:27 +07:00
26a99b7d67 chore(repo): update deps 2024-04-19 01:10:07 +07:00
90328bf8d0 Merge pull request #5 from robonen/stdlib-structs
Stack impl
2024-04-18 21:44:16 +07:00
55440a83ba chore(repo): update packageManager version to pnpm@9.0.1 2024-04-18 10:26:53 +07:00
943e913e76 refactor(packages/stdlib): use toReversed method instead of reverese 2024-04-18 10:26:16 +07:00
c90c17db93 feat(packages/stdlib): add structs exports 2024-04-17 04:04:28 +07:00
7546f2b653 feat(packages/stdlib): add stack data structure and tests 2024-04-17 04:00:41 +07:00
784457a507 refactor(packages/stdlib): add more accurate expectation RangeError for bitsflags 2024-04-17 03:59:35 +07:00
5bf6317673 refactor(packages/stdlib): add jsdoc for pubsub 2024-04-17 02:01:29 +07:00
80d8e37c03 refactor(packages/stdlib): add bitflags export 2024-04-16 23:08:57 +07:00
8a5d063800 Merge pull request #4 from robonen/stdlib-bitsflags
feat(packages/stdlib): add flagsGenerator and bitwise operation funct…
2024-04-17 05:08:22 +07:00
bf9e811346 feat(packages/stdlib): add flagsGenerator and bitwise operation functions 2024-04-16 23:03:20 +07:00
841d172598 chore(packages/stdlib): bump vitest to 1.5.0 2024-04-16 21:04:34 +07:00
43796ecae2 Merge pull request #3 from robonen/stdlib-pubsub
Stdlib pubsub
2024-04-16 17:07:14 +07:00
e3ef3a693e feat(packages/stdlib): add patterns exports 2024-04-16 16:39:24 +07:00
f987f722df feat(packages/stdlib): add PubSub class and tests to patterns 2024-04-16 16:32:56 +07:00
fb76d11725 feat(packages/stdlib): add jsr support 2024-04-12 00:42:47 +07:00
7182706595 refactor(packages/stdlib): remove slow types from levenstein distance 2024-04-12 00:42:12 +07:00
f3a2ae53f4 feat(repo): add support jsr in cli 2024-04-12 00:41:07 +07:00
379 changed files with 40740 additions and 2358 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @robonen

409
.github/skills/monorepo/SKILL.md vendored Normal file
View File

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

41
.github/workflows/ci.yaml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: CI
on:
pull_request:
branches:
- master
env:
NODE_VERSION: 22.x
jobs:
code-quality:
name: Code quality checks
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Lint
run: pnpm lint:check
- name: Test
run: pnpm test

78
.github/workflows/publish.yaml vendored Normal file
View File

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

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@ node_modules
.nuxt .nuxt
.nitro .nitro
.cache .cache
cache
out out
build build
dist dist

View File

@@ -1,20 +0,0 @@
import { defineConfig } from 'vitepress';
export default defineConfig({
lang: 'ru-RU',
title: "Tools",
description: "A set of tools and utilities for web development",
rewrites: {
'packages/:pkg/README.md': 'packages/:pkg/index.md'
},
themeConfig: {
sidebar: [
{
text: 'Пакеты',
items: [
{ text: '@robonen/tsconfig', link: '/packages/tsconfig/' },
],
},
],
},
});

View File

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

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
<div>
<img src="https://bage.robonen.ru/github?profile=robonen&project=tools&description=My%20most%20frequently%20used%20web%20tools">
</div>

View File

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

67
configs/oxlint/README.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
{
"extends": "@robonen/tsconfig/tsconfig.json",
"compilerOptions": {
"rootDir": "src"
},
"include": [
"src/**/*.ts"
]
}

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
{ {
"name": "@robonen/tsconfig", "name": "@robonen/tsconfig",
"private": true, "version": "0.0.2",
"version": "1.0.0", "license": "Apache-2.0",
"license": "UNLICENSED",
"description": "Base typescript configuration for projects", "description": "Base typescript configuration for projects",
"keywords": [ "keywords": [
"tsconfig", "tsconfig",
@@ -16,9 +15,9 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "packages/tsconfig" "directory": "packages/tsconfig"
}, },
"packageManager": "pnpm@8.15.6", "packageManager": "pnpm@10.30.3",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=24.13.1"
}, },
"files": [ "files": [
"**tsconfig.json" "**tsconfig.json"
@@ -26,4 +25,4 @@
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
} }
} }

View File

@@ -3,8 +3,9 @@
"display": "Base TypeScript Configuration", "display": "Base TypeScript Configuration",
"compilerOptions": { "compilerOptions": {
/* Basic Options */ /* Basic Options */
"module": "Preserve", "module": "ESNext",
"noEmit": true, "noEmit": true,
"lib": ["ESNext"],
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"target": "ESNext", "target": "ESNext",
"outDir": "dist", "outDir": "dist",
@@ -12,11 +13,12 @@
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"allowImportingTsExtensions": true,
"allowJs": true, "allowJs": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"moduleDetection": "force", "moduleDetection": "force",
"isolatedModules": true, "isolatedModules": true,
"removeComments": true, "removeComments": false,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"useDefineForClassFields": true, "useDefineForClassFields": true,
@@ -26,7 +28,7 @@
/* Library transpiling */ /* Library transpiling */
"declaration": true, "declaration": true,
"composite": true, // "composite": true,
"sourceMap": false, "sourceMap": false,
"declarationMap": false "declarationMap": false
}, },

View File

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

View File

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

View File

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

6
core/encoding/jsr.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,257 @@
import { describe, expect, it } from 'vitest';
import { encodeText, encodeBinary, encodeSegments, makeSegments, isNumeric, isAlphanumeric, QrCode, QrCodeDataType, EccMap, LOW, MEDIUM, QUARTILE, HIGH } from '..';
describe('isNumeric', () => {
it('accepts pure digit strings', () => {
expect(isNumeric('0123456789')).toBe(true);
expect(isNumeric('0')).toBe(true);
expect(isNumeric('')).toBe(true);
});
it('rejects non-digit characters', () => {
expect(isNumeric('12a3')).toBe(false);
expect(isNumeric('HELLO')).toBe(false);
expect(isNumeric('12 34')).toBe(false);
});
});
describe('isAlphanumeric', () => {
it('accepts valid alphanumeric strings', () => {
expect(isAlphanumeric('HELLO WORLD')).toBe(true);
expect(isAlphanumeric('0123456789')).toBe(true);
expect(isAlphanumeric('ABC123')).toBe(true);
expect(isAlphanumeric('')).toBe(true);
});
it('rejects lowercase and special characters', () => {
expect(isAlphanumeric('hello')).toBe(false);
expect(isAlphanumeric('Hello')).toBe(false);
expect(isAlphanumeric('test@email')).toBe(false);
});
});
describe('makeSegments', () => {
it('returns empty array for empty string', () => {
expect(makeSegments('')).toEqual([]);
});
it('selects numeric mode for digit strings', () => {
const segs = makeSegments('12345');
expect(segs).toHaveLength(1);
expect(segs[0]!.mode[0]).toBe(0x1); // MODE_NUMERIC
});
it('selects alphanumeric mode for uppercase strings', () => {
const segs = makeSegments('HELLO WORLD');
expect(segs).toHaveLength(1);
expect(segs[0]!.mode[0]).toBe(0x2); // MODE_ALPHANUMERIC
});
it('selects byte mode for general text', () => {
const segs = makeSegments('Hello, World!');
expect(segs).toHaveLength(1);
expect(segs[0]!.mode[0]).toBe(0x4); // MODE_BYTE
});
});
describe('encodeText', () => {
it('encodes short text at LOW ECC', () => {
const qr = encodeText('Hello', LOW);
expect(qr).toBeInstanceOf(QrCode);
expect(qr.version).toBeGreaterThanOrEqual(1);
expect(qr.size).toBe(qr.version * 4 + 17);
expect(qr.mask).toBeGreaterThanOrEqual(0);
expect(qr.mask).toBeLessThanOrEqual(7);
});
it('encodes text at different ECC levels', () => {
const qrL = encodeText('Test', LOW);
const qrM = encodeText('Test', MEDIUM);
const qrH = encodeText('Test', HIGH);
// Higher ECC needs same or higher version
expect(qrH.version).toBeGreaterThanOrEqual(qrL.version);
// All produce valid sizes
for (const qr of [qrL, qrM, qrH]) {
expect(qr.size).toBe(qr.version * 4 + 17);
}
});
it('encodes numeric-only text', () => {
const qr = encodeText('123456789012345', LOW);
expect(qr.version).toBe(1); // Numeric mode is compact
});
it('encodes a URL', () => {
const qr = encodeText('https://example.com/path?query=value', LOW);
expect(qr).toBeInstanceOf(QrCode);
expect(qr.size).toBeGreaterThanOrEqual(21);
});
it('encodes long text', () => {
const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.';
const qr = encodeText(longText, LOW);
expect(qr).toBeInstanceOf(QrCode);
});
it('throws for data too long', () => {
const tooLong = 'A'.repeat(10000);
expect(() => encodeText(tooLong, HIGH)).toThrow(RangeError);
});
});
describe('encodeBinary', () => {
it('encodes binary data', () => {
const data = [0x00, 0xFF, 0x48, 0x65, 0x6C, 0x6C, 0x6F];
const qr = encodeBinary(data, LOW);
expect(qr).toBeInstanceOf(QrCode);
});
});
describe('QrCode', () => {
it('modules grid has correct dimensions', () => {
const qr = encodeText('Test', LOW);
// Flat Uint8Array grid, verify via getModule
expect(qr.size).toBeGreaterThanOrEqual(21);
for (let y = 0; y < qr.size; y++) {
for (let x = 0; x < qr.size; x++) {
const mod = qr.getModule(x, y);
expect(typeof mod).toBe('boolean');
}
}
});
it('types grid has correct dimensions', () => {
const qr = encodeText('Test', LOW);
// Flat Int8Array grid, verify via getType
for (let y = 0; y < qr.size; y++) {
for (let x = 0; x < qr.size; x++) {
const t = qr.getType(x, y);
expect(typeof t).toBe('number');
}
}
});
it('getModule returns false for out of bounds', () => {
const qr = encodeText('Test', LOW);
expect(qr.getModule(-1, 0)).toBe(false);
expect(qr.getModule(0, -1)).toBe(false);
expect(qr.getModule(qr.size, 0)).toBe(false);
expect(qr.getModule(0, qr.size)).toBe(false);
});
it('produces deterministic output', () => {
const qr1 = encodeText('Hello', LOW);
const qr2 = encodeText('Hello', LOW);
expect(qr1.version).toBe(qr2.version);
expect(qr1.mask).toBe(qr2.mask);
for (let y = 0; y < qr1.size; y++) {
for (let x = 0; x < qr1.size; x++) {
expect(qr1.getModule(x, y)).toBe(qr2.getModule(x, y));
}
}
});
it('different inputs produce different outputs', () => {
const qr1 = encodeText('Hello', LOW);
const qr2 = encodeText('World', LOW);
// They might have the same version/size but different modules
let hasDiff = false;
for (let y = 0; y < qr1.size && !hasDiff; y++) {
for (let x = 0; x < qr1.size && !hasDiff; x++) {
if (qr1.getModule(x, y) !== qr2.getModule(x, y))
hasDiff = true;
}
}
expect(hasDiff).toBe(true);
});
});
describe('EccMap', () => {
it('has all four levels', () => {
expect(EccMap.L).toBeDefined();
expect(EccMap.M).toBeDefined();
expect(EccMap.Q).toBeDefined();
expect(EccMap.H).toBeDefined();
});
it('works with encodeText', () => {
const qr = encodeText('Test', EccMap.L);
expect(qr).toBeInstanceOf(QrCode);
});
});
describe('encodeSegments', () => {
it('uses explicit mask when specified', () => {
const qr = encodeSegments(makeSegments('Test'), LOW, 1, 40, 3);
expect(qr.mask).toBe(3);
});
it('preserves ECC level when boostEcl is false', () => {
const qr = encodeSegments(makeSegments('Test'), LOW, 1, 40, -1, false);
expect(qr.ecc).toBe(LOW);
});
it('boosts ECC level by default when data fits', () => {
const qr = encodeSegments(makeSegments('Test'), LOW);
expect(qr.ecc).toBe(HIGH);
});
it('forces a specific version when min equals max', () => {
const qr = encodeSegments(makeSegments('Test'), LOW, 5, 5);
expect(qr.version).toBe(5);
});
it('throws on invalid version range', () => {
expect(() => encodeSegments(makeSegments('Test'), LOW, 2, 1)).toThrow(RangeError);
});
it('throws on invalid mask value', () => {
expect(() => encodeSegments(makeSegments('Test'), LOW, 1, 40, 8)).toThrow(RangeError);
});
});
describe('encodeBinary edge cases', () => {
it('encodes an empty array', () => {
const qr = encodeBinary([], LOW);
expect(qr).toBeInstanceOf(QrCode);
});
});
describe('encodeText edge cases', () => {
it('encodes Unicode emoji text', () => {
const qr = encodeText('Hello \uD83C\uDF0D', LOW);
expect(qr).toBeInstanceOf(QrCode);
expect(qr.size).toBeGreaterThanOrEqual(21);
});
it('uses compact encoding for alphanumeric text', () => {
const qr = encodeText('HELLO WORLD', LOW);
expect(qr.version).toBe(1);
});
it('selects version >= 7 for long data (triggers drawVersion)', () => {
const qr = encodeText('a'.repeat(200), LOW);
expect(qr.version).toBeGreaterThanOrEqual(7);
});
});
describe('getType semantics', () => {
it('identifies finder pattern modules as Position', () => {
const qr = encodeText('Test', LOW);
// Top-left finder pattern
expect(qr.getType(0, 0)).toBe(QrCodeDataType.Position);
expect(qr.getType(3, 3)).toBe(QrCodeDataType.Position);
expect(qr.getType(6, 6)).toBe(QrCodeDataType.Position);
// Top-right finder pattern
expect(qr.getType(qr.size - 1, 0)).toBe(QrCodeDataType.Position);
// Bottom-left finder pattern
expect(qr.getType(0, qr.size - 1)).toBe(QrCodeDataType.Position);
});
it('identifies timing pattern modules as Timing', () => {
const qr = encodeText('Test', LOW);
// Horizontal timing row y=6, between finders
expect(qr.getType(8, 6)).toBe(QrCodeDataType.Timing);
});
});

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import { QrSegment, makeNumeric, makeAlphanumeric, makeBytes } from '../segment';
import { MODE_ALPHANUMERIC, MODE_BYTE, MODE_NUMERIC } from '../constants';
describe('QrSegment', () => {
it('throws on negative numChars', () => {
expect(() => new QrSegment(MODE_BYTE, -1, [])).toThrow(RangeError);
});
it('accepts zero numChars', () => {
const seg = new QrSegment(MODE_BYTE, 0, []);
expect(seg.numChars).toBe(0);
expect(seg.bitData).toEqual([]);
});
});
describe('makeNumeric', () => {
it('encodes a 5-digit string', () => {
const seg = makeNumeric('12345');
expect(seg.mode).toBe(MODE_NUMERIC);
expect(seg.numChars).toBe(5);
// "123" → 10 bits, "45" → 7 bits
expect(seg.bitData).toHaveLength(17);
});
it('encodes a single digit', () => {
const seg = makeNumeric('0');
expect(seg.numChars).toBe(1);
expect(seg.bitData).toHaveLength(4);
});
it('encodes an empty string', () => {
const seg = makeNumeric('');
expect(seg.numChars).toBe(0);
expect(seg.bitData).toHaveLength(0);
});
it('throws on non-numeric input', () => {
expect(() => makeNumeric('12a3')).toThrow(RangeError);
expect(() => makeNumeric('hello')).toThrow(RangeError);
});
});
describe('makeAlphanumeric', () => {
it('encodes a character pair', () => {
const seg = makeAlphanumeric('AB');
expect(seg.mode).toBe(MODE_ALPHANUMERIC);
expect(seg.numChars).toBe(2);
// 1 pair → 11 bits
expect(seg.bitData).toHaveLength(11);
});
it('encodes a pair plus remainder', () => {
const seg = makeAlphanumeric('ABC');
expect(seg.numChars).toBe(3);
// 1 pair (11 bits) + 1 remainder (6 bits)
expect(seg.bitData).toHaveLength(17);
});
it('throws on lowercase input', () => {
expect(() => makeAlphanumeric('hello')).toThrow(RangeError);
});
it('throws on invalid characters', () => {
expect(() => makeAlphanumeric('test@email')).toThrow(RangeError);
});
});
describe('makeBytes', () => {
it('encodes an empty array', () => {
const seg = makeBytes([]);
expect(seg.mode).toBe(MODE_BYTE);
expect(seg.numChars).toBe(0);
expect(seg.bitData).toHaveLength(0);
});
it('encodes two bytes', () => {
const seg = makeBytes([0x48, 0x65]);
expect(seg.numChars).toBe(2);
expect(seg.bitData).toHaveLength(16);
});
it('encodes 0xFF correctly', () => {
const seg = makeBytes([0xFF]);
expect(seg.bitData).toEqual([1, 1, 1, 1, 1, 1, 1, 1]);
});
it('encodes 0x00 correctly', () => {
const seg = makeBytes([0x00]);
expect(seg.bitData).toEqual([0, 0, 0, 0, 0, 0, 0, 0]);
});
});

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import { appendBits, getBit, getNumDataCodewords, getNumRawDataModules, getTotalBits, numCharCountBits } from '../utils';
import { HIGH, LOW, MODE_BYTE, MODE_NUMERIC } from '../constants';
import { QrSegment } from '../segment';
describe('appendBits', () => {
it('appends nothing when len is 0', () => {
const bb: number[] = [];
appendBits(0, 0, bb);
expect(bb).toEqual([]);
});
it('appends bits in MSB-first order', () => {
const bb: number[] = [];
appendBits(0b101, 3, bb);
expect(bb).toEqual([1, 0, 1]);
});
it('appends to an existing array', () => {
const bb = [1, 0];
appendBits(0b11, 2, bb);
expect(bb).toEqual([1, 0, 1, 1]);
});
it('throws when value exceeds bit length', () => {
expect(() => appendBits(5, 2, [])).toThrow(RangeError);
});
it('throws on negative length', () => {
expect(() => appendBits(0, -1, [])).toThrow(RangeError);
});
});
describe('getBit', () => {
it('returns correct bits for 0b10110', () => {
expect(getBit(0b10110, 0)).toBe(false);
expect(getBit(0b10110, 1)).toBe(true);
expect(getBit(0b10110, 2)).toBe(true);
expect(getBit(0b10110, 3)).toBe(false);
expect(getBit(0b10110, 4)).toBe(true);
});
it('returns false for high bits of a small number', () => {
expect(getBit(1, 7)).toBe(false);
expect(getBit(1, 31)).toBe(false);
});
});
describe('getNumRawDataModules', () => {
it('returns 208 for version 1', () => {
expect(getNumRawDataModules(1)).toBe(208);
});
it('returns correct value for version 2 (with alignment)', () => {
expect(getNumRawDataModules(2)).toBe(359);
});
it('returns correct value for version 7 (with version info)', () => {
expect(getNumRawDataModules(7)).toBe(1568);
});
it('returns 29648 for version 40', () => {
expect(getNumRawDataModules(40)).toBe(29648);
});
it('throws on version 0', () => {
expect(() => getNumRawDataModules(0)).toThrow(RangeError);
});
it('throws on version 41', () => {
expect(() => getNumRawDataModules(41)).toThrow(RangeError);
});
});
describe('getNumDataCodewords', () => {
it('returns 19 for version 1 LOW', () => {
expect(getNumDataCodewords(1, LOW)).toBe(19);
});
it('returns 9 for version 1 HIGH', () => {
expect(getNumDataCodewords(1, HIGH)).toBe(9);
});
});
describe('getTotalBits', () => {
it('returns 0 for empty segments', () => {
expect(getTotalBits([], 1)).toBe(0);
});
it('returns Infinity when numChars overflows char count field', () => {
// MODE_BYTE at v1 has ccbits=8, so numChars=256 overflows
const seg = new QrSegment(MODE_BYTE, 256, []);
expect(getTotalBits([seg], 1)).toBe(Number.POSITIVE_INFINITY);
});
it('calculates total bits for a single segment', () => {
// MODE_BYTE at v1: 4 (mode) + 8 (char count) + 8 (data) = 20
const seg = new QrSegment(MODE_BYTE, 1, [0, 0, 0, 0, 0, 0, 0, 0]);
expect(getTotalBits([seg], 1)).toBe(20);
});
});
describe('numCharCountBits', () => {
it('returns correct bits for MODE_NUMERIC across version ranges', () => {
expect(numCharCountBits(MODE_NUMERIC, 1)).toBe(10);
expect(numCharCountBits(MODE_NUMERIC, 9)).toBe(10);
expect(numCharCountBits(MODE_NUMERIC, 10)).toBe(12);
expect(numCharCountBits(MODE_NUMERIC, 26)).toBe(12);
expect(numCharCountBits(MODE_NUMERIC, 27)).toBe(14);
expect(numCharCountBits(MODE_NUMERIC, 40)).toBe(14);
});
it('returns correct bits for MODE_BYTE across version ranges', () => {
expect(numCharCountBits(MODE_BYTE, 1)).toBe(8);
expect(numCharCountBits(MODE_BYTE, 9)).toBe(8);
expect(numCharCountBits(MODE_BYTE, 10)).toBe(16);
expect(numCharCountBits(MODE_BYTE, 40)).toBe(16);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

23
core/platform/README.md Normal file
View File

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

7
core/platform/jsr.json Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@robonen/platform",
"license": "Apache-2.0",
"version": "0.0.2",
"exports": "./src/index.ts"
}

View File

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

View File

@@ -0,0 +1,66 @@
{
"name": "@robonen/platform",
"version": "0.0.4",
"license": "Apache-2.0",
"description": "Platform dependent utilities for javascript development",
"keywords": [
"javascript",
"typescript",
"browser",
"platform",
"node",
"bun",
"deno"
],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "packages/platform"
},
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},
"type": "module",
"files": [
"dist"
],
"exports": {
"./browsers": {
"import": {
"types": "./dist/browsers.d.mts",
"default": "./dist/browsers.mjs"
},
"require": {
"types": "./dist/browsers.d.cts",
"default": "./dist/browsers.cjs"
}
},
"./multi": {
"import": {
"types": "./dist/multi.d.mts",
"default": "./dist/multi.mjs"
},
"require": {
"types": "./dist/multi.d.cts",
"default": "./dist/multi.cjs"
}
}
},
"scripts": {
"lint:check": "oxlint -c oxlint.config.ts",
"lint:fix": "oxlint -c oxlint.config.ts --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:",
"oxlint": "catalog:",
"tsdown": "catalog:"
}
}

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, vi } from 'vitest';
import {
getAnimationName,
isAnimatable,
shouldSuspendUnmount,
dispatchAnimationEvent,
onAnimationSettle,
} from '.';
describe('getAnimationName', () => {
it('returns "none" for undefined element', () => {
expect(getAnimationName(undefined)).toBe('none');
});
it('returns the animation name from inline style', () => {
const el = document.createElement('div');
el.style.animationName = 'fadeIn';
document.body.appendChild(el);
expect(getAnimationName(el)).toBe('fadeIn');
document.body.removeChild(el);
});
});
describe('isAnimatable', () => {
it('returns false for undefined element', () => {
expect(isAnimatable(undefined)).toBe(false);
});
it('returns false for element with no animation or transition', () => {
const el = document.createElement('div');
document.body.appendChild(el);
expect(isAnimatable(el)).toBe(false);
document.body.removeChild(el);
});
});
describe('shouldSuspendUnmount', () => {
it('returns false for undefined element', () => {
expect(shouldSuspendUnmount(undefined, 'none')).toBe(false);
});
it('returns false for element with no animation/transition', () => {
const el = document.createElement('div');
document.body.appendChild(el);
expect(shouldSuspendUnmount(el, 'none')).toBe(false);
document.body.removeChild(el);
});
});
describe('dispatchAnimationEvent', () => {
it('dispatches a custom event on the element', () => {
const el = document.createElement('div');
const handler = vi.fn();
el.addEventListener('enter', handler);
dispatchAnimationEvent(el, 'enter');
expect(handler).toHaveBeenCalledOnce();
});
it('does not throw for undefined element', () => {
expect(() => dispatchAnimationEvent(undefined, 'leave')).not.toThrow();
});
it('dispatches non-bubbling event', () => {
const el = document.createElement('div');
const parent = document.createElement('div');
const handler = vi.fn();
parent.appendChild(el);
parent.addEventListener('enter', handler);
dispatchAnimationEvent(el, 'enter');
expect(handler).not.toHaveBeenCalled();
});
});
describe('onAnimationSettle', () => {
it('returns a cleanup function', () => {
const el = document.createElement('div');
const cleanup = onAnimationSettle(el, { onSettle: vi.fn() });
expect(typeof cleanup).toBe('function');
cleanup();
});
it('calls onSettle callback on transitionend', () => {
const el = document.createElement('div');
const callback = vi.fn();
onAnimationSettle(el, { onSettle: callback });
el.dispatchEvent(new Event('transitionend'));
expect(callback).toHaveBeenCalledOnce();
});
it('calls onSettle callback on transitioncancel', () => {
const el = document.createElement('div');
const callback = vi.fn();
onAnimationSettle(el, { onSettle: callback });
el.dispatchEvent(new Event('transitioncancel'));
expect(callback).toHaveBeenCalledOnce();
});
it('calls onStart callback on animationstart', () => {
const el = document.createElement('div');
const startCallback = vi.fn();
onAnimationSettle(el, {
onSettle: vi.fn(),
onStart: startCallback,
});
el.dispatchEvent(new Event('animationstart'));
expect(startCallback).toHaveBeenCalledOnce();
});
it('removes all listeners on cleanup', () => {
const el = document.createElement('div');
const callback = vi.fn();
const cleanup = onAnimationSettle(el, { onSettle: callback });
cleanup();
el.dispatchEvent(new Event('transitionend'));
el.dispatchEvent(new Event('transitioncancel'));
expect(callback).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,139 @@
export type AnimationLifecycleEvent = 'enter' | 'after-enter' | 'leave' | 'after-leave';
export interface AnimationSettleCallbacks {
onSettle: () => void;
onStart?: (animationName: string) => void;
}
/**
* @name getAnimationName
* @category Browsers
* @description Returns the current CSS animation name(s) of an element
*
* @since 0.0.5
*/
export function getAnimationName(el: HTMLElement | undefined): string {
return el ? getComputedStyle(el).animationName || 'none' : 'none';
}
/**
* @name isAnimatable
* @category Browsers
* @description Checks whether an element has a running CSS animation or transition
*
* @since 0.0.5
*/
export function isAnimatable(el: HTMLElement | undefined): boolean {
if (!el) return false;
const style = getComputedStyle(el);
const animationName = style.animationName || 'none';
const transitionProperty = style.transitionProperty || 'none';
const hasAnimation = animationName !== 'none' && animationName !== '';
const hasTransition = transitionProperty !== 'none' && transitionProperty !== '' && transitionProperty !== 'all';
return hasAnimation || hasTransition;
}
/**
* @name shouldSuspendUnmount
* @category Browsers
* @description Determines whether unmounting should be delayed due to a running animation/transition change
*
* @since 0.0.5
*/
export function shouldSuspendUnmount(el: HTMLElement | undefined, prevAnimationName: string): boolean {
if (!el) return false;
const style = getComputedStyle(el);
if (style.display === 'none') return false;
const animationName = style.animationName || 'none';
const transitionProperty = style.transitionProperty || 'none';
const hasAnimation = animationName !== 'none' && animationName !== '';
const hasTransition = transitionProperty !== 'none' && transitionProperty !== '' && transitionProperty !== 'all';
if (!hasAnimation && !hasTransition) return false;
return prevAnimationName !== animationName || hasTransition;
}
/**
* @name dispatchAnimationEvent
* @category Browsers
* @description Dispatches a non-bubbling custom event on an element for animation lifecycle tracking
*
* @since 0.0.5
*/
export function dispatchAnimationEvent(el: HTMLElement | undefined, name: AnimationLifecycleEvent): void {
el?.dispatchEvent(new CustomEvent(name, { bubbles: false, cancelable: false }));
}
/**
* @name onAnimationSettle
* @category Browsers
* @description Attaches animation/transition end listeners to an element with fill-mode flash prevention. Returns a cleanup function.
*
* @since 0.0.5
*/
export function onAnimationSettle(el: HTMLElement, callbacks: AnimationSettleCallbacks): () => void {
let fillModeTimeoutId: ReturnType<typeof setTimeout> | undefined;
const handleAnimationEnd = (event: AnimationEvent) => {
const currentAnimationName = getAnimationName(el);
const isCurrentAnimation = currentAnimationName.includes(CSS.escape(event.animationName));
if (event.target === el && isCurrentAnimation) {
callbacks.onSettle();
if (fillModeTimeoutId !== undefined) {
clearTimeout(fillModeTimeoutId);
}
const currentFillMode = el.style.animationFillMode;
el.style.animationFillMode = 'forwards';
fillModeTimeoutId = setTimeout(() => {
if (el.style.animationFillMode === 'forwards') {
el.style.animationFillMode = currentFillMode;
}
});
}
else if (event.target === el && currentAnimationName === 'none') {
callbacks.onSettle();
}
};
const handleAnimationStart = (event: AnimationEvent) => {
if (event.target === el) {
callbacks.onStart?.(getAnimationName(el));
}
};
const handleTransitionEnd = (event: TransitionEvent) => {
if (event.target === el) {
callbacks.onSettle();
}
};
el.addEventListener('animationstart', handleAnimationStart, { passive: true });
el.addEventListener('animationcancel', handleAnimationEnd, { passive: true });
el.addEventListener('animationend', handleAnimationEnd, { passive: true });
el.addEventListener('transitioncancel', handleTransitionEnd, { passive: true });
el.addEventListener('transitionend', handleTransitionEnd, { passive: true });
return () => {
el.removeEventListener('animationstart', handleAnimationStart);
el.removeEventListener('animationcancel', handleAnimationEnd);
el.removeEventListener('animationend', handleAnimationEnd);
el.removeEventListener('transitioncancel', handleTransitionEnd);
el.removeEventListener('transitionend', handleTransitionEnd);
if (fillModeTimeoutId !== undefined) {
clearTimeout(fillModeTimeoutId);
}
};
}

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { focusGuard, createGuardAttrs } from '.';
describe('focusGuard', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('initialize with the correct default namespace', () => {
const guard = focusGuard();
expect(guard.selector).toBe('data-focus-guard');
});
it('create focus guards in the DOM', () => {
const guard = focusGuard();
guard.createGuard();
const guards = document.querySelectorAll(`[${guard.selector}]`);
expect(guards.length).toBe(2);
guards.forEach((element) => {
expect(element.tagName).toBe('SPAN');
expect(element.getAttribute('tabindex')).toBe('0');
});
});
it('remove focus guards from the DOM correctly', () => {
const guard = focusGuard();
guard.createGuard();
guard.removeGuard();
const guards = document.querySelectorAll(`[${guard.selector}]`);
expect(guards.length).toBe(0);
});
it('reuse the same guards when calling createGuard multiple times', () => {
const guard = focusGuard();
guard.createGuard();
guard.createGuard();
guard.removeGuard();
const guards = document.querySelectorAll(`[${guard.selector}]`);
expect(guards.length).toBe(0);
});
it('allow custom namespaces', () => {
const namespace = 'custom-guard';
const guard = focusGuard(namespace);
guard.createGuard();
expect(guard.selector).toBe(`data-${namespace}`);
const guards = document.querySelectorAll(`[${guard.selector}]`);
expect(guards.length).toBe(2);
});
it('createGuardAttrs should create a valid guard element', () => {
const namespace = 'custom-guard';
const element = createGuardAttrs(namespace);
expect(element.tagName).toBe('SPAN');
expect(element.getAttribute(namespace)).toBe('');
expect(element.getAttribute('tabindex')).toBe('0');
expect(element.getAttribute('style')).toBe('outline: none; opacity: 0; pointer-events: none; position: fixed;');
});
});

View File

@@ -0,0 +1,50 @@
/**
* @name focusGuard
* @category Browsers
* @description Adds a pair of focus guards at the boundaries of the DOM tree to ensure consistent focus behavior
*
* @param {string} namespace - The namespace to use for the guard attributes
* @returns {Object} - An object containing the selector, createGuard, and removeGuard functions
*
* @example
* const guard = focusGuard();
* guard.createGuard();
* guard.removeGuard();
*
* @example
* const guard = focusGuard('focus-guard');
* guard.createGuard();
* guard.removeGuard();
*
* @since 0.0.3
*/
export function focusGuard(namespace = 'focus-guard') {
const guardAttr = `data-${namespace}`;
const createGuard = () => {
const edges = document.querySelectorAll(`[${guardAttr}]`);
document.body.insertAdjacentElement('afterbegin', edges[0] ?? createGuardAttrs(guardAttr));
document.body.insertAdjacentElement('beforeend', edges[1] ?? createGuardAttrs(guardAttr));
};
const removeGuard = () => {
document.querySelectorAll(`[${guardAttr}]`).forEach(element => element.remove());
};
return {
selector: guardAttr,
createGuard,
removeGuard,
};
}
export function createGuardAttrs(namespace = 'focus-guard') {
const element = document.createElement('span');
element.setAttribute(namespace, '');
element.setAttribute('tabindex', '0');
element.setAttribute('style', 'outline: none; opacity: 0; pointer-events: none; position: fixed;');
return element;
}

View File

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

View File

@@ -0,0 +1,49 @@
// eslint-disable
export interface DebounceOptions {
/**
* Call the function on the leading edge of the timeout, instead of waiting for the trailing edge
*/
readonly immediate?: boolean;
/**
* Call the function on the trailing edge with the last used arguments.
* Result of call is from previous call
*/
readonly trailing?: boolean;
}
const DEFAULT_DEBOUNCE_OPTIONS: DebounceOptions = {
trailing: true,
}
export function debounce<FnArguments extends unknown[], FnReturn>(
fn: (...args: FnArguments) => PromiseLike<FnReturn> | FnReturn,
timeout: number = 20,
options: DebounceOptions = {},
) {
options = {
...DEFAULT_DEBOUNCE_OPTIONS,
...options,
};
if (!Number.isFinite(timeout) || timeout <= 0)
throw new TypeError('Debounce timeout must be a positive number');
// Last result for leading edge
let leadingValue: PromiseLike<FnReturn> | FnReturn;
// Debounce timeout id
let timeoutId: NodeJS.Timeout;
// Promises to be resolved when debounce is finished
let resolveList: Array<(value: unknown) => void> = [];
// State of currently resolving promise
let currentResolve: Promise<FnReturn>;
// Trailing call information
let trailingArgs: unknown[];
}

View File

@@ -0,0 +1,28 @@
// TODO: tests
/**
* @name _global
* @category Multi
* @description Global object that works in any environment
*
* @since 0.0.1
*/
export const _global
= typeof globalThis !== 'undefined'
? globalThis
: typeof window !== 'undefined'
? window
: typeof global !== 'undefined'
? global
: typeof self !== 'undefined'
? self
: undefined;
/**
* @name isClient
* @category Multi
* @description Check if the current environment is the client
*
* @since 0.0.1
*/
export const isClient = typeof window !== 'undefined' && typeof document !== 'undefined';

View File

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

View File

@@ -1,3 +1,6 @@
{ {
"extends": "@robonen/tsconfig/tsconfig.json", "extends": "@robonen/tsconfig/tsconfig.json",
"compilerOptions": {
"lib": ["DOM"]
}
} }

View File

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

View File

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

32
core/stdlib/README.md Normal file
View File

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

6
core/stdlib/jsr.json Normal file
View File

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

View File

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

56
core/stdlib/package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "@robonen/stdlib",
"version": "0.0.9",
"license": "Apache-2.0",
"description": "A collection of tools, utilities, and helpers for TypeScript",
"keywords": [
"stdlib",
"utils",
"tools",
"helpers",
"math",
"algorithms",
"data-structures"
],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "packages/stdlib"
},
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"scripts": {
"lint:check": "oxlint -c oxlint.config.ts",
"lint:fix": "oxlint -c oxlint.config.ts --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:",
"oxlint": "catalog:",
"tsdown": "catalog:"
}
}

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { cluster } from '.';
describe('cluster', () => {
it('cluster an array into subarrays of a specific size', () => {
const result = cluster([1, 2, 3, 4, 5, 6, 7, 8], 3);
expect(result).toEqual([[1, 2, 3], [4, 5, 6], [7, 8]]);
});
it('handle arrays that are not perfectly divisible by the size', () => {
const result = cluster([1, 2, 3, 4, 5], 2);
expect(result).toEqual([[1, 2], [3, 4], [5]]);
});
it('return an array with each element in its own subarray if size is 1', () => {
const result = cluster([1, 2, 3, 4], 1);
expect(result).toEqual([[1], [2], [3], [4]]);
});
it('return an array with a single subarray if size is greater than the array length', () => {
const result = cluster([1, 2, 3], 5);
expect(result).toEqual([[1, 2, 3]]);
});
it('return an empty array if size is less than or equal to 0', () => {
const result = cluster([1, 2, 3, 4], -1);
expect(result).toEqual([]);
});
it('return an empty array if the input array is empty', () => {
const result = cluster([], 3);
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,24 @@
/**
* @name cluster
* @category Arrays
* @description Cluster an array into subarrays of a specific size
*
* @param {Value[]} arr The array to cluster
* @param {number} size The size of each cluster
* @returns {Value[][]} The clustered array
*
* @example
* cluster([1, 2, 3, 4, 5, 6, 7, 8], 3) // => [[1, 2, 3], [4, 5, 6], [7, 8]]
*
* @example
* cluster([1, 2, 3, 4], -1) // => []
*
* @since 0.0.3
*/
export function cluster<Value>(arr: Value[], size: number): Value[][] {
if (size <= 0) return [];
const clusterLength = Math.ceil(arr.length / size);
return Array.from({ length: clusterLength }, (_, i) => arr.slice(i * size, i * size + size));
}

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { first } from '.';
describe('first', () => {
it('return the first element of a non-empty array', () => {
expect(first([1, 2, 3])).toBe(1);
expect(first(['a', 'b', 'c'])).toBe('a');
});
it('return undefined for an empty array without a default value', () => {
expect(first([])).toBeUndefined();
});
it('return the default value for an empty array with a default value', () => {
expect(first([], 42)).toBe(42);
expect(first([], 'default')).toBe('default');
});
it('return the first element even if a default value is provided', () => {
expect(first([1, 2, 3], 42)).toBe(1);
expect(first(['a', 'b', 'c'], 'default')).toBe('a');
});
});

View File

@@ -0,0 +1,20 @@
/**
* @name first
* @category Arrays
* @description Returns the first element of an array
*
* @param {Value[]} arr The array to get the first element from
* @param {Value} [defaultValue] The default value to return if the array is empty
* @returns {Value | undefined} The first element of the array, or the default value if the array is empty
*
* @example
* first([1, 2, 3]); // => 1
*
* @example
* first([]); // => undefined
*
* @since 0.0.3
*/
export function first<Value>(arr: Value[], defaultValue?: Value) {
return arr[0] ?? defaultValue;
}

View File

@@ -0,0 +1,5 @@
export * from './cluster';
export * from './first';
export * from './last';
export * from './sum';
export * from './unique';

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { last } from '.';
describe('last', () => {
it('return the last element of a non-empty array', () => {
expect(last([1, 2, 3, 4, 5])).toBe(5);
expect(last(['a', 'b', 'c'])).toBe('c');
});
it('return undefined if the array is empty and no default value is provided', () => {
expect(last([])).toBeUndefined();
});
it('return the default value for an empty array with a default value', () => {
expect(last([], 42)).toBe(42);
expect(last([], 'default')).toBe('default');
});
it('return the first element even if a default value is provided', () => {
expect(last([1, 2, 3], 42)).toBe(3);
expect(last(['a', 'b', 'c'], 'default')).toBe('c');
});
});

View File

@@ -0,0 +1,20 @@
/**
* @name last
* @section Arrays
* @description Gets the last element of an array
*
* @param {Value[]} arr The array to get the last element of
* @param {Value} [defaultValue] The default value to return if the array is empty
* @returns {Value | undefined} The last element of the array, or the default value if the array is empty
*
* @example
* last([1, 2, 3, 4, 5]); // => 5
*
* @example
* last([], 3); // => 3
*
* @since 0.0.3
*/
export function last<Value>(arr: Value[], defaultValue?: Value) {
return arr[arr.length - 1] ?? defaultValue;
}

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { sum } from '.';
describe('sum', () => {
it('return the sum of all elements in a number array', () => {
const result = sum([1, 2, 3, 4, 5]);
expect(result).toBe(15);
});
it('return 0 for an empty array', () => {
const result = sum([]);
expect(result).toBe(0);
});
it('return the sum of all elements using a getValue function', () => {
const result = sum([{ value: 1 }, { value: 2 }, { value: 3 }], item => item.value);
expect(result).toBe(6);
});
it('handle arrays with negative numbers', () => {
const result = sum([-1, -2, -3, -4, -5]);
expect(result).toBe(-15);
});
it('handle arrays with mixed positive and negative numbers', () => {
const result = sum([1, -2, 3, -4, 5]);
expect(result).toBe(3);
});
it('handle arrays with floating point numbers', () => {
const result = sum([1.5, 2.5, 3.5]);
expect(result).toBe(7.5);
});
it('handle arrays with a getValue function returning floating point numbers', () => {
const result = sum([{ value: 1.5 }, { value: 2.5 }, { value: 3.5 }], item => item.value);
expect(result).toBe(7.5);
});
});

View File

@@ -0,0 +1,26 @@
/**
* @name sum
* @category Arrays
* @description Returns the sum of all the elements in an array
*
* @param {Value[]} array - The array to sum
* @param {(item: Value) => number} [getValue] - A function that returns the value to sum from each element in the array
* @returns {number} The sum of all the elements in the array
*
* @example
* sum([1, 2, 3, 4, 5]) // => 15
*
* sum([{ value: 1 }, { value: 2 }, { value: 3 }], (item) => item.value) // => 6
*
* @since 0.0.3
*/
export function sum<Value extends number>(array: Value[]): number;
export function sum<Value>(array: Value[], getValue: (item: Value) => number): number;
export function sum<Value>(array: Value[], getValue?: (item: Value) => number): number {
// This check is necessary because the overload without the getValue argument
// makes tree-shaking based on argument types possible
if (!getValue)
return array.reduce((acc, item) => acc + (item as number), 0);
return array.reduce((acc, item) => acc + getValue(item), 0);
}

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { unique } from '.';
describe('unique', () => {
it('return an array with unique numbers', () => {
const result = unique([1, 2, 3, 3, 4, 5, 5, 6]);
expect(result).toEqual([1, 2, 3, 4, 5, 6]);
});
it('return an array with unique objects based on id', () => {
const result = unique(
[{ id: 1 }, { id: 2 }, { id: 1 }],
item => item.id,
);
expect(result).toEqual([{ id: 1 }, { id: 2 }]);
});
it('return the same array if all elements are unique', () => {
const result = unique([1, 2, 3, 4, 5]);
expect(result).toEqual([1, 2, 3, 4, 5]);
});
it('handle arrays with different types of values', () => {
const result = unique([1, '1', 2, '2', 2, 3, '3']);
expect(result).toEqual([1, '1', 2, '2', 3, '3']);
});
it('handle arrays with symbols', () => {
const sym1 = Symbol('a');
const sym2 = Symbol('b');
const result = unique([sym1, sym2, sym1]);
expect(result).toEqual([sym1, sym2]);
});
it('return an empty array when given an empty array', () => {
const result = unique([]);
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,33 @@
export type UniqueKey = string | number | symbol;
export type Extractor<Value, Key extends UniqueKey> = (value: Value) => Key;
/**
* @name unique
* @category Arrays
* @description Returns a new array with unique values from the original array
*
* @param {Value[]} array - The array to filter
* @param {Function} [extractor] - The function to extract the value to compare
* @returns {Value[]} - The new array with unique values
*
* @example
* unique([1, 2, 3, 3, 4, 5, 5, 6]) //=> [1, 2, 3, 4, 5, 6]
*
* @example
* unique([{ id: 1 }, { id: 2 }, { id: 1 }], (a, b) => a.id === b.id) //=> [{ id: 1 }, { id: 2 }]
*
* @since 0.0.3
*/
export function unique<Value, Key extends UniqueKey>(
array: Value[],
extractor?: Extractor<Value, Key>,
) {
const values = new Map<Key, Value>();
for (const value of array) {
const key = extractor ? extractor(value) : value as any;
values.set(key, value);
}
return Array.from(values.values());
}

View File

@@ -0,0 +1,2 @@
export * from './sleep';
export * from './tryIt';

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { sleep } from '.';
describe('sleep', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
it('delay execution by the specified amount of time', async () => {
const start = performance.now();
const delay = 100;
await sleep(delay);
const end = performance.now();
expect(end - start).toBeGreaterThan(delay - 5);
});
});

View File

@@ -0,0 +1,21 @@
/**
* @name sleep
* @category Async
* @description Delays the execution of the current function by the specified amount of time
*
* @param {number} ms - The amount of time to delay the execution of the current function
* @returns {Promise<void>} - A promise that resolves after the specified amount of time
*
* @example
* await sleep(1000);
*
* @example
* sleep(1000).then(() => {
* console.log('Hello, World!');
* });
*
* @since 0.0.3
*/
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { tryIt } from '.';
describe('tryIt', () => {
it('handle synchronous functions without errors', () => {
const syncFn = (x: number) => x * 2;
const wrappedSyncFn = tryIt(syncFn);
const [error, result] = wrappedSyncFn(2);
expect(error).toBeUndefined();
expect(result).toBe(4);
});
it('handle synchronous functions with errors', () => {
const syncFn = (): void => {
throw new Error('Test error');
};
const wrappedSyncFn = tryIt(syncFn);
const [error, result] = wrappedSyncFn();
expect(error).toBeInstanceOf(Error);
expect(error?.message).toBe('Test error');
expect(result).toBeUndefined();
});
it('handle asynchronous functions without errors', async () => {
const asyncFn = async (x: number) => x * 2;
const wrappedAsyncFn = tryIt(asyncFn);
const [error, result] = await wrappedAsyncFn(2);
expect(error).toBeUndefined();
expect(result).toBe(4);
});
it('handle asynchronous functions with errors', async () => {
const asyncFn = async () => {
throw new Error('Test error');
};
const wrappedAsyncFn = tryIt(asyncFn);
const [error, result] = await wrappedAsyncFn();
expect(error).toBeInstanceOf(Error);
expect(error?.message).toBe('Test error');
expect(result).toBeUndefined();
});
it('handle promise-based functions without errors', async () => {
const promiseFn = (x: number) => Promise.resolve(x * 2);
const wrappedPromiseFn = tryIt(promiseFn);
const [error, result] = await wrappedPromiseFn(2);
expect(error).toBeUndefined();
expect(result).toBe(4);
});
it('handle promise-based functions with errors', async () => {
const promiseFn = () => Promise.reject(new Error('Test error'));
const wrappedPromiseFn = tryIt(promiseFn);
const [error, result] = await wrappedPromiseFn();
expect(error).toBeInstanceOf(Error);
expect(error?.message).toBe('Test error');
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,42 @@
import { isPromise } from '../../types';
export type TryItReturn<Return> = Return extends Promise<any>
? Promise<[Error, undefined] | [undefined, Awaited<Return>]>
: [Error, undefined] | [undefined, Return];
/**
* @name tryIt
* @category Async
* @description Wraps promise-based code in a try/catch block without forking the control flow
*
* @param {Function} fn - The function to try
* @returns {Function} - The function that will return a tuple with the error and the result
*
* @example
* const wrappedFetch = tryIt(fetch);
* const [error, result] = await wrappedFetch('https://jsonplaceholder.typicode.com/todos/1');
*
* @example
* const [error, result] = await tryIt(fetch)('https://jsonplaceholder.typicode.com/todos/1');
*
* @since 0.0.3
*/
export function tryIt<Args extends any[], Return>(
fn: (...args: Args) => Return,
) {
return (...args: Args): TryItReturn<Return> => {
try {
const result = fn(...args);
if (isPromise(result))
return result
.then(value => [undefined, value])
.catch(error => [error, undefined]) as TryItReturn<Return>;
return [undefined, result] as TryItReturn<Return>;
}
catch (error) {
return [error, undefined] as TryItReturn<Return>;
}
};
}

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { flagsGenerator } from '.';
describe('flagsGenerator', () => {
it('generate unique flags', () => {
const generateFlag = flagsGenerator();
const flag1 = generateFlag();
const flag2 = generateFlag();
const flag3 = generateFlag();
expect(flag1).toBe(1);
expect(flag2).toBe(2);
expect(flag3).toBe(4);
});
it('throw an error if more than 31 flags are created', () => {
const generateFlag = flagsGenerator();
for (let i = 0; i < 31; i++) {
generateFlag();
}
expect(() => generateFlag()).toThrow(new RangeError('Cannot create more than 31 flags'));
});
});

View File

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

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