1
0
mirror of https://github.com/robonen/tools.git synced 2026-03-20 19:04:46 +00:00

2 Commits

291 changed files with 19163 additions and 4435 deletions

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)

View File

@@ -35,7 +35,7 @@ jobs:
run: pnpm build
- name: Lint
run: pnpm lint
run: pnpm lint:check
- name: Test
run: pnpm test

View File

@@ -41,6 +41,19 @@ compose(base, typescript, {
| `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`

View File

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

View File

@@ -16,7 +16,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "configs/oxlint"
},
"packageManager": "pnpm@10.29.3",
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},
@@ -27,12 +27,13 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"scripts": {
"lint": "oxlint -c oxlint.config.ts",
"lint:check": "oxlint -c oxlint.config.ts",
"lint:fix": "oxlint -c oxlint.config.ts --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
@@ -41,11 +42,18 @@
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:",
"oxlint": "catalog:",
"tsdown": "catalog:"
},
"peerDependencies": {
"oxlint": ">=1.0.0"
"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

@@ -31,6 +31,7 @@ function deepMerge(target: Record<string, unknown>, source: Record<string, unkno
* 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
@@ -60,6 +61,22 @@ export function compose(...configs: OxlintConfig[]): OxlintConfig {
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 };

View File

@@ -2,7 +2,7 @@
export { compose } from './compose';
/* Presets */
export { base, typescript, vue, vitest, imports, node } from './presets';
export { base, typescript, vue, vitest, imports, node, stylistic } from './presets';
/* Types */
export type {
@@ -10,6 +10,7 @@ export type {
OxlintOverride,
OxlintEnv,
OxlintGlobals,
ExternalPluginEntry,
AllowWarnDeny,
DummyRule,
DummyRuleMap,

View File

@@ -16,5 +16,7 @@ export const imports: OxlintConfig = {
'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

@@ -4,3 +4,4 @@ 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,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

@@ -11,6 +11,7 @@ export type {
OxlintOverride,
OxlintEnv,
OxlintGlobals,
ExternalPluginEntry,
AllowWarnDeny,
DummyRule,
DummyRuleMap,

View File

@@ -143,4 +143,29 @@ describe('compose', () => {
expect(result.env).toBeUndefined();
expect(result.settings).toBeUndefined();
});
it('should concatenate jsPlugins with dedup by specifier', () => {
const a: OxlintConfig = { jsPlugins: ['eslint-plugin-foo'] };
const b: OxlintConfig = { jsPlugins: ['eslint-plugin-foo', 'eslint-plugin-bar'] };
const result = compose(a, b);
expect(result.jsPlugins).toEqual(['eslint-plugin-foo', 'eslint-plugin-bar']);
});
it('should dedup jsPlugins with mixed string and object entries', () => {
const a: OxlintConfig = { jsPlugins: ['eslint-plugin-foo'] };
const b: OxlintConfig = { jsPlugins: [{ name: 'foo', specifier: 'eslint-plugin-foo' }] };
const result = compose(a, b);
expect(result.jsPlugins).toEqual(['eslint-plugin-foo']);
});
it('should keep jsPlugins and plugins independent', () => {
const a: OxlintConfig = { plugins: ['eslint'], jsPlugins: ['eslint-plugin-foo'] };
const b: OxlintConfig = { plugins: ['typescript'], jsPlugins: ['eslint-plugin-bar'] };
const result = compose(a, b);
expect(result.plugins).toEqual(['eslint', 'typescript']);
expect(result.jsPlugins).toEqual(['eslint-plugin-foo', 'eslint-plugin-bar']);
});
});

View File

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

View File

@@ -15,7 +15,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "packages/tsconfig"
},
"packageManager": "pnpm@10.29.3",
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},

View File

@@ -15,7 +15,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "configs/tsdown"
},
"packageManager": "pnpm@10.29.3",
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},

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

View File

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

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

View File

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

View File

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

View File

@@ -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',
},
});

View File

@@ -1,8 +1,8 @@
import { defineConfig } from 'oxlint';
import { compose, base, typescript, imports } from '@robonen/oxlint';
import { compose, base, typescript, imports, stylistic } from '@robonen/oxlint';
export default defineConfig(
compose(base, typescript, imports, {
compose(base, typescript, imports, stylistic, {
overrides: [
{
files: ['src/multi/global/index.ts'],

View File

@@ -18,7 +18,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "packages/platform"
},
"packageManager": "pnpm@10.29.3",
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},
@@ -39,7 +39,8 @@
}
},
"scripts": {
"lint": "oxlint -c oxlint.config.ts",
"lint:check": "oxlint -c oxlint.config.ts",
"lint:fix": "oxlint -c oxlint.config.ts --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
@@ -48,6 +49,7 @@
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:",
"oxlint": "catalog:",
"tsdown": "catalog:"
}

View File

@@ -29,7 +29,7 @@ export function focusGuard(namespace = 'focus-guard') {
};
const removeGuard = () => {
document.querySelectorAll(`[${guardAttr}]`).forEach((element) => element.remove());
document.querySelectorAll(`[${guardAttr}]`).forEach(element => element.remove());
};
return {

View File

@@ -7,8 +7,8 @@
*
* @since 0.0.1
*/
export const _global =
typeof globalThis !== 'undefined'
export const _global
= typeof globalThis !== 'undefined'
? globalThis
: typeof window !== 'undefined'
? window

View File

@@ -5,4 +5,3 @@ export default defineConfig({
environment: 'jsdom',
},
});

View File

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

View File

@@ -18,7 +18,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "packages/stdlib"
},
"packageManager": "pnpm@10.29.3",
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},
@@ -34,7 +34,8 @@
}
},
"scripts": {
"lint": "oxlint -c oxlint.config.ts",
"lint:check": "oxlint -c oxlint.config.ts",
"lint:fix": "oxlint -c oxlint.config.ts --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
@@ -43,6 +44,7 @@
"@robonen/oxlint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@stylistic/eslint-plugin": "catalog:",
"oxlint": "catalog:",
"tsdown": "catalog:"
}

View File

@@ -15,7 +15,7 @@ describe('sum', () => {
});
it('return the sum of all elements using a getValue function', () => {
const result = sum([{ value: 1 }, { value: 2 }, { value: 3 }], (item) => item.value);
const result = sum([{ value: 1 }, { value: 2 }, { value: 3 }], item => item.value);
expect(result).toBe(6);
});
@@ -39,7 +39,7 @@ describe('sum', () => {
});
it('handle arrays with a getValue function returning floating point numbers', () => {
const result = sum([{ value: 1.5 }, { value: 2.5 }, { value: 3.5 }], (item) => item.value);
const result = sum([{ value: 1.5 }, { value: 2.5 }, { value: 3.5 }], item => item.value);
expect(result).toBe(7.5);
});

View File

@@ -11,7 +11,7 @@ describe('unique', () => {
it('return an array with unique objects based on id', () => {
const result = unique(
[{ id: 1 }, { id: 2 }, { id: 1 }],
(item) => item.id,
item => item.id,
);
expect(result).toEqual([{ id: 1 }, { id: 2 }]);

View File

@@ -17,5 +17,5 @@
* @since 0.0.3
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@@ -13,7 +13,9 @@ describe('tryIt', () => {
});
it('handle synchronous functions with errors', () => {
const syncFn = (): void => { throw new Error('Test error') };
const syncFn = (): void => {
throw new Error('Test error');
};
const wrappedSyncFn = tryIt(syncFn);
const [error, result] = wrappedSyncFn();
@@ -34,7 +36,9 @@ describe('tryIt', () => {
});
it('handle asynchronous functions with errors', async () => {
const asyncFn = async () => { throw new Error('Test error') };
const asyncFn = async () => {
throw new Error('Test error');
};
const wrappedAsyncFn = tryIt(asyncFn);
const [error, result] = await wrappedAsyncFn();

View File

@@ -30,11 +30,12 @@ export function tryIt<Args extends any[], Return>(
if (isPromise(result))
return result
.then((value) => [undefined, value])
.catch((error) => [error, undefined]) as TryItReturn<Return>;
.then(value => [undefined, value])
.catch(error => [error, undefined]) as TryItReturn<Return>;
return [undefined, result] as TryItReturn<Return>;
} catch (error) {
}
catch (error) {
return [error, undefined] as TryItReturn<Return>;
}
};

View File

@@ -1,7 +1,6 @@
import { describe, it, expect } from 'vitest';
import { and, or, not, has, is, unset, toggle } from '.';
describe('flagsAnd', () => {
it('no effect on zero flags', () => {
const result = and();

View File

@@ -1,14 +1,14 @@
import type { Collection, Path } from '../../types';
export type ExtractFromObject<O extends Record<PropertyKey, unknown>, K> =
K extends keyof O
export type ExtractFromObject<O extends Record<PropertyKey, unknown>, K>
= K extends keyof O
? O[K]
: K extends keyof NonNullable<O>
? NonNullable<O>[K]
: never;
export type ExtractFromArray<A extends readonly any[], K> =
any[] extends A
export type ExtractFromArray<A extends readonly any[], K>
= any[] extends A
? A extends ReadonlyArray<infer T>
? T | undefined
: undefined
@@ -16,8 +16,8 @@ export type ExtractFromArray<A extends readonly any[], K> =
? A[K]
: undefined;
export type ExtractFromCollection<O, K> =
K extends []
export type ExtractFromCollection<O, K>
= K extends []
? O
: K extends [infer Key, ...infer Rest]
? O extends Record<PropertyKey, unknown>

View File

@@ -8,4 +8,4 @@ export * from './structs';
export * from './sync';
export * from './text';
export * from './types';
export * from './utils'
export * from './utils';

View File

@@ -20,7 +20,7 @@ import type { Arrayable } from '../../types';
*/
export function omit<Target extends object, Key extends keyof Target>(
target: Target,
keys: Arrayable<Key>
keys: Arrayable<Key>,
): Omit<Target, Key> {
const result = { ...target };
@@ -31,7 +31,8 @@ export function omit<Target extends object, Key extends keyof Target>(
for (const key of keys) {
delete result[key];
}
} else {
}
else {
delete result[keys];
}

View File

@@ -20,7 +20,7 @@ import type { Arrayable } from '../../types';
*/
export function pick<Target extends object, Key extends keyof Target>(
target: Target,
keys: Arrayable<Key>
keys: Arrayable<Key>,
): Pick<Target, Key> {
const result = {} as Pick<Target, Key>;
@@ -31,7 +31,8 @@ export function pick<Target extends object, Key extends keyof Target>(
for (const key of keys) {
result[key] = target[key];
}
} else {
}
else {
result[keys] = target[keys];
}

View File

@@ -205,11 +205,11 @@ describe('asyncCommandHistory', () => {
function addItemAsync(item: string): AsyncCommand {
return {
execute: async () => {
await new Promise((r) => setTimeout(r, 5));
await new Promise(r => setTimeout(r, 5));
items.push(item);
},
undo: async () => {
await new Promise((r) => setTimeout(r, 5));
await new Promise(r => setTimeout(r, 5));
items.pop();
},
};

View File

@@ -40,7 +40,7 @@ export class CommandHistory extends BaseCommandHistory<Command> {
batch(commands: Command[]): void {
const macro: Command = {
execute: () => commands.forEach((c) => c.execute()),
execute: () => commands.forEach(c => c.execute()),
undo: () => {
for (let i = commands.length - 1; i >= 0; i--)
commands[i]!.undo();

View File

@@ -92,7 +92,7 @@ export class PubSub<Events extends Record<string, (...args: any[]) => any>> {
if (!listeners)
return false;
listeners.forEach((listener) => listener(...args));
listeners.forEach(listener => listener(...args));
return true;
}

View File

@@ -39,7 +39,8 @@ export class AsyncStateMachine<
if (isString(transition)) {
target = transition;
} else {
}
else {
if (transition.guard && !(await transition.guard(this.context)))
return this.currentState;

View File

@@ -169,7 +169,7 @@ describe('stateMachine', () => {
on: {
FAIL: {
target: 'idle',
guard: (ctx) => ctx.retries < 3,
guard: ctx => ctx.retries < 3,
},
SUCCESS: 'done',
},
@@ -255,7 +255,7 @@ describe('stateMachine', () => {
on: {
UNLOCK: {
target: 'unlocked',
guard: (ctx) => ctx.unlocked,
guard: ctx => ctx.unlocked,
},
},
exit: exitHook,
@@ -374,7 +374,7 @@ describe('stateMachine', () => {
on: {
NEXT: {
target: 'c',
guard: (ctx) => ctx.step === 1,
guard: ctx => ctx.step === 1,
action: (ctx) => { ctx.step = 2; },
},
},
@@ -434,7 +434,7 @@ describe('asyncStateMachine', () => {
on: {
GO: {
target: 'active',
guard: async (ctx) => ctx.allowed,
guard: async ctx => ctx.allowed,
},
},
},
@@ -456,7 +456,7 @@ describe('asyncStateMachine', () => {
on: {
GO: {
target: 'active',
guard: async (ctx) => ctx.allowed,
guard: async ctx => ctx.allowed,
},
},
},
@@ -483,7 +483,7 @@ describe('asyncStateMachine', () => {
FETCH: {
target: 'done',
action: async (ctx) => {
await new Promise((r) => setTimeout(r, 10));
await new Promise(r => setTimeout(r, 10));
ctx.data = 'fetched';
order.push('action');
},
@@ -513,13 +513,13 @@ describe('asyncStateMachine', () => {
a: {
on: { GO: 'b' },
exit: async () => {
await new Promise((r) => setTimeout(r, 10));
await new Promise(r => setTimeout(r, 10));
order.push('exit-a');
},
},
b: {
entry: async () => {
await new Promise((r) => setTimeout(r, 10));
await new Promise(r => setTimeout(r, 10));
order.push('entry-b');
},
},
@@ -544,7 +544,7 @@ describe('asyncStateMachine', () => {
on: {
UNLOCK: {
target: 'unlocked',
guard: async (ctx) => ctx.unlocked,
guard: async ctx => ctx.unlocked,
},
},
exit: exitHook,
@@ -573,7 +573,7 @@ describe('asyncStateMachine', () => {
on: {
GO: {
target: 'active',
guard: async (ctx) => ctx.ready,
guard: async ctx => ctx.ready,
},
},
},
@@ -667,7 +667,7 @@ describe('asyncStateMachine', () => {
on: {
GO: {
target: 'active',
guard: (ctx) => ctx.count === 0,
guard: ctx => ctx.count === 0,
action: (ctx) => { ctx.count++; },
},
},

View File

@@ -39,7 +39,8 @@ export class StateMachine<
if (isString(transition)) {
target = transition;
} else {
}
else {
if (transition.guard && !transition.guard(this.context))
return this.currentState;

View File

@@ -106,7 +106,8 @@ export class LinkedList<T> implements LinkedListLike<T> {
node.prev = this.last;
this.last.next = node;
this.last = node;
} else {
}
else {
this.first = node;
this.last = node;
}
@@ -128,7 +129,8 @@ export class LinkedList<T> implements LinkedListLike<T> {
node.next = this.first;
this.first.prev = node;
this.first = node;
} else {
}
else {
this.first = node;
this.last = node;
}
@@ -196,7 +198,8 @@ export class LinkedList<T> implements LinkedListLike<T> {
if (node.prev) {
node.prev.next = newNode;
} else {
}
else {
this.first = newNode;
}
@@ -220,7 +223,8 @@ export class LinkedList<T> implements LinkedListLike<T> {
if (node.next) {
node.next.prev = newNode;
} else {
}
else {
this.last = newNode;
}
@@ -307,13 +311,15 @@ export class LinkedList<T> implements LinkedListLike<T> {
private detach(node: LinkedListNode<T>): void {
if (node.prev) {
node.prev.next = node.next;
} else {
}
else {
this.first = node.next;
}
if (node.next) {
node.next.prev = node.prev;
} else {
}
else {
this.last = node.prev;
}

View File

@@ -37,7 +37,7 @@ export function levenshteinDistance(left: string, right: string): number {
distanceMatrix[j]![i]! = Math.min(
distanceMatrix[j]![i - 1]! + 1, // deletion
distanceMatrix[j - 1]![i]! + 1, // insertion
distanceMatrix[j - 1]![i - 1]! + indicator // substitution
distanceMatrix[j - 1]![i - 1]! + indicator, // substitution
);
}
}

View File

@@ -38,9 +38,9 @@ describe.skip('templateObject', () => {
name: 'John Doe',
addresses: [
{ street: '123 Main St', city: 'Springfield' },
{ street: '456 Elm St', city: 'Shelbyville'}
]
}
{ street: '456 Elm St', city: 'Shelbyville' },
],
},
});
expect(result).toBe('Hello {John Doe, your address 123 Main St');

View File

@@ -25,8 +25,8 @@ const TEMPLATE_PLACEHOLDER = /\{\s*([^{}]+?)\s*\}/gm;
* type Unbalanced = ClearPlaceholder<'{user.name'>; // 'user.name'
* type Spaces = ClearPlaceholder<'{ user.name }'>; // 'user.name'
*/
export type ClearPlaceholder<In extends string> =
In extends `${string}{${infer Template}`
export type ClearPlaceholder<In extends string>
= In extends `${string}{${infer Template}`
? ClearPlaceholder<Template>
: In extends `${infer Template}}${string}`
? ClearPlaceholder<Template>
@@ -38,8 +38,8 @@ export type ClearPlaceholder<In extends string> =
* @example
* type Base = ExtractPlaceholders<'Hello {user.name}, {user.addresses.0.street}'>; // 'user.name' | 'user.addresses.0.street'
*/
export type ExtractPlaceholders<In extends string> =
In extends `${infer Before}}${infer After}`
export type ExtractPlaceholders<In extends string>
= In extends `${infer Before}}${infer After}`
? Before extends `${string}{${infer Placeholder}`
? ClearPlaceholder<Placeholder> | ExtractPlaceholders<After>
: ExtractPlaceholders<After>
@@ -56,7 +56,7 @@ export type GenerateTypes<T extends string, Target = string> = UnionToIntersecti
export function templateObject<
T extends string,
A extends GenerateTypes<ExtractPlaceholders<T>, TemplateValue> & Collection
A extends GenerateTypes<ExtractPlaceholders<T>, TemplateValue> & Collection,
>(template: T, args: A, fallback?: TemplateFallback) {
return template.replace(TEMPLATE_PLACEHOLDER, (_, key) => {
const value = get(args, key)?.toString();

View File

@@ -19,7 +19,7 @@ describe('trigramProfile', () => {
['orl', 1],
['rld', 1],
['ld\n', 1],
['d\n\n', 1]
['d\n\n', 1],
]));
});
@@ -37,7 +37,7 @@ describe('trigramProfile', () => {
['o h', 1],
[' he', 1],
['lo\n', 1],
['o\n\n', 1]
['o\n\n', 1],
]));
});

View File

@@ -6,8 +6,8 @@ export type Collection = Record<PropertyKey, any> | any[];
/**
* Parse a collection path string into an array of keys
*/
export type Path<T> =
T extends `${infer Key}.${infer Rest}`
export type Path<T>
= T extends `${infer Key}.${infer Rest}`
? [Key, ...Path<Rest>]
: T extends `${infer Key}`
? [Key]
@@ -16,8 +16,8 @@ export type Path<T> =
/**
* Convert a collection path array into a Target type
*/
export type PathToType<T extends string[], Target = unknown> =
T extends [infer Head, ...infer Rest]
export type PathToType<T extends string[], Target = unknown>
= T extends [infer Head, ...infer Rest]
? Head extends `${number}`
? Rest extends string[]
? Array<PathToType<Rest, Target>>

4
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.nuxt/
.output/
node_modules/
dist/

5
docs/app/app.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View File

@@ -0,0 +1,69 @@
@import "tailwindcss";
@source "../../../../vue/toolkit/src/**/demo.vue";
@source "../../../../core/stdlib/src/**/demo.vue";
@source "../../../../core/platform/src/**/demo.vue";
@theme {
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--color-brand-50: #eef2ff;
--color-brand-100: #e0e7ff;
--color-brand-200: #c7d2fe;
--color-brand-300: #a5b4fc;
--color-brand-400: #818cf8;
--color-brand-500: #6366f1;
--color-brand-600: #4f46e5;
--color-brand-700: #4338ca;
--color-brand-800: #3730a3;
--color-brand-900: #312e81;
--color-brand-950: #1e1b4b;
}
:root {
--color-bg: #ffffff;
--color-bg-soft: #f9fafb;
--color-bg-mute: #f3f4f6;
--color-border: #e5e7eb;
--color-text: #111827;
--color-text-soft: #4b5563;
--color-text-mute: #9ca3af;
--color-header-bg: rgba(255, 255, 255, 0.8);
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f172a;
--color-bg-soft: #1e293b;
--color-bg-mute: #334155;
--color-border: #334155;
--color-text: #f1f5f9;
--color-text-soft: #94a3b8;
--color-text-mute: #64748b;
--color-header-bg: rgba(15, 23, 42, 0.8);
}
}
body {
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code, pre {
font-family: var(--font-mono);
}
/* Shiki dual-theme: activate dark colors via prefers-color-scheme */
@media (prefers-color-scheme: dark) {
.shiki,
.shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
}

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">defineProps<{
kind: string;
size?: 'sm' | 'md';
}>();
const kindColors: Record<string, string> = {
function: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
class: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300',
interface: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
type: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
enum: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300',
variable: 'bg-slate-100 text-slate-700 dark:bg-slate-800/50 dark:text-slate-300',
};
const kindLabels: Record<string, string> = {
function: 'fn',
class: 'C',
interface: 'I',
type: 'T',
enum: 'E',
variable: 'V',
};
</script>
<template>
<span
:class="[
'inline-flex items-center justify-center rounded font-mono font-medium shrink-0',
kindColors[kind] ?? kindColors.variable,
size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs',
]"
:title="kind"
>
{{ kindLabels[kind] ?? '?' }}
</span>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">const props = defineProps<{
code: string;
lang?: string;
}>();
const { highlight } = useShiki();
const html = ref('');
onMounted(async () => {
html.value = await highlight(props.code, props.lang ?? 'typescript');
});
watch(() => props.code, async (newCode) => {
html.value = await highlight(newCode, props.lang ?? 'typescript');
});
</script>
<template>
<div class="code-block relative group rounded-lg border border-(--color-border) overflow-hidden max-w-full">
<div
v-if="html"
class="overflow-x-auto text-sm leading-relaxed [&_pre]:p-4 [&_pre]:m-0 [&_pre]:min-w-0"
v-html="html"
/>
<pre v-else class="p-4 text-sm bg-(--color-bg-soft) overflow-x-auto"><code>{{ code }}</code></pre>
</div>
</template>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">import type { Component } from 'vue';
const props = defineProps<{
component: Component;
source: string;
}>();
const showSource = ref(false);
const { highlighted, highlightReactive } = useShiki();
watch(showSource, async (show) => {
if (show && !highlighted.value) {
await highlightReactive(props.source, 'vue');
}
}, { immediate: false });
</script>
<template>
<div class="border border-(--color-border) rounded-lg overflow-hidden">
<!-- Live demo -->
<div class="p-6 bg-(--color-bg-soft)">
<component :is="component" />
</div>
<!-- Source toggle bar -->
<div class="flex items-center border-t border-(--color-border) bg-(--color-bg)">
<button
class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-(--color-text-mute) hover:text-(--color-text) transition-colors cursor-pointer"
@click="showSource = !showSource"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
{{ showSource ? 'Hide source' : 'View source' }}
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="transition-transform"
:class="showSource ? 'rotate-180' : ''"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
<!-- Source code -->
<div v-if="showSource" class="border-t border-(--color-border)">
<div class="overflow-x-auto text-sm" v-html="highlighted" />
</div>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">import type { MethodMeta } from '../../modules/extractor/types';
defineProps<{
methods: MethodMeta[];
}>();
</script>
<template>
<div v-if="methods.length > 0" class="space-y-4">
<div
v-for="method in methods"
:key="method.name"
class="border border-(--color-border) rounded-lg p-4"
>
<div class="flex items-center gap-2 mb-2">
<code class="text-sm font-mono font-semibold text-(--color-text)">{{ method.name }}</code>
<span
v-if="method.visibility !== 'public'"
class="text-[10px] uppercase px-1.5 py-0.5 rounded bg-(--color-bg-mute) text-(--color-text-mute)"
>
{{ method.visibility }}
</span>
</div>
<p v-if="method.description" class="text-sm text-(--color-text-soft) mb-3">
{{ method.description }}
</p>
<DocsCode
v-for="(sig, i) in method.signatures"
:key="i"
:code="sig"
class="mb-3"
/>
<DocsParamsTable v-if="method.params.length > 0" :params="method.params" />
<div v-if="method.returns" class="mt-2 text-sm">
<span class="text-(--color-text-mute)">Returns:</span>
<code class="ml-1 text-xs font-mono bg-(--color-bg-mute) px-1.5 py-0.5 rounded">{{ method.returns.type }}</code>
<span v-if="method.returns.description" class="ml-2 text-(--color-text-soft)">{{ method.returns.description }}</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">import type { ParamMeta } from '../../modules/extractor/types';
defineProps<{
params: ParamMeta[];
}>();
</script>
<template>
<div v-if="params.length > 0" class="overflow-x-auto max-w-full">
<table class="w-full text-sm border-collapse table-fixed">
<thead>
<tr class="border-b border-(--color-border)">
<th class="text-left py-2 pr-4 font-medium text-(--color-text-soft)">Parameter</th>
<th class="text-left py-2 pr-4 font-medium text-(--color-text-soft)">Type</th>
<th class="text-left py-2 pr-4 font-medium text-(--color-text-soft)">Default</th>
<th class="text-left py-2 font-medium text-(--color-text-soft)">Description</th>
</tr>
</thead>
<tbody>
<tr
v-for="param in params"
:key="param.name"
class="border-b border-(--color-border) last:border-b-0"
>
<td class="py-2 pr-4">
<code class="text-brand-600 font-mono text-xs">{{ param.name }}</code>
<span v-if="param.optional" class="text-(--color-text-mute) text-xs ml-1">?</span>
</td>
<td class="py-2 pr-4 max-w-48 overflow-hidden">
<code class="text-xs font-mono text-(--color-text-soft) bg-(--color-bg-mute) px-1.5 py-0.5 rounded break-all">{{ param.type }}</code>
</td>
<td class="py-2 pr-4">
<code v-if="param.defaultValue" class="text-xs font-mono text-(--color-text-mute)">{{ param.defaultValue }}</code>
<span v-else class="text-(--color-text-mute)"></span>
</td>
<td class="py-2 text-(--color-text-soft)">
{{ param.description || '—' }}
</td>
</tr>
</tbody>
</table>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">import type { PropertyMeta } from '../../modules/extractor/types';
defineProps<{
properties: PropertyMeta[];
}>();
</script>
<template>
<div v-if="properties.length > 0" class="overflow-x-auto max-w-full">
<table class="w-full text-sm border-collapse table-fixed">
<thead>
<tr class="border-b border-(--color-border)">
<th class="text-left py-2 pr-4 font-medium text-(--color-text-soft)">Property</th>
<th class="text-left py-2 pr-4 font-medium text-(--color-text-soft)">Type</th>
<th class="text-left py-2 pr-4 font-medium text-(--color-text-soft)">Default</th>
<th class="text-left py-2 font-medium text-(--color-text-soft)">Description</th>
</tr>
</thead>
<tbody>
<tr
v-for="prop in properties"
:key="prop.name"
class="border-b border-(--color-border) last:border-b-0"
>
<td class="py-2 pr-4">
<code class="text-brand-600 font-mono text-xs">{{ prop.name }}</code>
<span v-if="prop.readonly" class="text-(--color-text-mute) text-[10px] ml-1 uppercase">readonly</span>
<span v-if="prop.optional" class="text-(--color-text-mute) text-xs ml-1">?</span>
</td>
<td class="py-2 pr-4 max-w-48 overflow-hidden">
<code class="text-xs font-mono text-(--color-text-soft) bg-(--color-bg-mute) px-1.5 py-0.5 rounded break-all">{{ prop.type }}</code>
</td>
<td class="py-2 pr-4">
<code v-if="prop.defaultValue" class="text-xs font-mono text-(--color-text-mute)">{{ prop.defaultValue }}</code>
<span v-else class="text-(--color-text-mute)"></span>
</td>
<td class="py-2 text-(--color-text-soft)">
{{ prop.description || '—' }}
</td>
</tr>
</tbody>
</table>
</div>
</template>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">const { searchItems } = useDocs();
const isOpen = ref(false);
const query = ref('');
const results = computed(() => searchItems(query.value).slice(0, 20));
function open() {
isOpen.value = true;
nextTick(() => {
const input = document.querySelector<HTMLInputElement>('[data-search-input]');
input?.focus();
});
}
function close() {
isOpen.value = false;
query.value = '';
}
// Keyboard shortcut: Cmd+K / Ctrl+K
if (import.meta.client) {
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
if (isOpen.value) close();
else open();
}
if (e.key === 'Escape' && isOpen.value) {
close();
}
});
}
function useEventListener(target: Window, event: string, handler: (e: any) => void) {
onMounted(() => target.addEventListener(event, handler));
onUnmounted(() => target.removeEventListener(event, handler));
}
</script>
<template>
<div>
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm text-(--color-text-mute) bg-(--color-bg-mute) border border-(--color-border) rounded-lg hover:border-(--color-text-mute) transition-colors"
@click="open"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<span class="hidden sm:inline">Search...</span>
<kbd class="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono bg-(--color-bg) border border-(--color-border) rounded">
<span></span>K
</kbd>
</button>
<!-- Search modal -->
<Teleport to="body">
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="isOpen" class="fixed inset-0 z-50">
<div class="fixed inset-0 bg-black/50" @click="close" />
<div class="fixed inset-x-0 top-[10vh] mx-auto max-w-lg px-4">
<div class="bg-(--color-bg) rounded-xl border border-(--color-border) shadow-2xl overflow-hidden">
<div class="flex items-center px-4 border-b border-(--color-border)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-(--color-text-mute) shrink-0">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
v-model="query"
data-search-input
type="text"
placeholder="Search documentation..."
class="w-full py-3 px-3 bg-transparent text-(--color-text) placeholder:text-(--color-text-mute) focus:outline-none"
>
</div>
<div class="max-h-80 overflow-y-auto">
<div v-if="query && results.length === 0" class="py-8 text-center text-sm text-(--color-text-mute)">
No results found
</div>
<ul v-else-if="results.length > 0" class="py-2">
<li v-for="{ pkg, item } in results" :key="`${pkg.slug}-${item.slug}`">
<NuxtLink
:to="`/${pkg.slug}/${item.slug}`"
class="flex items-center gap-3 px-4 py-2.5 hover:bg-(--color-bg-mute) transition-colors"
@click="close"
>
<DocsBadge :kind="item.kind" size="sm" />
<div class="min-w-0">
<div class="text-sm font-medium text-(--color-text) truncate">
{{ item.name }}
</div>
<div class="text-xs text-(--color-text-mute) truncate">
{{ pkg.name }} · {{ item.description }}
</div>
</div>
</NuxtLink>
</li>
</ul>
<div v-else class="py-8 text-center text-sm text-(--color-text-mute)">
Type to search...
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">defineProps<{
label: string;
variant?: 'since' | 'test' | 'demo' | 'wip';
}>();
const variantClasses: Record<string, string> = {
since: 'bg-(--color-bg-mute) text-(--color-text-mute)',
test: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
demo: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400',
wip: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
};
</script>
<template>
<span
:class="[
'inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full',
variantClasses[variant ?? 'since'],
]"
>
{{ label }}
</span>
</template>

View File

@@ -0,0 +1,64 @@
import metadata from '#docs/metadata';
import type { DocsMetadata, PackageMeta, CategoryMeta, ItemMeta } from '../../modules/extractor/types';
export function useDocs() {
const data = metadata as unknown as DocsMetadata;
function getPackages(): PackageMeta[] {
return data.packages;
}
function getPackage(slug: string): PackageMeta | undefined {
return data.packages.find(p => p.slug === slug);
}
function getItem(packageSlug: string, itemSlug: string): { pkg: PackageMeta; category: CategoryMeta; item: ItemMeta } | undefined {
const pkg = getPackage(packageSlug);
if (!pkg) return undefined;
for (const category of pkg.categories) {
const item = category.items.find(i => i.slug === itemSlug);
if (item) return { pkg, category, item };
}
return undefined;
}
function searchItems(query: string): Array<{ pkg: PackageMeta; item: ItemMeta }> {
if (!query.trim()) return [];
const q = query.toLowerCase();
const results: Array<{ pkg: PackageMeta; item: ItemMeta }> = [];
for (const pkg of data.packages) {
for (const category of pkg.categories) {
for (const item of category.items) {
if (
item.name.toLowerCase().includes(q)
|| item.description.toLowerCase().includes(q)
) {
results.push({ pkg, item });
}
}
}
}
return results;
}
function getTotalItems(): number {
return data.packages.reduce(
(sum, pkg) => sum + pkg.categories.reduce(
(catSum, cat) => catSum + cat.items.length, 0,
), 0,
);
}
return {
data,
getPackages,
getPackage,
getItem,
searchItems,
getTotalItems,
};
}

View File

@@ -0,0 +1,42 @@
import { createHighlighter } from 'shiki';
import type { Highlighter } from 'shiki';
let highlighterPromise: Promise<Highlighter> | null = null;
function getHighlighter(): Promise<Highlighter> {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: ['github-light', 'github-dark'],
langs: ['typescript', 'vue', 'json', 'bash'],
});
}
return highlighterPromise;
}
export function useShiki() {
const highlighted = ref<string>('');
const isReady = ref(false);
async function highlight(code: string, lang: string = 'typescript'): Promise<string> {
const highlighter = await getHighlighter();
return highlighter.codeToHtml(code, {
lang,
themes: {
light: 'github-light',
dark: 'github-dark',
},
});
}
async function highlightReactive(code: string, lang: string = 'typescript'): Promise<void> {
highlighted.value = await highlight(code, lang);
isReady.value = true;
}
return {
highlight,
highlighted,
highlightReactive,
isReady,
};
}

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">const { getPackages } = useDocs();
const packages = getPackages();
const route = useRoute();
const isSidebarOpen = ref(false);
const currentPackageSlug = computed(() => {
const param = route.params.package;
return typeof param === 'string' ? param : undefined;
});
watch(() => route.path, () => {
isSidebarOpen.value = false;
});
</script>
<template>
<div class="min-h-screen flex flex-col">
<!-- Header -->
<header class="sticky top-0 z-50 border-b border-(--color-border) backdrop-blur-sm" style="background-color: var(--color-header-bg)">
<div class="mx-auto max-w-7xl flex items-center justify-between px-4 h-14 sm:px-6">
<div class="flex items-center gap-4">
<button
class="lg:hidden p-1.5 -ml-1.5 text-(--color-text-soft) hover:text-(--color-text)"
@click="isSidebarOpen = !isSidebarOpen"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<NuxtLink to="/" class="flex items-center gap-2 font-semibold text-(--color-text)">
<span class="text-brand-600">@robonen</span><span class="text-(--color-text-mute)">/</span><span>tools</span>
</NuxtLink>
</div>
<div class="flex items-center gap-3">
<DocsSearch />
<a
href="https://github.com/robonen/tools"
target="_blank"
rel="noopener noreferrer"
class="p-1.5 text-(--color-text-soft) hover:text-(--color-text) transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</a>
</div>
</div>
</header>
<div class="mx-auto max-w-7xl w-full flex flex-1 px-4 sm:px-6">
<!-- Sidebar -->
<aside
:class="[
'fixed inset-y-0 left-0 z-40 w-64 bg-(--color-bg) border-r border-(--color-border) pt-14 transform transition-transform lg:sticky lg:top-14 lg:z-auto lg:h-[calc(100vh-3.5rem)] lg:w-56 lg:shrink-0 lg:translate-x-0 lg:pt-0 lg:border-r-0 lg:bg-transparent lg:transform-none',
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
]"
>
<nav class="h-full overflow-y-auto py-6 px-4 lg:pr-6 lg:pl-0 overscroll-contain">
<div v-for="pkg in packages" :key="pkg.slug" class="mb-6">
<NuxtLink
:to="`/${pkg.slug}`"
class="block text-xs font-semibold uppercase tracking-wider mb-2"
:class="currentPackageSlug === pkg.slug ? 'text-brand-600' : 'text-(--color-text-mute) hover:text-(--color-text-soft)'"
>
{{ pkg.name }}
</NuxtLink>
<div v-for="category in pkg.categories" :key="category.slug" class="mb-3">
<div class="text-xs font-medium text-(--color-text-mute) mb-1 pl-2">
{{ category.name }}
</div>
<ul class="space-y-0.5">
<li v-for="item in category.items" :key="item.slug">
<NuxtLink
:to="`/${pkg.slug}/${item.slug}`"
class="block py-1 px-2 text-sm rounded-md transition-colors"
active-class="bg-brand-50 text-brand-700 dark:bg-brand-950 dark:text-brand-300 font-medium"
:class="route.path !== `/${pkg.slug}/${item.slug}` ? 'text-(--color-text-soft) hover:text-(--color-text) hover:bg-(--color-bg-mute)' : ''"
>
{{ item.name }}
</NuxtLink>
</li>
</ul>
</div>
</div>
</nav>
</aside>
<!-- Sidebar overlay -->
<div
v-if="isSidebarOpen"
class="fixed inset-0 z-30 bg-black/20 lg:hidden"
@click="isSidebarOpen = false"
/>
<!-- Main content -->
<main class="flex-1 min-w-0 overflow-hidden py-8 lg:pl-8">
<slot />
</main>
</div>
</div>
</template>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">import { demos } from '#docs/demos';
const route = useRoute();
const { getItem } = useDocs();
const packageSlug = computed(() => route.params.package as string);
const utilitySlug = computed(() => route.params.utility as string);
const result = computed(() => getItem(packageSlug.value, utilitySlug.value));
if (!result.value) {
throw createError({
statusCode: 404,
message: `Item "${utilitySlug.value}" not found in package "${packageSlug.value}"`,
});
}
const { pkg, category, item } = result.value;
useHead({
title: `${item.name}${pkg.name} — @robonen/tools`,
meta: [
{ name: 'description', content: item.description },
],
});
const sourceUrl = computed(() => `https://github.com/robonen/tools/blob/main/${item.sourcePath}`);
const demoComponent = computed(() => {
const key = `${pkg.slug}/${item.slug}`;
return demos[key] ?? null;
});
</script>
<template>
<div v-if="item" class="max-w-3xl min-w-0">
<!-- Breadcrumb -->
<nav class="flex items-center gap-1.5 text-sm text-(--color-text-mute) mb-6">
<NuxtLink :to="`/${pkg.slug}`" class="hover:text-(--color-text-soft) transition-colors">
{{ pkg.name }}
</NuxtLink>
<span>/</span>
<span>{{ category.name }}</span>
<span>/</span>
<span class="text-(--color-text)">{{ item.name }}</span>
</nav>
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-3 mb-2 flex-wrap">
<DocsBadge :kind="item.kind" size="md" />
<h1 class="text-2xl font-bold font-mono tracking-tight text-(--color-text)">
{{ item.name }}
</h1>
<DocsTag v-if="item.since" :label="`v${item.since}`" variant="since" />
<DocsTag v-if="item.hasTests" label="tested" variant="test" />
<DocsTag v-if="item.hasDemo" label="demo" variant="demo" />
</div>
<p class="text-(--color-text-soft) leading-relaxed">
{{ item.description }}
</p>
<!-- Links (source, tests) -->
<div class="flex items-center gap-4 mt-3 text-sm">
<a
:href="sourceUrl"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 text-(--color-text-mute) hover:text-(--color-text) transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>
Source
</a>
<a
v-if="item.hasTests"
:href="`${sourceUrl.replace('index.ts', 'index.test.ts')}`"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 text-(--color-text-mute) hover:text-(--color-text) transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<path d="m9 15 2 2 4-4" />
</svg>
Tests
</a>
</div>
</div>
<!-- Examples (moved to top for quick reference) -->
<section v-if="item.examples.length > 0" class="mb-8">
<h2 class="text-sm font-semibold uppercase tracking-wider text-(--color-text-mute) mb-3">
{{ item.examples.length > 1 ? 'Examples' : 'Example' }}
</h2>
<div class="space-y-3">
<DocsCode v-for="(example, i) in item.examples" :key="i" :code="example" />
</div>
</section>
<!-- Demo -->
<section v-if="item.hasDemo && demoComponent" class="mb-8">
<h2 class="text-sm font-semibold uppercase tracking-wider text-(--color-text-mute) mb-3">
Demo
</h2>
<DocsDemo :component="demoComponent" :source="item.demoSource" />
</section>
<!-- Signatures -->
<section v-if="item.signatures.length > 0" class="mb-8">
<h2 class="text-sm font-semibold uppercase tracking-wider text-(--color-text-mute) mb-3">
{{ item.signatures.length > 1 ? 'Signatures' : 'Signature' }}
</h2>
<div class="space-y-2">
<DocsCode v-for="(sig, i) in item.signatures" :key="i" :code="sig" />
</div>
</section>
<!-- Type Parameters -->
<section v-if="item.typeParams.length > 0" class="mb-8">
<h2 class="text-sm font-semibold uppercase tracking-wider text-(--color-text-mute) mb-3">
Type Parameters
</h2>
<div class="space-y-2">
<div
v-for="tp in item.typeParams"
:key="tp.name"
class="flex items-baseline gap-2 text-sm"
>
<code class="font-mono font-medium text-brand-600">{{ tp.name }}</code>
<span v-if="tp.constraint" class="text-(--color-text-mute)">
extends <code class="font-mono text-xs">{{ tp.constraint }}</code>
</span>
<span v-if="tp.default" class="text-(--color-text-mute)">
= <code class="font-mono text-xs">{{ tp.default }}</code>
</span>
</div>
</div>
</section>
<!-- Parameters -->
<section v-if="item.params.length > 0" class="mb-8">
<h2 class="text-sm font-semibold uppercase tracking-wider text-(--color-text-mute) mb-3">
Parameters
</h2>
<DocsParamsTable :params="item.params" />
</section>
<!-- Return value -->
<section v-if="item.returns" class="mb-8">
<h2 class="text-sm font-semibold uppercase tracking-wider text-(--color-text-mute) mb-3">
Returns
</h2>
<div class="flex items-baseline gap-2 text-sm">
<code class="font-mono bg-(--color-bg-mute) px-2 py-1 rounded text-xs break-all">{{ item.returns.type }}</code>
<span v-if="item.returns.description" class="text-(--color-text-soft)">{{ item.returns.description }}</span>
</div>
</section>
<!-- Properties (interfaces, classes) -->
<section v-if="item.properties.length > 0" class="mb-8">
<h2 class="text-sm font-semibold uppercase tracking-wider text-(--color-text-mute) mb-3">
Properties
</h2>
<DocsPropsTable :properties="item.properties" />
</section>
<!-- Methods (classes) -->
<section v-if="item.methods.length > 0" class="mb-8">
<h2 class="text-sm font-semibold uppercase tracking-wider text-(--color-text-mute) mb-3">
Methods
</h2>
<DocsMethodsList :methods="item.methods" />
</section>
<!-- Related Types -->
<section v-if="item.relatedTypes?.length" class="mb-8">
<h2 class="text-sm font-semibold uppercase tracking-wider text-(--color-text-mute) mb-3">
Related Types
</h2>
<div class="space-y-4">
<div
v-for="rt in item.relatedTypes"
:key="rt.name"
class="border border-(--color-border) rounded-lg p-4 bg-(--color-bg-soft)"
>
<div class="flex items-center gap-2 mb-2">
<DocsBadge :kind="rt.kind" size="sm" />
<h3 class="font-mono font-semibold text-sm">{{ rt.name }}</h3>
</div>
<p v-if="rt.description" class="text-sm text-(--color-text-soft) mb-3">
{{ rt.description }}
</p>
<DocsCode v-if="rt.signatures.length > 0" :code="rt.signatures[0]!" />
<DocsPropsTable v-if="rt.properties.length > 0" :properties="rt.properties" class="mt-3" />
</div>
</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">const route = useRoute();
const { getPackage } = useDocs();
const slug = computed(() => route.params.package as string);
const pkg = computed(() => getPackage(slug.value));
if (!pkg.value) {
throw createError({ statusCode: 404, message: `Package "${slug.value}" not found` });
}
useHead({
title: `${pkg.value.name} — @robonen/tools`,
});
</script>
<template>
<div v-if="pkg" class="max-w-3xl min-w-0">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-3 mb-2">
<h1 class="text-2xl font-bold tracking-tight text-(--color-text)">
{{ pkg.name }}
</h1>
<DocsTag :label="`v${pkg.version}`" variant="since" />
</div>
<p class="text-(--color-text-soft)">
{{ pkg.description }}
</p>
</div>
<!-- Install -->
<div class="mb-8">
<h2 class="text-sm font-semibold uppercase tracking-wider text-(--color-text-mute) mb-2">
Installation
</h2>
<DocsCode :code="`pnpm add ${pkg.name}`" lang="bash" />
</div>
<!-- Categories -->
<div v-for="category in pkg.categories" :key="category.slug" class="mb-8">
<h2 class="text-lg font-semibold text-(--color-text) mb-4 pb-2 border-b border-(--color-border)">
{{ category.name }}
</h2>
<div class="grid gap-2">
<NuxtLink
v-for="item in category.items"
:key="item.slug"
:to="`/${pkg.slug}/${item.slug}`"
class="group flex items-center gap-3 p-3 rounded-lg border border-(--color-border) hover:border-brand-300 hover:bg-(--color-bg-soft) transition-all"
>
<DocsBadge :kind="item.kind" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-mono text-sm font-medium text-(--color-text) group-hover:text-brand-600 transition-colors">
{{ item.name }}
</span>
<DocsTag v-if="item.since" :label="`v${item.since}`" variant="since" />
<DocsTag v-if="item.hasTests" label="tested" variant="test" />
<DocsTag v-if="item.hasDemo" label="demo" variant="demo" />
</div>
<p v-if="item.description" class="text-sm text-(--color-text-mute) mt-0.5 truncate">
{{ item.description }}
</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-(--color-text-mute) group-hover:text-brand-600 transition-colors shrink-0">
<polyline points="9 18 15 12 9 6" />
</svg>
</NuxtLink>
</div>
</div>
</div>
</template>

72
docs/app/pages/index.vue Normal file
View File

@@ -0,0 +1,72 @@
<script setup lang="ts">const { getPackages, getTotalItems } = useDocs();
const packages = getPackages();
const totalItems = getTotalItems();
const packageIcons: Record<string, string> = {
stdlib: '📦',
platform: '🖥️',
vue: '💚',
oxlint: '🔍',
};
</script>
<template>
<div class="max-w-3xl min-w-0">
<!-- Hero -->
<div class="mb-12">
<h1 class="text-3xl font-bold tracking-tight text-(--color-text) mb-3">
@robonen/tools
</h1>
<p class="text-lg text-(--color-text-soft) leading-relaxed">
A collection of TypeScript utilities, Vue composables, and developer tools.
Auto-generated documentation from source types and JSDoc annotations.
</p>
<div class="mt-4 flex items-center gap-4 text-sm text-(--color-text-mute)">
<span>{{ packages.length }} packages</span>
<span>·</span>
<span>{{ totalItems }} documented items</span>
</div>
</div>
<!-- Package cards -->
<div class="grid gap-4 sm:grid-cols-2">
<NuxtLink
v-for="pkg in packages"
:key="pkg.slug"
:to="`/${pkg.slug}`"
class="group block p-5 rounded-xl border border-(--color-border) hover:border-brand-300 hover:shadow-sm transition-all bg-(--color-bg)"
>
<div class="flex items-start gap-3">
<span class="text-2xl">{{ packageIcons[pkg.slug] ?? '📦' }}</span>
<div class="min-w-0">
<h2 class="font-semibold text-(--color-text) group-hover:text-brand-600 transition-colors">
{{ pkg.name }}
</h2>
<p class="text-sm text-(--color-text-soft) mt-1">
{{ pkg.description }}
</p>
<div class="mt-3 flex items-center gap-3 text-xs text-(--color-text-mute)">
<span>v{{ pkg.version }}</span>
<span>·</span>
<span>{{ pkg.categories.reduce((sum, c) => sum + c.items.length, 0) }} items</span>
<span>·</span>
<span>{{ pkg.categories.length }} categories</span>
</div>
</div>
</div>
</NuxtLink>
</div>
<!-- Quick start -->
<div class="mt-12">
<h2 class="text-lg font-semibold text-(--color-text) mb-4">
Quick Start
</h2>
<div class="space-y-3">
<div v-for="pkg in packages" :key="pkg.slug">
<DocsCode :code="`pnpm add ${pkg.name}`" lang="bash" />
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,700 @@
/**
* ts-morph based metadata extractor for @robonen/tools packages.
*
* Scans package source files, extracts JSDoc annotations and TypeScript signatures,
* and produces a structured JSON metadata file consumed by the Nuxt docs site.
*/
import { resolve, relative, dirname } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import { Project } from 'ts-morph';
import type { SourceFile, FunctionDeclaration, ClassDeclaration, InterfaceDeclaration, TypeAliasDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature } from 'ts-morph';
import type {
DocsMetadata,
PackageMeta,
CategoryMeta,
ItemMeta,
ParamMeta,
ReturnMeta,
TypeParamMeta,
MethodMeta,
PropertyMeta,
} from './types';
/** Repository root — docs/modules/extractor → three levels up */
const ROOT = resolve(import.meta.dirname, '..', '..', '..');
/** Packages to document — relative paths from repo root */
const PACKAGES: PackageConfig[] = [
{ path: 'core/stdlib', slug: 'stdlib' },
{ path: 'core/platform', slug: 'platform' },
{ path: 'vue/toolkit', slug: 'vue' },
{ path: 'configs/oxlint', slug: 'oxlint' },
];
interface PackageConfig {
path: string;
slug: string;
}
// ── Helpers ────────────────────────────────────────────────────────────────
function toKebabCase(str: string): string {
return str
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
.toLowerCase();
}
function slugify(name: string): string {
return toKebabCase(name);
}
function getJsDocTags(jsdocs: JSDoc[]): JSDocTag[] {
return jsdocs.flatMap(doc => doc.getTags());
}
function getTagValue(tags: JSDocTag[], tagName: string): string {
const tag = tags.find(t => t.getTagName() === tagName);
if (!tag) return '';
const comment = tag.getCommentText();
return comment?.trim() ?? '';
}
function getDescription(jsdocs: JSDoc[], tags: JSDocTag[]): string {
// Try @description tag first
const descTag = getTagValue(tags, 'description');
if (descTag) return descTag;
// Fall back to the main JSDoc comment text
for (const doc of jsdocs) {
const desc = doc.getDescription().trim();
if (desc) return desc;
}
return '';
}
function getExamples(tags: JSDocTag[]): string[] {
return tags
.filter(t => t.getTagName() === 'example')
.map((t) => {
const text = t.getCommentText()?.trim() ?? '';
// Strip surrounding ```ts ... ``` if present
return text.replace(/^```(?:ts|typescript)?\n?/, '').replace(/\n?```$/, '').trim();
})
.filter(Boolean);
}
function extractParams(tags: JSDocTag[], node: FunctionDeclaration | MethodDeclaration): ParamMeta[] {
const params: ParamMeta[] = [];
const paramTags = tags.filter(t => t.getTagName() === 'param');
for (const param of node.getParameters()) {
const name = param.getName();
const type = param.getType().getText(param);
const optional = param.isOptional();
const defaultValue = param.getInitializer()?.getText() ?? null;
// Find matching @param tag
const paramTag = paramTags.find((t) => {
const tagText = t.getText();
return tagText.includes(name);
});
let description = '';
if (paramTag) {
const comment = paramTag.getCommentText() ?? '';
// Remove leading {type} annotation and param name
description = comment
.replace(/^\{[^}]*\}\s*/, '')
.replace(new RegExp(`^${name}\\s*[-–—]?\\s*`), '')
.replace(new RegExp(`^${name}\\s+`), '')
.replace(/^[-–—]\s*/, '')
.trim();
}
params.push({ name, type, description, optional, defaultValue });
}
return params;
}
function extractTypeParams(node: FunctionDeclaration | ClassDeclaration | InterfaceDeclaration | TypeAliasDeclaration): TypeParamMeta[] {
const typeParams = node.getTypeParameters();
return typeParams.map(tp => ({
name: tp.getName(),
constraint: tp.getConstraint()?.getText() ?? null,
default: tp.getDefault()?.getText() ?? null,
description: '',
}));
}
function extractReturnMeta(tags: JSDocTag[], node: FunctionDeclaration | MethodDeclaration): ReturnMeta | null {
const returnType = node.getReturnType().getText(node);
if (returnType === 'void') return null;
const returnsTag = getTagValue(tags, 'returns') || getTagValue(tags, 'return');
const description = returnsTag.replace(/^\{[^}]*\}\s*/, '').trim();
return { type: returnType, description };
}
function extractMethodMeta(method: MethodDeclaration): MethodMeta {
const jsdocs = method.getJsDocs();
const tags = getJsDocTags(jsdocs);
const name = method.getName();
// Simplified signature
const signature = method.getText().split('{')[0]?.trim() ?? '';
const visibility = method.getScope() ?? 'public';
return {
name,
description: getDescription(jsdocs, tags),
signatures: [signature],
params: extractParams(tags, method),
returns: extractReturnMeta(tags, method),
visibility,
};
}
function extractPropertyMeta(prop: PropertyDeclaration | PropertySignature): PropertyMeta {
const jsdocs = prop.getJsDocs();
const tags = getJsDocTags(jsdocs);
return {
name: prop.getName(),
type: prop.getType().getText(prop),
description: getDescription(jsdocs, tags),
optional: prop.hasQuestionToken?.() ?? false,
defaultValue: getTagValue(tags, 'default') || null,
readonly: prop.isReadonly?.() ?? false,
};
}
function getSourceDir(itemPath: string): string {
// Get the directory containing the item's index.ts
return dirname(itemPath);
}
function hasDemoFile(sourceFilePath: string): boolean {
const dir = getSourceDir(sourceFilePath);
return existsSync(resolve(dir, 'demo.vue'));
}
function readDemoSource(sourceFilePath: string): string {
const dir = getSourceDir(sourceFilePath);
const demoPath = resolve(dir, 'demo.vue');
if (!existsSync(demoPath)) return '';
return readFileSync(demoPath, 'utf-8');
}
function hasTestFile(sourceFilePath: string): boolean {
const dir = getSourceDir(sourceFilePath);
return existsSync(resolve(dir, 'index.test.ts'));
}
// ── Extraction ─────────────────────────────────────────────────────────────
function extractFunction(fn: FunctionDeclaration, sourceFilePath: string, entryPoint: string): ItemMeta | null {
const name = fn.getName();
if (!name) return null;
// Skip private/internal functions
if (name.startsWith('_')) return null;
const jsdocs = fn.getJsDocs();
const tags = getJsDocTags(jsdocs);
const description = getDescription(jsdocs, tags);
// Get signature text without body
const signatureText = fn.getOverloads().length > 0
? fn.getOverloads().map(o => o.getText().trim())
: [fn.getText().split('{')[0]?.trim() + '{ ... }'];
return {
name,
slug: slugify(name),
kind: 'function',
description,
since: getTagValue(tags, 'since'),
signatures: signatureText,
params: extractParams(tags, fn),
returns: extractReturnMeta(tags, fn),
typeParams: extractTypeParams(fn),
examples: getExamples(tags),
methods: [],
properties: [],
hasDemo: hasDemoFile(sourceFilePath),
demoSource: readDemoSource(sourceFilePath),
hasTests: hasTestFile(sourceFilePath),
relatedTypes: [],
sourcePath: relative(ROOT, sourceFilePath),
entryPoint,
};
}
function extractClass(cls: ClassDeclaration, sourceFilePath: string, entryPoint: string): ItemMeta | null {
const name = cls.getName();
if (!name) return null;
const jsdocs = cls.getJsDocs();
const tags = getJsDocTags(jsdocs);
const methods = cls.getMethods()
.filter(m => (m.getScope() ?? 'public') === 'public')
.filter(m => !m.getName().startsWith('_'))
.map(extractMethodMeta);
const properties = cls.getProperties()
.filter(p => (p.getScope() ?? 'public') === 'public')
.map(p => extractPropertyMeta(p));
// Also include get accessors as readonly properties
const getters = cls.getGetAccessors()
.filter(g => (g.getScope() ?? 'public') === 'public')
.map(g => ({
name: g.getName(),
type: g.getReturnType().getText(g),
description: getDescription(g.getJsDocs(), getJsDocTags(g.getJsDocs())),
optional: false,
defaultValue: null,
readonly: true,
}));
// Build class signature
const typeParams = cls.getTypeParameters();
const typeParamStr = typeParams.length > 0
? `<${typeParams.map(tp => tp.getText()).join(', ')}>`
: '';
const extendsClause = cls.getExtends() ? ` extends ${cls.getExtends()!.getText()}` : '';
const implementsClause = cls.getImplements().length > 0
? ` implements ${cls.getImplements().map(i => i.getText()).join(', ')}`
: '';
const signature = `class ${name}${typeParamStr}${extendsClause}${implementsClause}`;
return {
name,
slug: slugify(name),
kind: 'class',
description: getDescription(jsdocs, tags),
since: getTagValue(tags, 'since'),
signatures: [signature],
params: [],
returns: null,
typeParams: extractTypeParams(cls),
examples: getExamples(tags),
methods,
properties: [...properties, ...getters],
hasDemo: hasDemoFile(sourceFilePath),
demoSource: readDemoSource(sourceFilePath),
hasTests: hasTestFile(sourceFilePath),
relatedTypes: [],
sourcePath: relative(ROOT, sourceFilePath),
entryPoint,
};
}
function extractInterface(iface: InterfaceDeclaration, sourceFilePath: string, entryPoint: string): ItemMeta | null {
const name = iface.getName();
if (!name) return null;
const jsdocs = iface.getJsDocs();
const tags = getJsDocTags(jsdocs);
const properties = iface.getProperties().map(p => extractPropertyMeta(p));
const typeParams = iface.getTypeParameters();
const typeParamStr = typeParams.length > 0
? `<${typeParams.map(tp => tp.getText()).join(', ')}>`
: '';
const extendsExprs = iface.getExtends();
const extendsStr = extendsExprs.length > 0
? ` extends ${extendsExprs.map(e => e.getText()).join(', ')}`
: '';
const signature = `interface ${name}${typeParamStr}${extendsStr}`;
return {
name,
slug: slugify(name),
kind: 'interface',
description: getDescription(jsdocs, tags),
since: getTagValue(tags, 'since'),
signatures: [signature],
params: [],
returns: null,
typeParams: extractTypeParams(iface),
examples: getExamples(tags),
methods: [],
properties,
hasDemo: hasDemoFile(sourceFilePath),
demoSource: readDemoSource(sourceFilePath),
hasTests: hasTestFile(sourceFilePath),
relatedTypes: [],
sourcePath: relative(ROOT, sourceFilePath),
entryPoint,
};
}
function extractTypeAlias(typeAlias: TypeAliasDeclaration, sourceFilePath: string, entryPoint: string): ItemMeta | null {
const name = typeAlias.getName();
if (!name) return null;
const jsdocs = typeAlias.getJsDocs();
const tags = getJsDocTags(jsdocs);
const signature = typeAlias.getText().trim();
return {
name,
slug: slugify(name),
kind: 'type',
description: getDescription(jsdocs, tags),
since: getTagValue(tags, 'since'),
signatures: [signature],
params: [],
returns: null,
typeParams: extractTypeParams(typeAlias),
examples: getExamples(tags),
methods: [],
properties: [],
hasDemo: hasDemoFile(sourceFilePath),
demoSource: readDemoSource(sourceFilePath),
hasTests: hasTestFile(sourceFilePath),
relatedTypes: [],
sourcePath: relative(ROOT, sourceFilePath),
entryPoint,
};
}
// ── Source Tree Walking ────────────────────────────────────────────────────
function collectExportedItems(
sourceFile: SourceFile,
entryPoint: string,
visited: Set<string> = new Set(),
): ItemMeta[] {
const filePath = sourceFile.getFilePath();
if (visited.has(filePath)) return [];
visited.add(filePath);
const items: ItemMeta[] = [];
// Direct exports from this file
for (const fn of sourceFile.getFunctions()) {
if (!fn.isExported()) continue;
// Skip implementation signatures that have overloads
const overloads = fn.getOverloads();
if (overloads.length > 0) {
// Use the first overload's doc for metadata, but collect all signatures
const firstOverload = overloads[0]!;
const jsdocs = firstOverload.getJsDocs();
const tags = getJsDocTags(jsdocs);
const name = fn.getName();
if (!name || name.startsWith('_')) continue;
const item: ItemMeta = {
name,
slug: slugify(name),
kind: 'function',
description: getDescription(jsdocs, tags),
since: getTagValue(tags, 'since'),
signatures: overloads.map(o => o.getText().trim()),
params: extractParams(tags, fn),
returns: extractReturnMeta(tags, fn),
typeParams: extractTypeParams(fn),
examples: getExamples(tags),
methods: [],
properties: [],
hasDemo: hasDemoFile(filePath),
demoSource: readDemoSource(filePath),
hasTests: hasTestFile(filePath),
relatedTypes: [],
sourcePath: relative(ROOT, filePath),
entryPoint,
};
items.push(item);
}
else {
const item = extractFunction(fn, filePath, entryPoint);
if (item) items.push(item);
}
}
for (const cls of sourceFile.getClasses()) {
if (!cls.isExported()) continue;
const item = extractClass(cls, filePath, entryPoint);
if (item) items.push(item);
}
for (const iface of sourceFile.getInterfaces()) {
if (!iface.isExported()) continue;
// Skip internal interfaces (e.g. Options, Return types that are documented inline)
const jsdocs = iface.getJsDocs();
const tags = getJsDocTags(jsdocs);
const hasCategory = getTagValue(tags, 'category') !== '';
// Only include interfaces with @category or that have significant documentation
if (!hasCategory && jsdocs.length === 0) continue;
const item = extractInterface(iface, filePath, entryPoint);
if (item) items.push(item);
}
for (const typeAlias of sourceFile.getTypeAliases()) {
if (!typeAlias.isExported()) continue;
const jsdocs = typeAlias.getJsDocs();
const tags = getJsDocTags(jsdocs);
const hasCategory = getTagValue(tags, 'category') !== '';
if (!hasCategory && jsdocs.length === 0) continue;
const item = extractTypeAlias(typeAlias, filePath, entryPoint);
if (item) items.push(item);
}
// Follow barrel re-exports: export * from './...'
for (const exportDecl of sourceFile.getExportDeclarations()) {
const moduleSpecifier = exportDecl.getModuleSpecifierValue();
if (!moduleSpecifier) continue;
const referencedFile = exportDecl.getModuleSpecifierSourceFile();
if (referencedFile) {
items.push(...collectExportedItems(referencedFile, entryPoint, visited));
}
}
return items;
}
// ── Co-located Type Grouping ───────────────────────────────────────────────
/**
* Groups types/interfaces from `types.ts` files with their sibling
* class/function items from the same directory.
*
* For example, Transition and TransitionConfig from StateMachine/types.ts
* get attached as relatedTypes of StateMachine and AsyncStateMachine.
*/
function groupCoLocatedTypes(items: ItemMeta[]): ItemMeta[] {
// Build a map: directory → items from types.ts
const typesByDir = new Map<string, ItemMeta[]>();
// Build a map: directory → primary items (classes, functions)
const primaryByDir = new Map<string, ItemMeta[]>();
for (const item of items) {
const dir = dirname(item.sourcePath);
const isTypesFile = item.sourcePath.endsWith('/types.ts');
const isSecondary = isTypesFile && (item.kind === 'type' || item.kind === 'interface');
if (isSecondary) {
const existing = typesByDir.get(dir) ?? [];
existing.push(item);
typesByDir.set(dir, existing);
}
else {
const existing = primaryByDir.get(dir) ?? [];
existing.push(item);
primaryByDir.set(dir, existing);
}
}
// Attach co-located types to their primary items
const absorbed = new Set<string>();
for (const entry of Array.from(typesByDir.entries())) {
const [dir, types] = entry;
const primaries = primaryByDir.get(dir);
if (!primaries || primaries.length === 0) continue;
// Distribute types to all primary items in the same directory
for (const primary of primaries) {
primary.relatedTypes = [...types];
}
for (const t of types) {
absorbed.add(`${t.entryPoint}:${t.name}`);
}
}
// Return items without the absorbed types
return items.filter(item => !absorbed.has(`${item.entryPoint}:${item.name}`));
}
// ── Package Extraction ─────────────────────────────────────────────────────
function extractPackage(config: PackageConfig): PackageMeta | null {
const pkgDir = resolve(ROOT, config.path);
const pkgJsonPath = resolve(pkgDir, 'package.json');
if (!existsSync(pkgJsonPath)) {
console.warn(`[extractor] package.json not found: ${pkgJsonPath}`);
return null;
}
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
const exports = pkgJson.exports ?? {};
// Determine entry points
const entryPoints: Array<{ subpath: string; filePath: string }> = [];
for (const [subpath, value] of Object.entries(exports)) {
if (typeof value === 'object' && value !== null) {
const entry = (value as Record<string, string>).import ?? (value as Record<string, string>).types;
if (entry) {
// Map dist path back to source path
// e.g. "./dist/index.js" → "src/index.ts" or "./dist/browsers.js" → "src/browsers/index.ts"
const srcPath = entry
.replace(/^\.\/dist\//, 'src/')
.replace(/\.js$/, '.ts')
.replace(/\.d\.ts$/, '.ts');
const fullPath = resolve(pkgDir, srcPath);
if (existsSync(fullPath)) {
entryPoints.push({ subpath, filePath: fullPath });
}
else {
// Try index.ts in subdirectory
const altPath = resolve(pkgDir, srcPath.replace(/\.ts$/, '/index.ts'));
if (existsSync(altPath)) {
entryPoints.push({ subpath, filePath: altPath });
}
else {
console.warn(`[extractor] Entry point not found: ${fullPath} or ${altPath}`);
}
}
}
}
}
if (entryPoints.length === 0) {
console.warn(`[extractor] No entry points found for ${pkgJson.name}`);
return null;
}
// Create ts-morph project for this package
const tsconfigPath = resolve(pkgDir, 'tsconfig.json');
const project = new Project({
tsConfigFilePath: existsSync(tsconfigPath) ? tsconfigPath : undefined,
skipAddingFilesFromTsConfig: true,
});
// Add entry files
for (const ep of entryPoints) {
project.addSourceFileAtPath(ep.filePath);
}
// Resolve all referenced files
project.resolveSourceFileDependencies();
// Extract items from all entry points
const allItems: ItemMeta[] = [];
for (const ep of entryPoints) {
const sourceFile = project.getSourceFile(ep.filePath);
if (!sourceFile) continue;
const items = collectExportedItems(sourceFile, ep.subpath);
allItems.push(...items);
}
// Deduplicate by name (overloaded functions may appear once already)
const seen = new Set<string>();
const uniqueItems = allItems.filter((item) => {
const key = `${item.entryPoint}:${item.name}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
// Group co-located types with their parent class/function.
// Types/interfaces from a types.ts file in the same directory as a
// class or function become relatedTypes of that primary item.
const groupedItems = groupCoLocatedTypes(uniqueItems);
// Group by category
const categoryMap = new Map<string, ItemMeta[]>();
for (const item of groupedItems) {
// Infer category from source path if not set
const jsdocCategory = inferCategoryFromItem(item);
const existing = categoryMap.get(jsdocCategory) ?? [];
existing.push(item);
categoryMap.set(jsdocCategory, existing);
}
const categories: CategoryMeta[] = Array.from(categoryMap.entries())
.map(([name, items]) => ({
name,
slug: slugify(name),
items: items.sort((a, b) => a.name.localeCompare(b.name)),
}))
.sort((a, b) => a.name.localeCompare(b.name));
return {
name: pkgJson.name,
version: pkgJson.version,
description: pkgJson.description ?? '',
slug: config.slug,
entryPoints: entryPoints.map(ep => ep.subpath),
categories,
};
}
function inferCategoryFromItem(item: ItemMeta): string {
// Parse from source path
const parts = item.sourcePath.split('/src/');
if (parts.length < 2) return 'General';
const segments = parts[1]!.split('/');
// Patterns:
// composables/browser/useIntervalFn/index.ts → "Browser"
// arrays/cluster/index.ts → "Arrays"
// patterns/behavioral/PubSub/index.ts → "Patterns"
// types/js/primitives.ts → "Types"
// structs/Stack/index.ts → "Data Structures" (use @category if available)
if (segments[0] === 'composables' && segments.length >= 3) {
const cat = segments[1]!;
return cat.charAt(0).toUpperCase() + cat.slice(1);
}
if (segments[0] && segments.length >= 2) {
const cat = segments[0];
return cat.charAt(0).toUpperCase() + cat.slice(1);
}
return 'General';
}
// ── Main ───────────────────────────────────────────────────────────────────
export function extract(): DocsMetadata {
console.log('[extractor] Starting metadata extraction...');
const packages: PackageMeta[] = [];
for (const config of PACKAGES) {
console.log(`[extractor] Processing ${config.path}...`);
const pkg = extractPackage(config);
if (pkg) {
const itemCount = pkg.categories.reduce((sum, c) => sum + c.items.length, 0);
console.log(`[extractor] → ${pkg.name}@${pkg.version}: ${itemCount} items in ${pkg.categories.length} categories`);
packages.push(pkg);
}
}
const metadata: DocsMetadata = {
packages,
generatedAt: new Date().toISOString(),
};
const totalItems = packages.reduce(
(sum, pkg) => sum + pkg.categories.reduce((s, c) => s + c.items.length, 0),
0,
);
console.log(`[extractor] Done — ${totalItems} items across ${packages.length} packages`);
return metadata;
}
// Allow running directly — prints metadata as JSON to stdout
if (import.meta.filename === process.argv[1]) {
const metadata = extract();
console.log(JSON.stringify(metadata, null, 2));
}

View File

@@ -0,0 +1,147 @@
/**
* Nuxt module that extracts TypeScript metadata from source packages
* and provides it as a virtual module `#docs/metadata`.
*
* Runs extraction at build start and makes the data available to all
* pages/components via `import metadata from '#docs/metadata'`.
*/
import { defineNuxtModule, addTemplate, createResolver } from '@nuxt/kit';
import { resolve, dirname } from 'node:path';
import type { DocsMetadata } from './types';
export default defineNuxtModule({
meta: {
name: 'docs-extractor',
configKey: 'docsExtractor',
},
async setup(_options, nuxt) {
const { resolve: resolveModule } = createResolver(import.meta.url);
const ROOT = resolve(import.meta.dirname, '..', '..', '..');
// Run extraction immediately during setup so metadata is available
// when templates are resolved (app:templates phase runs before build:before)
console.log('[docs-extractor] Running metadata extraction...');
const { extract } = await import('./extract');
const metadata: DocsMetadata = extract();
// Add Vite resolve aliases for source packages so demo.vue imports resolve.
// The vue/toolkit package uses `@/` path aliases (e.g. `@/composables/...`).
// We prepend them via vite:extendConfig so they take priority over Nuxt's
// built-in `@` → srcDir alias.
const vueSrc = resolve(ROOT, 'vue/toolkit/src');
nuxt.hook('vite:extendConfig', (config) => {
const existing = config.resolve?.alias;
const sourceAliases = [
{ find: '@/composables', replacement: resolve(vueSrc, 'composables') },
{ find: '@/types', replacement: resolve(vueSrc, 'types') },
{ find: '@/utils', replacement: resolve(vueSrc, 'utils') },
];
if (Array.isArray(existing)) {
existing.unshift(...sourceAliases);
}
else {
config.resolve ??= {};
config.resolve.alias = [
...sourceAliases,
...Object.entries(existing ?? {}).map(([find, replacement]) => ({ find, replacement: replacement as string })),
];
}
});
// Provide metadata as a virtual template
addTemplate({
filename: 'docs-metadata.ts',
write: true,
getContents: () => {
const json = JSON.stringify(metadata, null, 2);
return `export default ${json} as const;`;
},
});
// Register the alias for the virtual module
nuxt.options.alias['#docs/metadata'] = resolve(nuxt.options.buildDir, 'docs-metadata');
// Add types reference
addTemplate({
filename: 'docs-metadata-types.d.ts',
write: true,
getContents: () => {
const typesPath = resolveModule('./types');
return `
import type { DocsMetadata } from '${typesPath}';
declare module '#docs/metadata' {
const metadata: DocsMetadata;
export default metadata;
}
`;
},
});
// Register prerender routes from metadata
nuxt.hook('prerender:routes', async ({ routes }: { routes: Set<string> }) => {
if (metadata.packages.length === 0) return;
for (const pkg of metadata.packages) {
routes.add(`/${pkg.slug}`);
for (const category of pkg.categories) {
for (const item of category.items) {
routes.add(`/${pkg.slug}/${item.slug}`);
}
}
}
console.log(`[docs-extractor] Registered ${routes.size} routes for prerender`);
});
// Generate demo component import map
addTemplate({
filename: 'docs-demos.ts',
write: true,
getContents: () => {
const entries: string[] = [];
for (const pkg of metadata.packages) {
for (const cat of pkg.categories) {
for (const item of cat.items) {
if (item.hasDemo) {
const demoPath = resolve(ROOT, dirname(item.sourcePath), 'demo.vue');
entries.push(` '${pkg.slug}/${item.slug}': defineAsyncComponent(() => import('${demoPath}')),`);
}
}
}
}
if (entries.length === 0) {
return `import type { Component } from 'vue';\nexport const demos: Record<string, Component> = {};\n`;
}
return [
`import { defineAsyncComponent } from 'vue';`,
`import type { Component } from 'vue';`,
``,
`export const demos: Record<string, Component> = {`,
...entries,
`};`,
``,
].join('\n');
},
});
nuxt.options.alias['#docs/demos'] = resolve(nuxt.options.buildDir, 'docs-demos');
addTemplate({
filename: 'docs-demos-types.d.ts',
write: true,
getContents: () => `
import type { Component } from 'vue';
declare module '#docs/demos' {
export const demos: Record<string, Component>;
}
`,
});
},
});

View File

@@ -0,0 +1,112 @@
/**
* Metadata types for the documentation extractor.
* These types represent the structured data extracted from source packages
* via ts-morph, used to generate documentation pages.
*/
export interface DocsMetadata {
packages: PackageMeta[];
generatedAt: string;
}
export interface PackageMeta {
/** Package name from package.json, e.g. "@robonen/stdlib" */
name: string;
/** Package version */
version: string;
/** Package description from package.json */
description: string;
/** URL-friendly slug derived from package name, e.g. "stdlib" */
slug: string;
/** Subpath export entries (e.g. "." or "./browsers") */
entryPoints: string[];
/** All documented items grouped by category */
categories: CategoryMeta[];
}
export interface CategoryMeta {
/** Category name from @category JSDoc tag or directory name */
name: string;
/** URL-friendly slug */
slug: string;
/** Items in this category */
items: ItemMeta[];
}
export interface ItemMeta {
/** Export name */
name: string;
/** URL-friendly slug (camelCase → kebab-case) */
slug: string;
/** Kind of export */
kind: 'function' | 'class' | 'type' | 'interface' | 'enum' | 'variable';
/** Description from @description tag or first JSDoc line */
description: string;
/** Version when this item was introduced */
since: string;
/** Full TypeScript signature(s) — supports overloads */
signatures: string[];
/** Function/method parameters */
params: ParamMeta[];
/** Return type description */
returns: ReturnMeta | null;
/** Template/generic type parameters */
typeParams: TypeParamMeta[];
/** Code examples from @example tags */
examples: string[];
/** Class methods (only for kind === 'class') */
methods: MethodMeta[];
/** Class/interface properties (only for kind === 'class' or 'interface') */
properties: PropertyMeta[];
/** Whether a demo.vue file exists alongside */
hasDemo: boolean;
/** Raw source code of the demo.vue file (for syntax-highlighted display) */
demoSource: string;
/** Whether an index.test.ts file exists alongside */
hasTests: boolean;
/** Related types/interfaces co-located in the same module directory */
relatedTypes: ItemMeta[];
/** Relative path to the source file from repo root */
sourcePath: string;
/** Subpath export this belongs to (e.g. "." or "./browsers") */
entryPoint: string;
}
export interface ParamMeta {
name: string;
type: string;
description: string;
optional: boolean;
defaultValue: string | null;
}
export interface ReturnMeta {
type: string;
description: string;
}
export interface TypeParamMeta {
name: string;
constraint: string | null;
default: string | null;
description: string;
}
export interface MethodMeta {
name: string;
description: string;
signatures: string[];
params: ParamMeta[];
returns: ReturnMeta | null;
/** Visibility: public, protected, private */
visibility: string;
}
export interface PropertyMeta {
name: string;
type: string;
description: string;
optional: boolean;
defaultValue: string | null;
readonly: boolean;
}

53
docs/nuxt.config.ts Normal file
View File

@@ -0,0 +1,53 @@
import tailwindcss from '@tailwindcss/vite';
export default defineNuxtConfig({
future: {
compatibilityVersion: 4,
},
modules: [
'@nuxt/fonts',
'./modules/extractor',
],
vite: {
plugins: [
tailwindcss() as any,
],
},
css: ['~/assets/css/main.css'],
ssr: true,
routeRules: {
'/**': { prerender: true },
},
nitro: {
prerender: {
crawlLinks: true,
},
},
fonts: {
families: [
{ name: 'Inter', provider: 'google', weights: [400, 500, 600, 700] },
{ name: 'JetBrains Mono', provider: 'google', weights: [400, 500] },
],
},
app: {
head: {
title: '@robonen/tools — Documentation',
meta: [
{ name: 'description', content: 'Auto-generated documentation for @robonen/tools monorepo' },
],
htmlAttrs: {
lang: 'en',
},
},
},
compatibilityDate: '2026-02-15',
});

4
docs/oxlint.config.ts Normal file
View File

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

34
docs/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "@robonen/docs",
"version": "0.0.1",
"private": true,
"license": "Apache-2.0",
"description": "Auto-generated documentation site for @robonen/tools",
"type": "module",
"scripts": {
"lint:check": "oxlint -c oxlint.config.ts",
"lint:fix": "oxlint -c oxlint.config.ts --fix",
"dev": "nuxt dev",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview",
"extract": "jiti ./modules/extractor/extract.ts"
},
"dependencies": {
"shiki": "^4.0.1"
},
"devDependencies": {
"@nuxt/fonts": "^0.14.0",
"@nuxt/kit": "^4.3.1",
"@robonen/oxlint": "workspace:*",
"@stylistic/eslint-plugin": "catalog:",
"@tailwindcss/vite": "^4.2.1",
"jiti": "^2.6.1",
"nuxt": "catalog:",
"oxlint": "catalog:",
"tailwindcss": "^4.2.1",
"ts-morph": "^27.0.2",
"vue": "catalog:",
"vue-router": "^5.0.3"
}
}

3
docs/tsconfig.json Normal file
View File

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

View File

@@ -16,7 +16,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "packages/renovate"
},
"packageManager": "pnpm@10.29.3",
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},
@@ -27,6 +27,6 @@
"test": "renovate-config-validator ./default.json"
},
"devDependencies": {
"renovate": "^43.12.0"
"renovate": "^43.59.2"
}
}

View File

@@ -15,13 +15,13 @@
"type": "git",
"url": "git+https://github.com/robonen/tools.git"
},
"packageManager": "pnpm@10.29.3",
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},
"type": "module",
"devDependencies": {
"@types/node": "^24.10.13",
"@types/node": "^25.3.5",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"citty": "^0.2.1",
@@ -32,9 +32,14 @@
},
"scripts": {
"build": "pnpm -r build",
"lint": "pnpm -r lint",
"lint:check": "pnpm -r lint:check",
"lint:fix": "pnpm -r lint:fix",
"test": "vitest run",
"test:ui": "vitest --ui",
"create": "jiti ./bin/cli.ts"
"create": "jiti ./bin/cli.ts",
"docs:dev": "pnpm -C docs dev",
"docs:generate": "pnpm -C docs generate",
"docs:preview": "pnpm -C docs preview",
"docs:extract": "pnpm -C docs extract"
}
}

10148
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,16 +2,22 @@ packages:
- configs/*
- core/*
- infra/*
- web/*
- vue/*
- docs
catalog:
'@stylistic/eslint-plugin': ^5.10.0
'@vitest/coverage-v8': ^4.0.18
'@vue/test-utils': ^2.4.6
jsdom: ^28.0.0
oxlint: ^1.2.0
tsdown: ^0.12.5
vitest: ^4.0.18
'@vitest/ui': ^4.0.18
vue: ^3.5.28
'@vue/shared': ^3.5.29
'@vue/test-utils': ^2.4.6
jsdom: ^28.1.0
nuxt: ^4.3.1
oxlint: ^1.51.0
tsdown: ^0.21.0
vitest: ^4.0.18
vue: ^3.5.29
ignoredBuiltDependencies:
- '@parcel/watcher'
- esbuild

View File

@@ -16,7 +16,10 @@
"path": "infra/renovate"
},
{
"path": "web/vue"
"path": "vue/toolkit"
},
{
"path": "vue/primitives"
}
]
}

View File

@@ -6,11 +6,12 @@ export default defineConfig({
'configs/oxlint/vitest.config.ts',
'core/stdlib/vitest.config.ts',
'core/platform/vitest.config.ts',
'web/vue/vitest.config.ts',
'vue/toolkit/vitest.config.ts',
'vue/primitives/vitest.config.ts',
],
coverage: {
provider: 'v8',
include: ['core/*', 'web/*'],
include: ['core/*', 'vue/*'],
exclude: ['**/node_modules/**', '**/dist/**'],
},
},

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