commit be635b18285e68199644ab106bc74eb090e701e1
Author: achraf
Date: Tue Jun 2 19:32:51 2026 +0200
First implementation of Plimi
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ecb13c8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,26 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+ignored
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..8f1866a
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,6 @@
+{
+ "semi": true,
+ "singleQuote": false,
+ "tabWidth": 2,
+ "trailingComma": "es5"
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5e16118
--- /dev/null
+++ b/README.md
@@ -0,0 +1,215 @@
+# Plimi: Privacy-First Browser Toolbox
+
+Plimi is a modern, modular, zero-backend utility suite that executes all tools directly inside your browser. No data ever leaves your device.
+
+## Features
+- **Privacy-First Design**: Completely offline-capable. No data is sent to external servers.
+- **Plugin Architecture**: Modular, strictly-typed plugin ecosystem supporting custom UI or generated UI based on schema.
+- **Web Workers & WASM**: Supports off-loading expensive operations (e.g. PDF manipulation, media processing) into separate threads.
+- **High Performance**: Built with React, Vite, and Tailwind CSS v4.
+
+---
+
+## Getting Started
+
+### Prerequisites
+- Node.js 18+
+- npm or pnpm
+
+### Installation & Run Dev Server
+```bash
+npm install
+npm run dev
+```
+
+### Run Unit Tests
+```bash
+npm test
+```
+
+### Production Build
+```bash
+npm run build
+```
+
+---
+
+## How to Create a Tool (Plugin)
+
+Adding a new tool to Plimi is seamless thanks to its generic rendering architecture. By default, Plimi generates the tool's interface based on its options schema and input/output definitions.
+
+### Step 1: Create the Plugin Definition
+Create a new directory in `src/tools/` (e.g., `src/tools/my-tool/index.ts`):
+
+```typescript
+import type { PlimiPlugin } from '../../core/plugins/plugin-types';
+import { runMyTool } from './run';
+
+export interface MyToolOptions {
+ uppercase: boolean;
+ prefix: string;
+}
+
+export const myToolPlugin: PlimiPlugin = {
+ manifest: {
+ id: 'my-tool',
+ name: 'My Custom Tool',
+ description: 'Does something awesome entirely in the browser.',
+ category: 'developer',
+ version: '1.0.0',
+ tags: ['custom', 'developer', 'text'],
+
+ // 1. Defining Inputs (see Input Options below)
+ input: {
+ type: 'text',
+ multiline: false, // Renders a single-line input field instead of a textarea
+ placeholder: 'Type something...',
+ example: 'Hello Plimi'
+ },
+
+ // 2. Defining Outputs (text, json, table, files)
+ output: { type: 'text' },
+ offlineReady: true,
+ },
+
+ // 3. User options generated in the right panel automatically
+ optionsSchema: {
+ fields: [
+ {
+ type: 'boolean',
+ key: 'uppercase',
+ label: 'Uppercase Output',
+ defaultValue: false
+ },
+ {
+ type: 'text',
+ key: 'prefix',
+ label: 'Output Prefix',
+ defaultValue: 'Result: ',
+ placeholder: 'e.g. Result: '
+ }
+ ]
+ },
+
+ // Optional: override or add user-facing examples.
+ // If omitted, Plimi automatically builds a "Try example" action from
+ // manifest.example for single-input tools or field.example for grouped inputs.
+ examples: [
+ {
+ label: 'Try greeting',
+ input: { text: 'Hello Plimi' },
+ options: { uppercase: true, prefix: 'Result: ' }
+ }
+ ],
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runMyTool,
+};
+```
+
+---
+
+### Step 2: Implement the Runner Logic
+Create the runner file (e.g., `src/tools/my-tool/run.ts`):
+
+```typescript
+import { getTextInput, type ToolInput } from '../../core/io/input-types';
+import type { ToolResult } from '../../core/io/output-types';
+import type { ToolContext } from '../../core/plugins/plugin-types';
+import type { MyToolOptions } from './index';
+
+export async function runMyTool(
+ input: ToolInput,
+ options: MyToolOptions,
+ context: ToolContext
+): Promise {
+ // Use getTextInput helper for safe retrieval
+ const text = getTextInput(input);
+ if (!text) {
+ throw new Error("No text provided.");
+ }
+
+ // Report progress back to the UI progress bar (if needed)
+ context.reportProgress({ percentage: 50, message: "Processing text..." });
+
+ let result = text;
+ if (options.uppercase) {
+ result = result.toUpperCase();
+ }
+
+ return {
+ type: 'text',
+ value: `${options.prefix}${result}`,
+ };
+}
+```
+
+---
+
+### Step 3: Register the Plugin
+Import your new plugin into `src/core/plugins/plugin-registry.ts` and append it to the `pluginRegistry` array:
+
+```typescript
+import { myToolPlugin } from "../../tools/my-tool";
+
+export const pluginRegistry: PlimiPlugin[] = [
+ // ... existing plugins
+ myToolPlugin,
+];
+```
+The application will automatically register the tool, list it in the directory, enable search, and construct its input and options UI!
+
+---
+
+## UI Abstraction Details
+
+### Input Schema Configuration
+The input property in the manifest supports three structures:
+1. **`type: "none"`**: No input field is rendered (useful for generators, e.g., Lorem Ipsum).
+2. **Single Field**: Renders a single input field.
+ - `type: "text"`: Renders a text entry box.
+ - Specify `multiline: false` to make it a compact single-line input field (default is a textarea).
+ - Specify `rows: number` to customize multiline text area height.
+ - `type: "files"`: Renders a drag-and-drop file uploader area.
+3. **`type: "group"`**: Renders a group of input fields stacked vertically. Each field requires a unique `key`.
+ - Plimi will automatically expose a shared **"Try example"** action when fields contain example data, allowing the user to populate the form with one click.
+ - Access group values inside the runner using `getTextInput(input, "field_key")`.
+
+### Output Visualizers
+Plimi automatically formats results based on the returned `ToolResult.type`:
+- **`type: "text"`**: Renders a code block or plain text area with a global copy button.
+- **`type: "files"`**: Renders a lists of downloadable files with size comparisons.
+- **`type: "table"`**: Renders a tabular data format with column headers, row styles, and a **"Copy CSV"** button.
+- **`type: "json"`**: Renders a flat grid of cards for each key.
+ - Return a key named `"primary"` in your JSON output to showcase it prominently in a large card at the top of the results panel.
+ - Each key in the grid renders with its own individual, hoverable **"Copy"** button for a premium experience.
+
+---
+
+### Writing Custom UIs
+For complex interfaces (like Canvas-based image manipulation or interactive dropzones), set `customUi: true` under `capabilities` in the manifest and supply a React component to the `customUi` key:
+
+```typescript
+import { lazy } from "react";
+const CustomUiComponent = lazy(() => import("./CustomUiComponent"));
+
+export const myPlugin = {
+ // ...
+ capabilities: { customUi: true },
+ customUi: CustomUiComponent
+};
+```
+Your component will receive the plugin definition as `plugin`, the current `dark` theme boolean, and normalized `examples` as props. Custom UIs can import `ExampleActionButton` from `src/components/tool/ExampleActionButton` to render the same "Try example" action style used by generated tools.
+
+---
+
+### Writing Tests
+Every tool should be tested. Create a `run.test.ts` alongside your tool and run:
+```bash
+npm test
+```
+See `src/tools/regex-tester/run.test.ts` for an example of testing both key-based inputs and fallback single-string values.
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..da6f026
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,22 @@
+import js from "@eslint/js";
+import globals from "globals";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import tseslint from "typescript-eslint";
+import { defineConfig, globalIgnores } from "eslint/config";
+
+export default defineConfig([
+ globalIgnores(["dist"]),
+ {
+ files: ["**/*.{ts,tsx}"],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ globals: globals.browser,
+ },
+ },
+]);
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..075df22
--- /dev/null
+++ b/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+ Plimi
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..dd35243
--- /dev/null
+++ b/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "plimi-app",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "test": "vitest",
+ "test:e2e": "playwright test",
+ "lint": "eslint .",
+ "format": "prettier --write ."
+ },
+ "dependencies": {
+ "@tailwindcss/vite": "^4.3.0",
+ "fabric": "^7.4.0",
+ "pdf-lib": "^1.17.1",
+ "react": "^19.2.6",
+ "react-dom": "^19.2.6",
+ "react-router-dom": "^7.15.0",
+ "tailwindcss": "^4.3.0",
+ "zxing-wasm": "^3.1.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@playwright/test": "^1.60.0",
+ "@types/node": "^24.12.3",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "eslint": "^10.3.0",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^17.6.0",
+ "prettier": "^3.8.3",
+ "typescript": "~6.0.2",
+ "typescript-eslint": "^8.59.2",
+ "vite": "^8.0.12",
+ "vitest": "^4.1.6"
+ }
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..b9bd2d9
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,2979 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.3.0
+ version: 4.3.0(vite@8.0.12(@types/node@24.12.4)(jiti@2.7.0))
+ fabric:
+ specifier: ^7.4.0
+ version: 7.4.0
+ pdf-lib:
+ specifier: ^1.17.1
+ version: 1.17.1
+ react:
+ specifier: ^19.2.6
+ version: 19.2.6
+ react-dom:
+ specifier: ^19.2.6
+ version: 19.2.6(react@19.2.6)
+ react-router-dom:
+ specifier: ^7.15.0
+ version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ tailwindcss:
+ specifier: ^4.3.0
+ version: 4.3.0
+ zxing-wasm:
+ specifier: ^3.1.0
+ version: 3.1.0(@types/emscripten@1.41.5)
+ devDependencies:
+ '@eslint/js':
+ specifier: ^10.0.1
+ version: 10.0.1(eslint@10.3.0(jiti@2.7.0))
+ '@playwright/test':
+ specifier: ^1.60.0
+ version: 1.60.0
+ '@types/node':
+ specifier: ^24.12.3
+ version: 24.12.4
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(vite@8.0.12(@types/node@24.12.4)(jiti@2.7.0))
+ eslint:
+ specifier: ^10.3.0
+ version: 10.3.0(jiti@2.7.0)
+ eslint-plugin-react-hooks:
+ specifier: ^7.1.1
+ version: 7.1.1(eslint@10.3.0(jiti@2.7.0))
+ eslint-plugin-react-refresh:
+ specifier: ^0.5.2
+ version: 0.5.2(eslint@10.3.0(jiti@2.7.0))
+ globals:
+ specifier: ^17.6.0
+ version: 17.6.0
+ prettier:
+ specifier: ^3.8.3
+ version: 3.8.3
+ typescript:
+ specifier: ~6.0.2
+ version: 6.0.3
+ typescript-eslint:
+ specifier: ^8.59.2
+ version: 8.59.3(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)
+ vite:
+ specifier: ^8.0.12
+ version: 8.0.12(@types/node@24.12.4)(jiti@2.7.0)
+ vitest:
+ specifier: ^4.1.6
+ version: 4.1.6(@types/node@24.12.4)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.12(@types/node@24.12.4)(jiti@2.7.0))
+
+packages:
+
+ '@asamuzakjp/css-color@3.2.0':
+ resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
+
+ '@babel/code-frame@7.29.0':
+ resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.29.3':
+ resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.0':
+ resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.1':
+ resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.28.6':
+ resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.28.0':
+ resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.28.6':
+ resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.28.6':
+ resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.27.1':
+ resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.2':
+ resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.3':
+ resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/template@7.28.6':
+ resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.0':
+ resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.0':
+ resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
+ engines: {node: '>=6.9.0'}
+
+ '@csstools/color-helpers@5.1.0':
+ resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
+ engines: {node: '>=18'}
+
+ '@csstools/css-calc@2.1.4':
+ resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^3.0.5
+ '@csstools/css-tokenizer': ^3.0.4
+
+ '@csstools/css-color-parser@3.1.0':
+ resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^3.0.5
+ '@csstools/css-tokenizer': ^3.0.4
+
+ '@csstools/css-parser-algorithms@3.0.5':
+ resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^3.0.4
+
+ '@csstools/css-tokenizer@3.0.4':
+ resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
+ engines: {node: '>=18'}
+
+ '@emnapi/core@1.10.0':
+ resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
+
+ '@emnapi/runtime@1.10.0':
+ resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+
+ '@emnapi/wasi-threads@1.2.1':
+ resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+
+ '@eslint-community/eslint-utils@4.9.1':
+ resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+ '@eslint-community/regexpp@4.12.2':
+ resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
+ engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+ '@eslint/config-array@0.23.5':
+ resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ '@eslint/config-helpers@0.5.5':
+ resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ '@eslint/core@1.2.1':
+ resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ '@eslint/js@10.0.1':
+ resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ peerDependencies:
+ eslint: ^10.0.0
+ peerDependenciesMeta:
+ eslint:
+ optional: true
+
+ '@eslint/object-schema@3.0.5':
+ resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ '@eslint/plugin-kit@0.7.1':
+ resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ '@humanfs/core@0.19.2':
+ resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/node@0.16.8':
+ resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/types@0.15.0':
+ resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanwhocodes/module-importer@1.0.1':
+ resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+ engines: {node: '>=12.22'}
+
+ '@humanwhocodes/retry@0.4.3':
+ resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
+ engines: {node: '>=18.18'}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@napi-rs/wasm-runtime@1.1.4':
+ resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1
+ '@emnapi/runtime': ^1.7.1
+
+ '@oxc-project/types@0.129.0':
+ resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==}
+
+ '@pdf-lib/standard-fonts@1.0.0':
+ resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
+
+ '@pdf-lib/upng@1.0.1':
+ resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
+
+ '@playwright/test@1.60.0':
+ resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ '@rolldown/binding-android-arm64@1.0.0':
+ resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@rolldown/binding-darwin-arm64@1.0.0':
+ resolution: {integrity: sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rolldown/binding-darwin-x64@1.0.0':
+ resolution: {integrity: sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rolldown/binding-freebsd-x64@1.0.0':
+ resolution: {integrity: sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0':
+ resolution: {integrity: sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.0':
+ resolution: {integrity: sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-arm64-musl@1.0.0':
+ resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0':
+ resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.0':
+ resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-gnu@1.0.0':
+ resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-musl@1.0.0':
+ resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-openharmony-arm64@1.0.0':
+ resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rolldown/binding-wasm32-wasi@1.0.0':
+ resolution: {integrity: sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [wasm32]
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.0':
+ resolution: {integrity: sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rolldown/binding-win32-x64-msvc@1.0.0':
+ resolution: {integrity: sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/pluginutils@1.0.0':
+ resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==}
+
+ '@rolldown/pluginutils@1.0.0-rc.7':
+ resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
+
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
+ '@tailwindcss/node@4.3.0':
+ resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
+
+ '@tailwindcss/oxide-android-arm64@4.3.0':
+ resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.3.0':
+ resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.3.0':
+ resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.3.0':
+ resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0':
+ resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==}
+ engines: {node: '>= 20'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.0':
+ resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.3.0':
+ resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.3.0':
+ resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.3.0':
+ resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.3.0':
+ resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.0':
+ resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.3.0':
+ resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.3.0':
+ resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==}
+ engines: {node: '>= 20'}
+
+ '@tailwindcss/vite@4.3.0':
+ resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==}
+ peerDependencies:
+ vite: ^5.2.0 || ^6 || ^7 || ^8
+
+ '@tybys/wasm-util@0.10.2':
+ resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
+
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
+ '@types/emscripten@1.41.5':
+ resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
+
+ '@types/esrecurse@4.3.1':
+ resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/json-schema@7.0.15':
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
+ '@types/node@24.12.4':
+ resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
+
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.14':
+ resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
+
+ '@typescript-eslint/eslint-plugin@8.59.3':
+ resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ '@typescript-eslint/parser': ^8.59.3
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/parser@8.59.3':
+ resolution: {integrity: sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/project-service@8.59.3':
+ resolution: {integrity: sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/scope-manager@8.59.3':
+ resolution: {integrity: sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.59.3':
+ resolution: {integrity: sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/type-utils@8.59.3':
+ resolution: {integrity: sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/types@8.59.3':
+ resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.59.3':
+ resolution: {integrity: sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/utils@8.59.3':
+ resolution: {integrity: sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/visitor-keys@8.59.3':
+ resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@vitejs/plugin-react@6.0.1':
+ resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
+ babel-plugin-react-compiler: ^1.0.0
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ '@rolldown/plugin-babel':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+
+ '@vitest/expect@4.1.6':
+ resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==}
+
+ '@vitest/mocker@4.1.6':
+ resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@4.1.6':
+ resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==}
+
+ '@vitest/runner@4.1.6':
+ resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==}
+
+ '@vitest/snapshot@4.1.6':
+ resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==}
+
+ '@vitest/spy@4.1.6':
+ resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==}
+
+ '@vitest/utils@4.1.6':
+ resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==}
+
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
+ acorn@8.16.0:
+ resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
+ agent-base@7.1.4:
+ resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
+ engines: {node: '>= 14'}
+
+ ajv@6.15.0:
+ resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
+
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
+ balanced-match@4.0.4:
+ resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+ engines: {node: 18 || 20 || >=22}
+
+ base64-js@1.5.1:
+ resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+
+ baseline-browser-mapping@2.10.29:
+ resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ bl@4.1.0:
+ resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
+
+ brace-expansion@5.0.6:
+ resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
+ engines: {node: 18 || 20 || >=22}
+
+ browserslist@4.28.2:
+ resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ buffer@5.7.1:
+ resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
+
+ caniuse-lite@1.0.30001792:
+ resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
+
+ canvas@3.2.3:
+ resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==}
+ engines: {node: ^18.12.0 || >= 20.9.0}
+
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
+
+ chownr@1.1.4:
+ resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
+ cssstyle@4.6.0:
+ resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
+ engines: {node: '>=18'}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ data-urls@5.0.0:
+ resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
+ engines: {node: '>=18'}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
+ decompress-response@6.0.0:
+ resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
+ engines: {node: '>=10'}
+
+ deep-extend@0.6.0:
+ resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
+ engines: {node: '>=4.0.0'}
+
+ deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ electron-to-chromium@1.5.353:
+ resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==}
+
+ end-of-stream@1.4.5:
+ resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+
+ enhanced-resolve@5.21.3:
+ resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==}
+ engines: {node: '>=10.13.0'}
+
+ entities@6.0.1:
+ resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
+ engines: {node: '>=0.12'}
+
+ es-module-lexer@2.1.0:
+ resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
+ eslint-plugin-react-hooks@7.1.1:
+ resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
+
+ eslint-plugin-react-refresh@0.5.2:
+ resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==}
+ peerDependencies:
+ eslint: ^9 || ^10
+
+ eslint-scope@9.1.2:
+ resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
+ eslint-visitor-keys@5.0.1:
+ resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ eslint@10.3.0:
+ resolution: {integrity: sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ espree@11.2.0:
+ resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ esquery@1.7.0:
+ resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
+ engines: {node: '>=0.10'}
+
+ esrecurse@4.3.0:
+ resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
+ engines: {node: '>=4.0'}
+
+ estraverse@5.3.0:
+ resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
+ engines: {node: '>=4.0'}
+
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
+ expand-template@2.0.3:
+ resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
+ engines: {node: '>=6'}
+
+ expect-type@1.3.0:
+ resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
+ engines: {node: '>=12.0.0'}
+
+ fabric@7.4.0:
+ resolution: {integrity: sha512-NalYDc3eifTl1C33zryQwpH6+XA/2ClxQrH9vkASkZw3tbkRmorpikhYMmxhUTmi7O3e9ODz0vOT8qfaCh9IVA==}
+ engines: {node: '>=20.0.0'}
+
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+ fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ file-entry-cache@8.0.0:
+ resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
+ engines: {node: '>=16.0.0'}
+
+ find-up@5.0.0:
+ resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+ engines: {node: '>=10'}
+
+ flat-cache@4.0.1:
+ resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
+ engines: {node: '>=16'}
+
+ flatted@3.4.2:
+ resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
+
+ fs-constants@1.0.0:
+ resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
+
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ github-from-package@0.0.0:
+ resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
+
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
+ globals@17.6.0:
+ resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==}
+ engines: {node: '>=18'}
+
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
+ hermes-estree@0.25.1:
+ resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
+
+ hermes-parser@0.25.1:
+ resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+
+ html-encoding-sniffer@4.0.0:
+ resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
+ engines: {node: '>=18'}
+
+ http-proxy-agent@7.0.2:
+ resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
+ engines: {node: '>= 14'}
+
+ https-proxy-agent@7.0.6:
+ resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
+ engines: {node: '>= 14'}
+
+ iconv-lite@0.6.3:
+ resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
+ engines: {node: '>=0.10.0'}
+
+ ieee754@1.2.1:
+ resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
+
+ ignore@5.3.2:
+ resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
+ engines: {node: '>= 4'}
+
+ ignore@7.0.5:
+ resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
+ engines: {node: '>= 4'}
+
+ imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+ engines: {node: '>=0.8.19'}
+
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+ ini@1.3.8:
+ resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
+
+ is-extglob@2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+
+ is-glob@4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ jiti@2.7.0:
+ resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
+ hasBin: true
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsdom@26.1.0:
+ resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json-buffer@3.0.1:
+ resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
+
+ json-schema-traverse@0.4.1:
+ resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
+ json-stable-stringify-without-jsonify@1.0.1:
+ resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ keyv@4.5.4:
+ resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+
+ levn@0.4.1:
+ resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+ engines: {node: '>= 0.8.0'}
+
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ engines: {node: '>= 12.0.0'}
+
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
+ lru-cache@10.4.3:
+ resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ mimic-response@3.1.0:
+ resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
+ engines: {node: '>=10'}
+
+ minimatch@10.2.5:
+ resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
+ engines: {node: 18 || 20 || >=22}
+
+ minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+ mkdirp-classic@0.5.3:
+ resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.12:
+ resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ napi-build-utils@2.0.0:
+ resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
+
+ natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
+ node-abi@3.92.0:
+ resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==}
+ engines: {node: '>=10'}
+
+ node-addon-api@7.1.1:
+ resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
+
+ node-releases@2.0.44:
+ resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==}
+
+ nwsapi@2.2.23:
+ resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
+
+ obug@2.1.1:
+ resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+
+ once@1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+ optionator@0.9.4:
+ resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+ engines: {node: '>= 0.8.0'}
+
+ p-limit@3.1.0:
+ resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
+ engines: {node: '>=10'}
+
+ p-locate@5.0.0:
+ resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+ engines: {node: '>=10'}
+
+ pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
+ parse5@7.3.0:
+ resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
+
+ path-exists@4.0.0:
+ resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
+ engines: {node: '>=8'}
+
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
+ pdf-lib@1.17.1:
+ resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
+ playwright-core@1.60.0:
+ resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.60.0:
+ resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ postcss@8.5.14:
+ resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ prebuild-install@7.1.3:
+ resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
+ engines: {node: '>=10'}
+ deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
+ hasBin: true
+
+ prelude-ls@1.2.1:
+ resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+ engines: {node: '>= 0.8.0'}
+
+ prettier@3.8.3:
+ resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
+ engines: {node: '>=14'}
+ hasBin: true
+
+ pump@3.0.4:
+ resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
+ rc@1.2.8:
+ resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
+ hasBin: true
+
+ react-dom@19.2.6:
+ resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==}
+ peerDependencies:
+ react: ^19.2.6
+
+ react-router-dom@7.15.0:
+ resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+
+ react-router@7.15.0:
+ resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
+ react@19.2.6:
+ resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
+ engines: {node: '>=0.10.0'}
+
+ readable-stream@3.6.2:
+ resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+ engines: {node: '>= 6'}
+
+ rolldown@1.0.0:
+ resolution: {integrity: sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
+ rrweb-cssom@0.8.0:
+ resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
+
+ safe-buffer@5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+ safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ semver@7.8.0:
+ resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
+ simple-concat@1.0.1:
+ resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
+
+ simple-get@4.0.1:
+ resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+ std-env@4.1.0:
+ resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
+
+ string_decoder@1.3.0:
+ resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+
+ strip-json-comments@2.0.1:
+ resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
+ engines: {node: '>=0.10.0'}
+
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
+ tagged-tag@1.0.0:
+ resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
+ engines: {node: '>=20'}
+
+ tailwindcss@4.3.0:
+ resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
+
+ tapable@2.3.3:
+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
+ engines: {node: '>=6'}
+
+ tar-fs@2.1.4:
+ resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
+
+ tar-stream@2.2.0:
+ resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
+ engines: {node: '>=6'}
+
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@1.1.2:
+ resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==}
+ engines: {node: '>=18'}
+
+ tinyglobby@0.2.16:
+ resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
+ engines: {node: '>=12.0.0'}
+
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
+ engines: {node: '>=14.0.0'}
+
+ tldts-core@6.1.86:
+ resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
+
+ tldts@6.1.86:
+ resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
+ hasBin: true
+
+ tough-cookie@5.1.2:
+ resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
+ engines: {node: '>=16'}
+
+ tr46@5.1.1:
+ resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
+ engines: {node: '>=18'}
+
+ ts-api-utils@2.5.0:
+ resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
+ engines: {node: '>=18.12'}
+ peerDependencies:
+ typescript: '>=4.8.4'
+
+ tslib@1.14.1:
+ resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ tunnel-agent@0.6.0:
+ resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
+
+ type-check@0.4.0:
+ resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+ engines: {node: '>= 0.8.0'}
+
+ type-fest@5.7.0:
+ resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==}
+ engines: {node: '>=20'}
+
+ typescript-eslint@8.59.3:
+ resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ typescript@6.0.3:
+ resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@7.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ uri-js@4.4.1:
+ resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
+ vite@8.0.12:
+ resolution: {integrity: sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.1.18
+ esbuild: ^0.27.0 || ^0.28.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vitest@4.1.6:
+ resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.1.6
+ '@vitest/browser-preview': 4.1.6
+ '@vitest/browser-webdriverio': 4.1.6
+ '@vitest/coverage-istanbul': 4.1.6
+ '@vitest/coverage-v8': 4.1.6
+ '@vitest/ui': 4.1.6
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/coverage-istanbul':
+ optional: true
+ '@vitest/coverage-v8':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
+ webidl-conversions@7.0.0:
+ resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
+ engines: {node: '>=12'}
+
+ whatwg-encoding@3.1.1:
+ resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
+ engines: {node: '>=18'}
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
+
+ whatwg-mimetype@4.0.0:
+ resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
+ engines: {node: '>=18'}
+
+ whatwg-url@14.2.0:
+ resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
+ engines: {node: '>=18'}
+
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
+ word-wrap@1.2.5:
+ resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+ engines: {node: '>=0.10.0'}
+
+ wrappy@1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+ ws@8.21.0:
+ resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+ yocto-queue@0.1.0:
+ resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
+ engines: {node: '>=10'}
+
+ zod-validation-error@4.0.2:
+ resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ zod: ^3.25.0 || ^4.0.0
+
+ zod@4.4.3:
+ resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
+
+ zxing-wasm@3.1.0:
+ resolution: {integrity: sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA==}
+ peerDependencies:
+ '@types/emscripten': '>=1.39.6'
+
+snapshots:
+
+ '@asamuzakjp/css-color@3.2.0':
+ dependencies:
+ '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-tokenizer': 3.0.4
+ lru-cache: 10.4.3
+ optional: true
+
+ '@babel/code-frame@7.29.0':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.28.5
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.29.3': {}
+
+ '@babel/core@7.29.0':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/generator': 7.29.1
+ '@babel/helper-compilation-targets': 7.28.6
+ '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
+ '@babel/helpers': 7.29.2
+ '@babel/parser': 7.29.3
+ '@babel/template': 7.28.6
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.1':
+ dependencies:
+ '@babel/parser': 7.29.3
+ '@babel/types': 7.29.0
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.28.6':
+ dependencies:
+ '@babel/compat-data': 7.29.3
+ '@babel/helper-validator-option': 7.27.1
+ browserslist: 4.28.2
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.28.0': {}
+
+ '@babel/helper-module-imports@7.28.6':
+ dependencies:
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-module-imports': 7.28.6
+ '@babel/helper-validator-identifier': 7.28.5
+ '@babel/traverse': 7.29.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-string-parser@7.27.1': {}
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
+ '@babel/helper-validator-option@7.27.1': {}
+
+ '@babel/helpers@7.29.2':
+ dependencies:
+ '@babel/template': 7.28.6
+ '@babel/types': 7.29.0
+
+ '@babel/parser@7.29.3':
+ dependencies:
+ '@babel/types': 7.29.0
+
+ '@babel/template@7.28.6':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/parser': 7.29.3
+ '@babel/types': 7.29.0
+
+ '@babel/traverse@7.29.0':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/generator': 7.29.1
+ '@babel/helper-globals': 7.28.0
+ '@babel/parser': 7.29.3
+ '@babel/template': 7.28.6
+ '@babel/types': 7.29.0
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.0':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
+ '@csstools/color-helpers@5.1.0':
+ optional: true
+
+ '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-tokenizer': 3.0.4
+ optional: true
+
+ '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
+ dependencies:
+ '@csstools/color-helpers': 5.1.0
+ '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
+ '@csstools/css-tokenizer': 3.0.4
+ optional: true
+
+ '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
+ dependencies:
+ '@csstools/css-tokenizer': 3.0.4
+ optional: true
+
+ '@csstools/css-tokenizer@3.0.4':
+ optional: true
+
+ '@emnapi/core@1.10.0':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.1
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.10.0':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0(jiti@2.7.0))':
+ dependencies:
+ eslint: 10.3.0(jiti@2.7.0)
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.2': {}
+
+ '@eslint/config-array@0.23.5':
+ dependencies:
+ '@eslint/object-schema': 3.0.5
+ debug: 4.4.3
+ minimatch: 10.2.5
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/config-helpers@0.5.5':
+ dependencies:
+ '@eslint/core': 1.2.1
+
+ '@eslint/core@1.2.1':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/js@10.0.1(eslint@10.3.0(jiti@2.7.0))':
+ optionalDependencies:
+ eslint: 10.3.0(jiti@2.7.0)
+
+ '@eslint/object-schema@3.0.5': {}
+
+ '@eslint/plugin-kit@0.7.1':
+ dependencies:
+ '@eslint/core': 1.2.1
+ levn: 0.4.1
+
+ '@humanfs/core@0.19.2':
+ dependencies:
+ '@humanfs/types': 0.15.0
+
+ '@humanfs/node@0.16.8':
+ dependencies:
+ '@humanfs/core': 0.19.2
+ '@humanfs/types': 0.15.0
+ '@humanwhocodes/retry': 0.4.3
+
+ '@humanfs/types@0.15.0': {}
+
+ '@humanwhocodes/module-importer@1.0.1': {}
+
+ '@humanwhocodes/retry@0.4.3': {}
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@tybys/wasm-util': 0.10.2
+ optional: true
+
+ '@oxc-project/types@0.129.0': {}
+
+ '@pdf-lib/standard-fonts@1.0.0':
+ dependencies:
+ pako: 1.0.11
+
+ '@pdf-lib/upng@1.0.1':
+ dependencies:
+ pako: 1.0.11
+
+ '@playwright/test@1.60.0':
+ dependencies:
+ playwright: 1.60.0
+
+ '@rolldown/binding-android-arm64@1.0.0':
+ optional: true
+
+ '@rolldown/binding-darwin-arm64@1.0.0':
+ optional: true
+
+ '@rolldown/binding-darwin-x64@1.0.0':
+ optional: true
+
+ '@rolldown/binding-freebsd-x64@1.0.0':
+ optional: true
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.0':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-musl@1.0.0':
+ optional: true
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0':
+ optional: true
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.0':
+ optional: true
+
+ '@rolldown/binding-linux-x64-gnu@1.0.0':
+ optional: true
+
+ '@rolldown/binding-linux-x64-musl@1.0.0':
+ optional: true
+
+ '@rolldown/binding-openharmony-arm64@1.0.0':
+ optional: true
+
+ '@rolldown/binding-wasm32-wasi@1.0.0':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ optional: true
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.0':
+ optional: true
+
+ '@rolldown/binding-win32-x64-msvc@1.0.0':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.0': {}
+
+ '@rolldown/pluginutils@1.0.0-rc.7': {}
+
+ '@standard-schema/spec@1.1.0': {}
+
+ '@tailwindcss/node@4.3.0':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.21.3
+ jiti: 2.7.0
+ lightningcss: 1.32.0
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.3.0
+
+ '@tailwindcss/oxide-android-arm64@4.3.0':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.3.0':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.3.0':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.3.0':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.0':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.3.0':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.3.0':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.3.0':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.3.0':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.0':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.3.0':
+ optional: true
+
+ '@tailwindcss/oxide@4.3.0':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.3.0
+ '@tailwindcss/oxide-darwin-arm64': 4.3.0
+ '@tailwindcss/oxide-darwin-x64': 4.3.0
+ '@tailwindcss/oxide-freebsd-x64': 4.3.0
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0
+ '@tailwindcss/oxide-linux-arm64-musl': 4.3.0
+ '@tailwindcss/oxide-linux-x64-gnu': 4.3.0
+ '@tailwindcss/oxide-linux-x64-musl': 4.3.0
+ '@tailwindcss/oxide-wasm32-wasi': 4.3.0
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0
+ '@tailwindcss/oxide-win32-x64-msvc': 4.3.0
+
+ '@tailwindcss/vite@4.3.0(vite@8.0.12(@types/node@24.12.4)(jiti@2.7.0))':
+ dependencies:
+ '@tailwindcss/node': 4.3.0
+ '@tailwindcss/oxide': 4.3.0
+ tailwindcss: 4.3.0
+ vite: 8.0.12(@types/node@24.12.4)(jiti@2.7.0)
+
+ '@tybys/wasm-util@0.10.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
+ '@types/deep-eql@4.0.2': {}
+
+ '@types/emscripten@1.41.5': {}
+
+ '@types/esrecurse@4.3.1': {}
+
+ '@types/estree@1.0.9': {}
+
+ '@types/json-schema@7.0.15': {}
+
+ '@types/node@24.12.4':
+ dependencies:
+ undici-types: 7.16.0
+
+ '@types/react-dom@19.2.3(@types/react@19.2.14)':
+ dependencies:
+ '@types/react': 19.2.14
+
+ '@types/react@19.2.14':
+ dependencies:
+ csstype: 3.2.3
+
+ '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)':
+ dependencies:
+ '@eslint-community/regexpp': 4.12.2
+ '@typescript-eslint/parser': 8.59.3(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/scope-manager': 8.59.3
+ '@typescript-eslint/type-utils': 8.59.3(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.59.3(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.59.3
+ eslint: 10.3.0(jiti@2.7.0)
+ ignore: 7.0.5
+ natural-compare: 1.4.0
+ ts-api-utils: 2.5.0(typescript@6.0.3)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/parser@8.59.3(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.59.3
+ '@typescript-eslint/types': 8.59.3
+ '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3)
+ '@typescript-eslint/visitor-keys': 8.59.3
+ debug: 4.4.3
+ eslint: 10.3.0(jiti@2.7.0)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.59.3(typescript@6.0.3)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@6.0.3)
+ '@typescript-eslint/types': 8.59.3
+ debug: 4.4.3
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.59.3':
+ dependencies:
+ '@typescript-eslint/types': 8.59.3
+ '@typescript-eslint/visitor-keys': 8.59.3
+
+ '@typescript-eslint/tsconfig-utils@8.59.3(typescript@6.0.3)':
+ dependencies:
+ typescript: 6.0.3
+
+ '@typescript-eslint/type-utils@8.59.3(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.59.3
+ '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.59.3(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)
+ debug: 4.4.3
+ eslint: 10.3.0(jiti@2.7.0)
+ ts-api-utils: 2.5.0(typescript@6.0.3)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/types@8.59.3': {}
+
+ '@typescript-eslint/typescript-estree@8.59.3(typescript@6.0.3)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.59.3(typescript@6.0.3)
+ '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@6.0.3)
+ '@typescript-eslint/types': 8.59.3
+ '@typescript-eslint/visitor-keys': 8.59.3
+ debug: 4.4.3
+ minimatch: 10.2.5
+ semver: 7.8.0
+ tinyglobby: 0.2.16
+ ts-api-utils: 2.5.0(typescript@6.0.3)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/utils@8.59.3(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0))
+ '@typescript-eslint/scope-manager': 8.59.3
+ '@typescript-eslint/types': 8.59.3
+ '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3)
+ eslint: 10.3.0(jiti@2.7.0)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.59.3':
+ dependencies:
+ '@typescript-eslint/types': 8.59.3
+ eslint-visitor-keys: 5.0.1
+
+ '@vitejs/plugin-react@6.0.1(vite@8.0.12(@types/node@24.12.4)(jiti@2.7.0))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.0-rc.7
+ vite: 8.0.12(@types/node@24.12.4)(jiti@2.7.0)
+
+ '@vitest/expect@4.1.6':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.6
+ '@vitest/utils': 4.1.6
+ chai: 6.2.2
+ tinyrainbow: 3.1.0
+
+ '@vitest/mocker@4.1.6(vite@8.0.12(@types/node@24.12.4)(jiti@2.7.0))':
+ dependencies:
+ '@vitest/spy': 4.1.6
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 8.0.12(@types/node@24.12.4)(jiti@2.7.0)
+
+ '@vitest/pretty-format@4.1.6':
+ dependencies:
+ tinyrainbow: 3.1.0
+
+ '@vitest/runner@4.1.6':
+ dependencies:
+ '@vitest/utils': 4.1.6
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.6':
+ dependencies:
+ '@vitest/pretty-format': 4.1.6
+ '@vitest/utils': 4.1.6
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.6': {}
+
+ '@vitest/utils@4.1.6':
+ dependencies:
+ '@vitest/pretty-format': 4.1.6
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
+
+ acorn-jsx@5.3.2(acorn@8.16.0):
+ dependencies:
+ acorn: 8.16.0
+
+ acorn@8.16.0: {}
+
+ agent-base@7.1.4:
+ optional: true
+
+ ajv@6.15.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-json-stable-stringify: 2.1.0
+ json-schema-traverse: 0.4.1
+ uri-js: 4.4.1
+
+ assertion-error@2.0.1: {}
+
+ balanced-match@4.0.4: {}
+
+ base64-js@1.5.1:
+ optional: true
+
+ baseline-browser-mapping@2.10.29: {}
+
+ bl@4.1.0:
+ dependencies:
+ buffer: 5.7.1
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+ optional: true
+
+ brace-expansion@5.0.6:
+ dependencies:
+ balanced-match: 4.0.4
+
+ browserslist@4.28.2:
+ dependencies:
+ baseline-browser-mapping: 2.10.29
+ caniuse-lite: 1.0.30001792
+ electron-to-chromium: 1.5.353
+ node-releases: 2.0.44
+ update-browserslist-db: 1.2.3(browserslist@4.28.2)
+
+ buffer@5.7.1:
+ dependencies:
+ base64-js: 1.5.1
+ ieee754: 1.2.1
+ optional: true
+
+ caniuse-lite@1.0.30001792: {}
+
+ canvas@3.2.3:
+ dependencies:
+ node-addon-api: 7.1.1
+ prebuild-install: 7.1.3
+ optional: true
+
+ chai@6.2.2: {}
+
+ chownr@1.1.4:
+ optional: true
+
+ convert-source-map@2.0.0: {}
+
+ cookie@1.1.1: {}
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
+ cssstyle@4.6.0:
+ dependencies:
+ '@asamuzakjp/css-color': 3.2.0
+ rrweb-cssom: 0.8.0
+ optional: true
+
+ csstype@3.2.3: {}
+
+ data-urls@5.0.0:
+ dependencies:
+ whatwg-mimetype: 4.0.0
+ whatwg-url: 14.2.0
+ optional: true
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ decimal.js@10.6.0:
+ optional: true
+
+ decompress-response@6.0.0:
+ dependencies:
+ mimic-response: 3.1.0
+ optional: true
+
+ deep-extend@0.6.0:
+ optional: true
+
+ deep-is@0.1.4: {}
+
+ detect-libc@2.1.2: {}
+
+ electron-to-chromium@1.5.353: {}
+
+ end-of-stream@1.4.5:
+ dependencies:
+ once: 1.4.0
+ optional: true
+
+ enhanced-resolve@5.21.3:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.3
+
+ entities@6.0.1:
+ optional: true
+
+ es-module-lexer@2.1.0: {}
+
+ escalade@3.2.0: {}
+
+ escape-string-regexp@4.0.0: {}
+
+ eslint-plugin-react-hooks@7.1.1(eslint@10.3.0(jiti@2.7.0)):
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/parser': 7.29.3
+ eslint: 10.3.0(jiti@2.7.0)
+ hermes-parser: 0.25.1
+ zod: 4.4.3
+ zod-validation-error: 4.0.2(zod@4.4.3)
+ transitivePeerDependencies:
+ - supports-color
+
+ eslint-plugin-react-refresh@0.5.2(eslint@10.3.0(jiti@2.7.0)):
+ dependencies:
+ eslint: 10.3.0(jiti@2.7.0)
+
+ eslint-scope@9.1.2:
+ dependencies:
+ '@types/esrecurse': 4.3.1
+ '@types/estree': 1.0.9
+ esrecurse: 4.3.0
+ estraverse: 5.3.0
+
+ eslint-visitor-keys@3.4.3: {}
+
+ eslint-visitor-keys@5.0.1: {}
+
+ eslint@10.3.0(jiti@2.7.0):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0))
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.23.5
+ '@eslint/config-helpers': 0.5.5
+ '@eslint/core': 1.2.1
+ '@eslint/plugin-kit': 0.7.1
+ '@humanfs/node': 0.16.8
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.9
+ ajv: 6.15.0
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 9.1.2
+ eslint-visitor-keys: 5.0.1
+ espree: 11.2.0
+ esquery: 1.7.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ minimatch: 10.2.5
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ optionalDependencies:
+ jiti: 2.7.0
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@11.2.0:
+ dependencies:
+ acorn: 8.16.0
+ acorn-jsx: 5.3.2(acorn@8.16.0)
+ eslint-visitor-keys: 5.0.1
+
+ esquery@1.7.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ esrecurse@4.3.0:
+ dependencies:
+ estraverse: 5.3.0
+
+ estraverse@5.3.0: {}
+
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.9
+
+ esutils@2.0.3: {}
+
+ expand-template@2.0.3:
+ optional: true
+
+ expect-type@1.3.0: {}
+
+ fabric@7.4.0:
+ optionalDependencies:
+ canvas: 3.2.3
+ jsdom: 26.1.0(canvas@3.2.3)
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
+ fast-deep-equal@3.1.3: {}
+
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
+ file-entry-cache@8.0.0:
+ dependencies:
+ flat-cache: 4.0.1
+
+ find-up@5.0.0:
+ dependencies:
+ locate-path: 6.0.0
+ path-exists: 4.0.0
+
+ flat-cache@4.0.1:
+ dependencies:
+ flatted: 3.4.2
+ keyv: 4.5.4
+
+ flatted@3.4.2: {}
+
+ fs-constants@1.0.0:
+ optional: true
+
+ fsevents@2.3.2:
+ optional: true
+
+ fsevents@2.3.3:
+ optional: true
+
+ gensync@1.0.0-beta.2: {}
+
+ github-from-package@0.0.0:
+ optional: true
+
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
+ globals@17.6.0: {}
+
+ graceful-fs@4.2.11: {}
+
+ hermes-estree@0.25.1: {}
+
+ hermes-parser@0.25.1:
+ dependencies:
+ hermes-estree: 0.25.1
+
+ html-encoding-sniffer@4.0.0:
+ dependencies:
+ whatwg-encoding: 3.1.1
+ optional: true
+
+ http-proxy-agent@7.0.2:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
+ https-proxy-agent@7.0.6:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
+ iconv-lite@0.6.3:
+ dependencies:
+ safer-buffer: 2.1.2
+ optional: true
+
+ ieee754@1.2.1:
+ optional: true
+
+ ignore@5.3.2: {}
+
+ ignore@7.0.5: {}
+
+ imurmurhash@0.1.4: {}
+
+ inherits@2.0.4:
+ optional: true
+
+ ini@1.3.8:
+ optional: true
+
+ is-extglob@2.1.1: {}
+
+ is-glob@4.0.3:
+ dependencies:
+ is-extglob: 2.1.1
+
+ is-potential-custom-element-name@1.0.1:
+ optional: true
+
+ isexe@2.0.0: {}
+
+ jiti@2.7.0: {}
+
+ js-tokens@4.0.0: {}
+
+ jsdom@26.1.0(canvas@3.2.3):
+ dependencies:
+ cssstyle: 4.6.0
+ data-urls: 5.0.0
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 4.0.0
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ is-potential-custom-element-name: 1.0.1
+ nwsapi: 2.2.23
+ parse5: 7.3.0
+ rrweb-cssom: 0.8.0
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 5.1.2
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 7.0.0
+ whatwg-encoding: 3.1.1
+ whatwg-mimetype: 4.0.0
+ whatwg-url: 14.2.0
+ ws: 8.21.0
+ xml-name-validator: 5.0.0
+ optionalDependencies:
+ canvas: 3.2.3
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ optional: true
+
+ jsesc@3.1.0: {}
+
+ json-buffer@3.0.1: {}
+
+ json-schema-traverse@0.4.1: {}
+
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
+ json5@2.2.3: {}
+
+ keyv@4.5.4:
+ dependencies:
+ json-buffer: 3.0.1
+
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
+ lightningcss-android-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.32.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.32.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ optional: true
+
+ lightningcss@1.32.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
+
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
+ lru-cache@10.4.3:
+ optional: true
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
+ mimic-response@3.1.0:
+ optional: true
+
+ minimatch@10.2.5:
+ dependencies:
+ brace-expansion: 5.0.6
+
+ minimist@1.2.8:
+ optional: true
+
+ mkdirp-classic@0.5.3:
+ optional: true
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.12: {}
+
+ napi-build-utils@2.0.0:
+ optional: true
+
+ natural-compare@1.4.0: {}
+
+ node-abi@3.92.0:
+ dependencies:
+ semver: 7.8.0
+ optional: true
+
+ node-addon-api@7.1.1:
+ optional: true
+
+ node-releases@2.0.44: {}
+
+ nwsapi@2.2.23:
+ optional: true
+
+ obug@2.1.1: {}
+
+ once@1.4.0:
+ dependencies:
+ wrappy: 1.0.2
+ optional: true
+
+ optionator@0.9.4:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ word-wrap: 1.2.5
+
+ p-limit@3.1.0:
+ dependencies:
+ yocto-queue: 0.1.0
+
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
+ pako@1.0.11: {}
+
+ parse5@7.3.0:
+ dependencies:
+ entities: 6.0.1
+ optional: true
+
+ path-exists@4.0.0: {}
+
+ path-key@3.1.1: {}
+
+ pathe@2.0.3: {}
+
+ pdf-lib@1.17.1:
+ dependencies:
+ '@pdf-lib/standard-fonts': 1.0.0
+ '@pdf-lib/upng': 1.0.1
+ pako: 1.0.11
+ tslib: 1.14.1
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.4: {}
+
+ playwright-core@1.60.0: {}
+
+ playwright@1.60.0:
+ dependencies:
+ playwright-core: 1.60.0
+ optionalDependencies:
+ fsevents: 2.3.2
+
+ postcss@8.5.14:
+ dependencies:
+ nanoid: 3.3.12
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ prebuild-install@7.1.3:
+ dependencies:
+ detect-libc: 2.1.2
+ expand-template: 2.0.3
+ github-from-package: 0.0.0
+ minimist: 1.2.8
+ mkdirp-classic: 0.5.3
+ napi-build-utils: 2.0.0
+ node-abi: 3.92.0
+ pump: 3.0.4
+ rc: 1.2.8
+ simple-get: 4.0.1
+ tar-fs: 2.1.4
+ tunnel-agent: 0.6.0
+ optional: true
+
+ prelude-ls@1.2.1: {}
+
+ prettier@3.8.3: {}
+
+ pump@3.0.4:
+ dependencies:
+ end-of-stream: 1.4.5
+ once: 1.4.0
+ optional: true
+
+ punycode@2.3.1: {}
+
+ rc@1.2.8:
+ dependencies:
+ deep-extend: 0.6.0
+ ini: 1.3.8
+ minimist: 1.2.8
+ strip-json-comments: 2.0.1
+ optional: true
+
+ react-dom@19.2.6(react@19.2.6):
+ dependencies:
+ react: 19.2.6
+ scheduler: 0.27.0
+
+ react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
+ dependencies:
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ react-router: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+
+ react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
+ dependencies:
+ cookie: 1.1.1
+ react: 19.2.6
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 19.2.6(react@19.2.6)
+
+ react@19.2.6: {}
+
+ readable-stream@3.6.2:
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+ optional: true
+
+ rolldown@1.0.0:
+ dependencies:
+ '@oxc-project/types': 0.129.0
+ '@rolldown/pluginutils': 1.0.0
+ optionalDependencies:
+ '@rolldown/binding-android-arm64': 1.0.0
+ '@rolldown/binding-darwin-arm64': 1.0.0
+ '@rolldown/binding-darwin-x64': 1.0.0
+ '@rolldown/binding-freebsd-x64': 1.0.0
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.0
+ '@rolldown/binding-linux-arm64-gnu': 1.0.0
+ '@rolldown/binding-linux-arm64-musl': 1.0.0
+ '@rolldown/binding-linux-ppc64-gnu': 1.0.0
+ '@rolldown/binding-linux-s390x-gnu': 1.0.0
+ '@rolldown/binding-linux-x64-gnu': 1.0.0
+ '@rolldown/binding-linux-x64-musl': 1.0.0
+ '@rolldown/binding-openharmony-arm64': 1.0.0
+ '@rolldown/binding-wasm32-wasi': 1.0.0
+ '@rolldown/binding-win32-arm64-msvc': 1.0.0
+ '@rolldown/binding-win32-x64-msvc': 1.0.0
+
+ rrweb-cssom@0.8.0:
+ optional: true
+
+ safe-buffer@5.2.1:
+ optional: true
+
+ safer-buffer@2.1.2:
+ optional: true
+
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+ optional: true
+
+ scheduler@0.27.0: {}
+
+ semver@6.3.1: {}
+
+ semver@7.8.0: {}
+
+ set-cookie-parser@2.7.2: {}
+
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
+ siginfo@2.0.0: {}
+
+ simple-concat@1.0.1:
+ optional: true
+
+ simple-get@4.0.1:
+ dependencies:
+ decompress-response: 6.0.0
+ once: 1.4.0
+ simple-concat: 1.0.1
+ optional: true
+
+ source-map-js@1.2.1: {}
+
+ stackback@0.0.2: {}
+
+ std-env@4.1.0: {}
+
+ string_decoder@1.3.0:
+ dependencies:
+ safe-buffer: 5.2.1
+ optional: true
+
+ strip-json-comments@2.0.1:
+ optional: true
+
+ symbol-tree@3.2.4:
+ optional: true
+
+ tagged-tag@1.0.0: {}
+
+ tailwindcss@4.3.0: {}
+
+ tapable@2.3.3: {}
+
+ tar-fs@2.1.4:
+ dependencies:
+ chownr: 1.1.4
+ mkdirp-classic: 0.5.3
+ pump: 3.0.4
+ tar-stream: 2.2.0
+ optional: true
+
+ tar-stream@2.2.0:
+ dependencies:
+ bl: 4.1.0
+ end-of-stream: 1.4.5
+ fs-constants: 1.0.0
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+ optional: true
+
+ tinybench@2.9.0: {}
+
+ tinyexec@1.1.2: {}
+
+ tinyglobby@0.2.16:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+
+ tinyrainbow@3.1.0: {}
+
+ tldts-core@6.1.86:
+ optional: true
+
+ tldts@6.1.86:
+ dependencies:
+ tldts-core: 6.1.86
+ optional: true
+
+ tough-cookie@5.1.2:
+ dependencies:
+ tldts: 6.1.86
+ optional: true
+
+ tr46@5.1.1:
+ dependencies:
+ punycode: 2.3.1
+ optional: true
+
+ ts-api-utils@2.5.0(typescript@6.0.3):
+ dependencies:
+ typescript: 6.0.3
+
+ tslib@1.14.1: {}
+
+ tslib@2.8.1:
+ optional: true
+
+ tunnel-agent@0.6.0:
+ dependencies:
+ safe-buffer: 5.2.1
+ optional: true
+
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
+ type-fest@5.7.0:
+ dependencies:
+ tagged-tag: 1.0.0
+
+ typescript-eslint@8.59.3(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3):
+ dependencies:
+ '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/parser': 8.59.3(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)
+ '@typescript-eslint/typescript-estree': 8.59.3(typescript@6.0.3)
+ '@typescript-eslint/utils': 8.59.3(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)
+ eslint: 10.3.0(jiti@2.7.0)
+ typescript: 6.0.3
+ transitivePeerDependencies:
+ - supports-color
+
+ typescript@6.0.3: {}
+
+ undici-types@7.16.0: {}
+
+ update-browserslist-db@1.2.3(browserslist@4.28.2):
+ dependencies:
+ browserslist: 4.28.2
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
+ util-deprecate@1.0.2:
+ optional: true
+
+ vite@8.0.12(@types/node@24.12.4)(jiti@2.7.0):
+ dependencies:
+ lightningcss: 1.32.0
+ picomatch: 4.0.4
+ postcss: 8.5.14
+ rolldown: 1.0.0
+ tinyglobby: 0.2.16
+ optionalDependencies:
+ '@types/node': 24.12.4
+ fsevents: 2.3.3
+ jiti: 2.7.0
+
+ vitest@4.1.6(@types/node@24.12.4)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.12(@types/node@24.12.4)(jiti@2.7.0)):
+ dependencies:
+ '@vitest/expect': 4.1.6
+ '@vitest/mocker': 4.1.6(vite@8.0.12(@types/node@24.12.4)(jiti@2.7.0))
+ '@vitest/pretty-format': 4.1.6
+ '@vitest/runner': 4.1.6
+ '@vitest/snapshot': 4.1.6
+ '@vitest/spy': 4.1.6
+ '@vitest/utils': 4.1.6
+ es-module-lexer: 2.1.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.1
+ pathe: 2.0.3
+ picomatch: 4.0.4
+ std-env: 4.1.0
+ tinybench: 2.9.0
+ tinyexec: 1.1.2
+ tinyglobby: 0.2.16
+ tinyrainbow: 3.1.0
+ vite: 8.0.12(@types/node@24.12.4)(jiti@2.7.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 24.12.4
+ jsdom: 26.1.0(canvas@3.2.3)
+ transitivePeerDependencies:
+ - msw
+
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+ optional: true
+
+ webidl-conversions@7.0.0:
+ optional: true
+
+ whatwg-encoding@3.1.1:
+ dependencies:
+ iconv-lite: 0.6.3
+ optional: true
+
+ whatwg-mimetype@4.0.0:
+ optional: true
+
+ whatwg-url@14.2.0:
+ dependencies:
+ tr46: 5.1.1
+ webidl-conversions: 7.0.0
+ optional: true
+
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
+ word-wrap@1.2.5: {}
+
+ wrappy@1.0.2:
+ optional: true
+
+ ws@8.21.0:
+ optional: true
+
+ xml-name-validator@5.0.0:
+ optional: true
+
+ xmlchars@2.2.0:
+ optional: true
+
+ yallist@3.1.1: {}
+
+ yocto-queue@0.1.0: {}
+
+ zod-validation-error@4.0.2(zod@4.4.3):
+ dependencies:
+ zod: 4.4.3
+
+ zod@4.4.3: {}
+
+ zxing-wasm@3.1.0(@types/emscripten@1.41.5):
+ dependencies:
+ '@types/emscripten': 1.41.5
+ type-fest: 5.7.0
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..a8c4509
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,60 @@
+
\ No newline at end of file
diff --git a/public/icons.svg b/public/icons.svg
new file mode 100644
index 0000000..e952219
--- /dev/null
+++ b/public/icons.svg
@@ -0,0 +1,24 @@
+
diff --git a/public/zxing_writer.wasm b/public/zxing_writer.wasm
new file mode 100644
index 0000000..fe78cbe
Binary files /dev/null and b/public/zxing_writer.wasm differ
diff --git a/src/App.css b/src/App.css
new file mode 100644
index 0000000..f460279
--- /dev/null
+++ b/src/App.css
@@ -0,0 +1,184 @@
+.counter {
+ font-size: 16px;
+ padding: 5px 10px;
+ border-radius: 5px;
+ color: var(--accent);
+ background: var(--accent-bg);
+ border: 2px solid transparent;
+ transition: border-color 0.3s;
+ margin-bottom: 24px;
+
+ &:hover {
+ border-color: var(--accent-border);
+ }
+ &:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ }
+}
+
+.hero {
+ position: relative;
+
+ .base,
+ .framework,
+ .vite {
+ inset-inline: 0;
+ margin: 0 auto;
+ }
+
+ .base {
+ width: 170px;
+ position: relative;
+ z-index: 0;
+ }
+
+ .framework,
+ .vite {
+ position: absolute;
+ }
+
+ .framework {
+ z-index: 1;
+ top: 34px;
+ height: 28px;
+ transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
+ scale(1.4);
+ }
+
+ .vite {
+ z-index: 0;
+ top: 107px;
+ height: 26px;
+ width: auto;
+ transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
+ scale(0.8);
+ }
+}
+
+#center {
+ display: flex;
+ flex-direction: column;
+ gap: 25px;
+ place-content: center;
+ place-items: center;
+ flex-grow: 1;
+
+ @media (max-width: 1024px) {
+ padding: 32px 20px 24px;
+ gap: 18px;
+ }
+}
+
+#next-steps {
+ display: flex;
+ border-top: 1px solid var(--border);
+ text-align: left;
+
+ & > div {
+ flex: 1 1 0;
+ padding: 32px;
+ @media (max-width: 1024px) {
+ padding: 24px 20px;
+ }
+ }
+
+ .icon {
+ margin-bottom: 16px;
+ width: 22px;
+ height: 22px;
+ }
+
+ @media (max-width: 1024px) {
+ flex-direction: column;
+ text-align: center;
+ }
+}
+
+#docs {
+ border-right: 1px solid var(--border);
+
+ @media (max-width: 1024px) {
+ border-right: none;
+ border-bottom: 1px solid var(--border);
+ }
+}
+
+#next-steps ul {
+ list-style: none;
+ padding: 0;
+ display: flex;
+ gap: 8px;
+ margin: 32px 0 0;
+
+ .logo {
+ height: 18px;
+ }
+
+ a {
+ color: var(--text-h);
+ font-size: 16px;
+ border-radius: 6px;
+ background: var(--social-bg);
+ display: flex;
+ padding: 6px 12px;
+ align-items: center;
+ gap: 8px;
+ text-decoration: none;
+ transition: box-shadow 0.3s;
+
+ &:hover {
+ box-shadow: var(--shadow);
+ }
+ .button-icon {
+ height: 18px;
+ width: 18px;
+ }
+ }
+
+ @media (max-width: 1024px) {
+ margin-top: 20px;
+ flex-wrap: wrap;
+ justify-content: center;
+
+ li {
+ flex: 1 1 calc(50% - 8px);
+ }
+
+ a {
+ width: 100%;
+ justify-content: center;
+ box-sizing: border-box;
+ }
+ }
+}
+
+#spacer {
+ height: 88px;
+ border-top: 1px solid var(--border);
+ @media (max-width: 1024px) {
+ height: 48px;
+ }
+}
+
+.ticks {
+ position: relative;
+ width: 100%;
+
+ &::before,
+ &::after {
+ content: "";
+ position: absolute;
+ top: -4.5px;
+ border: 5px solid transparent;
+ }
+
+ &::before {
+ left: 0;
+ border-left-color: var(--border);
+ }
+ &::after {
+ right: 0;
+ border-right-color: var(--border);
+ }
+}
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..1fd432d
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,122 @@
+import { useState } from "react";
+import reactLogo from "./assets/react.svg";
+import viteLogo from "./assets/vite.svg";
+import heroImg from "./assets/hero.png";
+import "./App.css";
+
+function App() {
+ const [count, setCount] = useState(0);
+
+ return (
+ <>
+
+
+
+
Get started
+
+ Edit src/App.tsx and save to test HMR
+
+
+
+
+
+
+
+
+
+
+
Documentation
+
Your questions, answered
+
+
+
+
+
Connect with us
+
Join the Vite community
+
+
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/src/app/App.tsx b/src/app/App.tsx
new file mode 100644
index 0000000..35f624d
--- /dev/null
+++ b/src/app/App.tsx
@@ -0,0 +1,6 @@
+import { RouterProvider } from "react-router-dom";
+import { router } from "./router";
+
+export function App() {
+ return ;
+}
diff --git a/src/app/ThemeContext.ts b/src/app/ThemeContext.ts
new file mode 100644
index 0000000..fea2749
--- /dev/null
+++ b/src/app/ThemeContext.ts
@@ -0,0 +1,8 @@
+import { createContext } from "react";
+
+export type ThemeContextType = {
+ dark: boolean;
+ toggleTheme: () => void;
+};
+
+export const ThemeContext = createContext(undefined);
diff --git a/src/app/ThemeProvider.tsx b/src/app/ThemeProvider.tsx
new file mode 100644
index 0000000..971cdf5
--- /dev/null
+++ b/src/app/ThemeProvider.tsx
@@ -0,0 +1,25 @@
+import React, { useState, useEffect } from 'react';
+import { ThemeContext } from "./ThemeContext";
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+ const [dark, setDark] = useState(false);
+
+ useEffect(() => {
+ const root = document.documentElement;
+ if (dark) {
+ root.classList.add('plimi-theme-dark');
+ root.classList.remove('plimi-theme-light');
+ } else {
+ root.classList.add('plimi-theme-light');
+ root.classList.remove('plimi-theme-dark');
+ }
+ }, [dark]);
+
+ const toggleTheme = () => setDark(!dark);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/router.tsx b/src/app/router.tsx
new file mode 100644
index 0000000..ca4fe06
--- /dev/null
+++ b/src/app/router.tsx
@@ -0,0 +1,31 @@
+import { createBrowserRouter } from "react-router-dom";
+import { AppShell } from "../components/layout/AppShell";
+import { HomePage } from "../pages/HomePage";
+import { ToolsPage } from "../pages/ToolsPage";
+import { ToolDetailPage } from "../pages/ToolDetailPage";
+import { HowItWorksPage } from "../pages/HowItWorksPage";
+
+export const router = createBrowserRouter([
+ {
+ path: "/",
+ element: ,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: "tools",
+ element: ,
+ },
+ {
+ path: "tools/:toolId",
+ element: ,
+ },
+ {
+ path: "how-it-works",
+ element: ,
+ },
+ ],
+ },
+]);
diff --git a/src/app/useTheme.ts b/src/app/useTheme.ts
new file mode 100644
index 0000000..20c560e
--- /dev/null
+++ b/src/app/useTheme.ts
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+import { ThemeContext } from "./ThemeContext";
+
+export function useTheme() {
+ const context = useContext(ThemeContext);
+ if (!context) {
+ throw new Error("useTheme must be used within a ThemeProvider");
+ }
+ return context;
+}
diff --git a/src/assets/hero.png b/src/assets/hero.png
new file mode 100644
index 0000000..02251f4
Binary files /dev/null and b/src/assets/hero.png differ
diff --git a/src/assets/logo.png b/src/assets/logo.png
new file mode 100644
index 0000000..e78df3a
Binary files /dev/null and b/src/assets/logo.png differ
diff --git a/src/assets/react.svg b/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/vite.svg b/src/assets/vite.svg
new file mode 100644
index 0000000..5101b67
--- /dev/null
+++ b/src/assets/vite.svg
@@ -0,0 +1 @@
+
diff --git a/src/components/directory/DirectoryComponents.tsx b/src/components/directory/DirectoryComponents.tsx
new file mode 100644
index 0000000..dd6e3be
--- /dev/null
+++ b/src/components/directory/DirectoryComponents.tsx
@@ -0,0 +1,263 @@
+import React, { useRef, useEffect } from 'react';
+import type { UnknownPlimiPlugin } from '../../core/plugins/plugin-types';
+import { CategoryIcon } from '../ui/CategoryIcon';
+
+export function PlimiSearch({
+ value,
+ onChange,
+ count,
+ total,
+ onArrow,
+ onEnter,
+}: {
+ value: string;
+ onChange: (val: string) => void;
+ count: number;
+ total: number;
+ onArrow?: (dir: number) => void;
+ onEnter?: () => void;
+}) {
+ const inputRef = useRef(null);
+ const shortcutText =
+ typeof window !== "undefined" &&
+ /Mac|iPod|iPhone|iPad/.test(navigator.userAgent || navigator.platform || "")
+ ? "⌘ K"
+ : "Ctrl K";
+
+ useEffect(() => {
+ const handleGlobalKeyDown = (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
+ e.preventDefault();
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }
+ };
+
+ window.addEventListener('keydown', handleGlobalKeyDown);
+ return () => {
+ window.removeEventListener('keydown', handleGlobalKeyDown);
+ };
+ }, []);
+
+ return (
+
+
inputRef.current?.focus()}
+ >
+
+
onChange(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+ e.preventDefault();
+ onArrow?.(e.key === 'ArrowDown' ? 1 : -1);
+ }
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ onEnter?.();
+ }
+ if (e.key === 'Escape') {
+ onChange('');
+ }
+ }}
+ placeholder="Start typing to find a tool…"
+ spellCheck={false}
+ className="flex-1 min-w-0 border-none outline-none bg-transparent text-[var(--p-text)] text-2xl font-sans tracking-tight font-medium"
+ />
+
+ {value ? (
+
+ ) : (
+
+ {shortcutText}
+
+ )}
+
+
+
+ {value ? `${count} match${count === 1 ? '' : 'es'}` : `${total} tools`}
+ ↑↓ to browse · enter to open
+
+
+ );
+}
+
+export function CategoryChips({
+ active,
+ onPick,
+ counts,
+ categories,
+}: {
+ active: string;
+ onPick: (cat: string) => void;
+ counts: Record;
+ categories: { id: string; label: string }[];
+}) {
+ return (
+
+ {categories.map((c) => {
+ const on = active === c.id;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function tilt(id: string, range = 1.5) {
+ let h = 0;
+ for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0;
+ const x = (Math.abs(h) % 1000) / 1000;
+ return (x - 0.5) * 2 * range;
+}
+
+const TINTS: Record = {
+ image: { paper: '#E8F5E9', ink: '#2E7D32', neon: '#69F0AE' },
+ pdf: { paper: '#FFEBEE', ink: '#C62828', neon: '#FF5252' },
+ developer: { paper: '#E3F2FD', ink: '#1565C0', neon: '#448AFF' },
+ text: { paper: '#FFF8E1', ink: '#F57F17', neon: '#FFD740' },
+ crypto: { paper: '#F3E5F5', ink: '#6A1B9A', neon: '#E040FB' },
+ privacy: { paper: '#E0F2F1', ink: '#00695C', neon: '#64FFDA' },
+};
+
+export function ToolTile({
+ plugin,
+ focused,
+ onClick,
+ dark,
+}: {
+ plugin: UnknownPlimiPlugin;
+ focused: boolean;
+ onClick: (plugin: UnknownPlimiPlugin) => void;
+ dark: boolean;
+}) {
+ const [hover, setHover] = React.useState(false);
+ const tints = TINTS[plugin.manifest.category] || TINTS.developer;
+ const t = tilt(plugin.manifest.id, 1.5);
+ const lifted = hover || focused;
+
+ const iconColor = dark ? tints.neon : tints.ink;
+
+ const bg = 'var(--p-surface)';
+ const stickerEdge = dark
+ ? `color-mix(in oklab, ${tints.neon} 22%, var(--p-border))`
+ : `color-mix(in oklab, ${tints.ink} 18%, var(--p-border))`;
+
+ return (
+
+ );
+}
+
+export function SectionHeader({ catLabel, catId, n }: { catLabel: string; catId: string; n: number }) {
+ const tints = TINTS[catId] || TINTS.developer;
+
+ return (
+
+
+
+ {catLabel}
+ {String(n).padStart(2, '0')}
+
+
+ );
+}
diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx
new file mode 100644
index 0000000..25c4a38
--- /dev/null
+++ b/src/components/layout/AppShell.tsx
@@ -0,0 +1,13 @@
+import { Outlet } from "react-router-dom";
+import { Header } from "./Header";
+
+export function AppShell() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
new file mode 100644
index 0000000..0053350
--- /dev/null
+++ b/src/components/layout/Header.tsx
@@ -0,0 +1,124 @@
+import { Link, useLocation } from "react-router-dom";
+import { useTheme } from "../../app/useTheme";
+
+function PlimiMark({ size = 28 }: { size?: number }) {
+ return (
+
+
+ plimi
+
+
+
+ );
+}
+
+
+
+function ThemeToggle({ dark, onClick }: { dark: boolean; onClick: () => void }) {
+ return (
+
+ );
+}
+
+export function Header() {
+ const { dark, toggleTheme } = useTheme();
+ const location = useLocation();
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
new file mode 100644
index 0000000..385557a
--- /dev/null
+++ b/src/components/layout/Sidebar.tsx
@@ -0,0 +1,27 @@
+import { NavLink } from "react-router-dom";
+
+export function Sidebar() {
+ return (
+
+ );
+}
diff --git a/src/components/tool/ExampleActionButton.tsx b/src/components/tool/ExampleActionButton.tsx
new file mode 100644
index 0000000..736387f
--- /dev/null
+++ b/src/components/tool/ExampleActionButton.tsx
@@ -0,0 +1,25 @@
+import { Button } from "../ui/Button";
+
+export function ExampleActionButton({
+ label = "Try example",
+ disabled,
+ onClick,
+ className = "",
+}: {
+ label?: string;
+ disabled?: boolean;
+ onClick: () => void;
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/tool/ToolInputPanel.tsx b/src/components/tool/ToolInputPanel.tsx
new file mode 100644
index 0000000..49a3865
--- /dev/null
+++ b/src/components/tool/ToolInputPanel.tsx
@@ -0,0 +1,246 @@
+import {
+ getInputValue,
+ setInputValue,
+ type ToolInput,
+ type ToolInputDefinition,
+ type ToolInputFieldDefinition,
+} from "../../core/io/input-types";
+import { Dropzone } from "../ui/Dropzone";
+
+interface ToolInputPanelTints {
+ paper: string;
+ ink: string;
+ neon: string;
+}
+
+export function ToolInputPanel({
+ definition,
+ value,
+ onChange,
+ dark,
+ tints,
+}: {
+ definition: ToolInputDefinition;
+ value: ToolInput;
+ onChange: (val: ToolInput) => void;
+ dark?: boolean;
+ tints?: ToolInputPanelTints;
+}) {
+ const isDark = dark ?? false;
+
+ const fields: ToolInputFieldDefinition[] =
+ definition.type === "group"
+ ? definition.fields
+ : definition.type === "none"
+ ? []
+ : [definition];
+
+ const handleTextChange = (fieldKey: string, newText: string) => {
+ const prev = getInputValue(value, fieldKey);
+ onChange(setInputValue(value, fieldKey, { ...prev, text: newText }));
+ };
+
+ const handleFilesChange = (fieldKey: string, files?: File[]) => {
+ const prev = getInputValue(value, fieldKey);
+ onChange(setInputValue(value, fieldKey, { ...prev, files }));
+ };
+
+ if (definition.type === "none") {
+ return (
+
+
+ no input required
+
+
+ This tool runs from its options only.
+
+
+ );
+ }
+
+ const renderFileField = (
+ field: Extract,
+ index: number
+ ) => {
+ const fieldKey = field.key ?? "input";
+ const fieldValue = getInputValue(value, fieldKey);
+
+ return (
+
+
+ {field.label ?? "input files"}
+
+
+ {field.description && (
+
+ {field.description}
+
+ )}
+
+ {fieldValue.files && fieldValue.files.length > 0 ? (
+
+
+ {fieldValue.files.map((file, i) => (
+ -
+
+
+
+ {file.name}
+
+
+
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+
+
+ ))}
+
+
+
+
+
+ ) : (
+
+
+
handleFilesChange(fieldKey, files)}
+ className="w-full flex-1 border-none"
+ />
+
+ processed in your browser
+
+
+ )}
+
+ );
+ };
+
+ const renderTextField = (
+ field: Extract,
+ index: number
+ ) => {
+ const fieldKey = field.key ?? "input";
+ const fieldValue = getInputValue(value, fieldKey);
+ const placeholder =
+ field.type === "text"
+ ? (field.placeholder ?? "Enter input here...")
+ : "Enter input here...";
+
+ const isMultiline =
+ field.type === "text-or-files" ||
+ (field.type === "text" && field.multiline !== false);
+ const rows = field.type === "text" ? field.rows : undefined;
+
+ return (
+
+
+ {field.label ?? "input"}
+ {isMultiline ? "paste / drop / type" : "type"}
+
+ {field.description && (
+
+ {field.description}
+
+ )}
+ {isMultiline ? (
+
+ );
+ };
+
+ return (
+
+ {fields.map((field, index) =>
+ field.type === "files"
+ ? renderFileField(field, index)
+ : renderTextField(field, index)
+ )}
+
+ );
+}
diff --git a/src/components/tool/ToolOptionsPanel.tsx b/src/components/tool/ToolOptionsPanel.tsx
new file mode 100644
index 0000000..d514a04
--- /dev/null
+++ b/src/components/tool/ToolOptionsPanel.tsx
@@ -0,0 +1,117 @@
+import type { ToolOptionsSchema, ToolOptionValue, ToolOptionsValue } from "../../core/plugins/plugin-types";
+import { Input } from "../ui/Input";
+import { Select } from "../ui/Select";
+import { Slider } from "../ui/Slider";
+
+interface ToolOptionsPanelTints {
+ paper: string;
+ ink: string;
+ neon: string;
+}
+
+export function ToolOptionsPanel({
+ schema,
+ value,
+ onChange,
+ dark,
+ tints,
+ toolCategory
+}: {
+ schema: ToolOptionsSchema;
+ value: ToolOptionsValue;
+ onChange: (val: ToolOptionsValue) => void;
+ dark?: boolean;
+ tints?: ToolOptionsPanelTints;
+ toolCategory?: string;
+}) {
+ const isDark = dark ?? false;
+
+ const handleFieldChange = (key: string, val: ToolOptionValue) => {
+ onChange({ ...value, [key]: val });
+ };
+
+ const isWasm = toolCategory === 'image' || toolCategory === 'pdf';
+
+ return (
+
+
+ options
+
+
+
+
+
+
+ engine
+
+
+ {isWasm ? 'wasm' : 'js worker'}
+
+
+
+ );
+}
diff --git a/src/components/tool/ToolProgress.tsx b/src/components/tool/ToolProgress.tsx
new file mode 100644
index 0000000..dccad69
--- /dev/null
+++ b/src/components/tool/ToolProgress.tsx
@@ -0,0 +1,54 @@
+interface ToolProgressTints {
+ ink: string;
+ neon: string;
+}
+
+export function ToolProgress({
+ percentage,
+ message,
+ onCancel,
+ dark,
+ tints
+}: {
+ percentage: number;
+ message?: string;
+ onCancel?: () => void;
+ dark?: boolean;
+ tints?: ToolProgressTints;
+}) {
+ const isDark = dark ?? false;
+
+ return (
+
+
+
+ {message || "Processing..."}
+
+
+ {Math.round(percentage)}%
+
+
+
+
+
+ {onCancel && (
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/tool/ToolResultPanel.tsx b/src/components/tool/ToolResultPanel.tsx
new file mode 100644
index 0000000..d74e475
--- /dev/null
+++ b/src/components/tool/ToolResultPanel.tsx
@@ -0,0 +1,295 @@
+import { useState, useCallback } from "react";
+import type { ToolResult } from "../../core/io/output-types";
+
+interface ToolResultPanelTints {
+ neon: string;
+}
+
+export function ToolResultPanel({
+ result,
+ dark,
+ tints,
+}: {
+ result: ToolResult;
+ dark?: boolean;
+ tints?: ToolResultPanelTints;
+}) {
+ const [copied, setCopied] = useState(false);
+ const isDark = dark ?? false;
+
+ const handleCopy = useCallback((text: string) => {
+ navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }, []);
+
+ const downloadBlob = useCallback((blob: Blob, filename: string) => {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }, []);
+
+ if (result.type === "text") {
+ return (
+
+
+ output
+ {result.value && (
+
+ )}
+
+
+ {result.value || ""}
+
+
+ );
+ }
+
+ if (result.type === "json") {
+ const value = result.value;
+ const isObject = typeof value === "object" && value !== null && !Array.isArray(value);
+ const valObj = isObject ? (value as Record) : null;
+ const primaryVal = valObj && "primary" in valObj ? String(valObj.primary) : null;
+
+ // Filter out 'primary' from the fields displayed in the grid
+ const fields = valObj
+ ? Object.entries(valObj).filter(([key]) => key !== "primary")
+ : [];
+
+ return (
+
+ {primaryVal !== null && (
+
+
+ Primary Result
+
+
+
+ {primaryVal}
+
+
+
+
+ )}
+
+
+
+ {primaryVal !== null ? "All Conversions / Details" : "output"}
+ {!primaryVal && (
+
+ )}
+
+
+ {isObject ? (
+
+ {fields.map(([key, val]) => {
+ const valStr = typeof val === "object" && val !== null ? JSON.stringify(val) : String(val ?? "null");
+ const isValObject = typeof val === "object" && val !== null;
+ return (
+
+
+
{key}
+ {isValObject ? (
+
+ {renderJsonValue(val)}
+
+ ) : (
+
{valStr}
+ )}
+
+ {!isValObject && (
+
+ )}
+
+ );
+ })}
+
+ ) : (
+ renderJsonValue(value)
+ )}
+
+
+
+ );
+ }
+
+ if (result.type === "files" && result.files) {
+ return (
+
+ );
+ }
+
+ if (result.type === "table" && result.columns && result.rows) {
+ return (
+
+
+ output table
+
+
+
+
+
+
+ {result.columns.map((col, i) => (
+ |
+ {col}
+ |
+ ))}
+
+
+
+ {result.rows.map((row, rIndex) => (
+
+ {row.map((cell, cIndex) => (
+ |
+ {cell === null ? (
+ null
+ ) : (
+ String(cell)
+ )}
+ |
+ ))}
+
+ ))}
+
+
+
+
+ );
+ }
+
+ return null;
+}
+
+function renderJsonValue(value: unknown): React.ReactNode {
+ if (value === null || value === undefined) return "null";
+ if (typeof value === "string") return value;
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
+
+ if (Array.isArray(value)) {
+ if (value.length === 0) return "[]";
+ return (
+
+ {value.map((item, i) => (
+
+ {renderJsonValue(item)}
+
+ ))}
+
+ );
+ }
+
+ if (typeof value === "object") {
+ const entries = Object.entries(value as Record);
+ if (entries.length === 0) return "{}";
+ return (
+
+ {entries.map(([key, val]) => (
+
+
{key}
+
+ {typeof val === "object" && val !== null ? (
+ renderJsonValue(val)
+ ) : (
+ {String(val ?? "null")}
+ )}
+
+
+ ))}
+
+ );
+ }
+
+ return String(value);
+}
diff --git a/src/components/tool/ToolShell.tsx b/src/components/tool/ToolShell.tsx
new file mode 100644
index 0000000..1239669
--- /dev/null
+++ b/src/components/tool/ToolShell.tsx
@@ -0,0 +1,231 @@
+import { Suspense, useCallback, useEffect, useState } from "react";
+import type { ToolInput } from "../../core/io/input-types";
+import type {
+ ToolOptionsValue,
+ UnknownPlimiPlugin,
+} from "../../core/plugins/plugin-types";
+import { describeToolPermissions } from "../../core/plugins/plugin-permissions";
+import { ToolInputPanel } from "./ToolInputPanel";
+import { ToolOptionsPanel } from "./ToolOptionsPanel";
+import { ToolResultPanel } from "./ToolResultPanel";
+import { ToolProgress } from "./ToolProgress";
+import { useToolExecution } from "./useToolExecution";
+import { CategoryIcon } from "../ui/CategoryIcon";
+import { getPluginExamples } from "../../core/plugins/plugin-examples";
+import { ExampleActionButton } from "./ExampleActionButton";
+
+const TINTS: Record = {
+ image: { paper: "#E8F5E9", ink: "#2E7D32", neon: "#69F0AE" },
+ pdf: { paper: "#FFEBEE", ink: "#C62828", neon: "#FF5252" },
+ developer: { paper: "#E3F2FD", ink: "#1565C0", neon: "#448AFF" },
+ text: { paper: "#FFF8E1", ink: "#F57F17", neon: "#FFD740" },
+ crypto: { paper: "#F3E5F5", ink: "#6A1B9A", neon: "#E040FB" },
+ privacy: { paper: "#E0F2F1", ink: "#00695C", neon: "#64FFDA" },
+};
+
+function ToolHeader({
+ plugin,
+ dark,
+ onClose,
+}: {
+ plugin: UnknownPlimiPlugin;
+ dark: boolean;
+ onClose?: () => void;
+}) {
+ const tints = TINTS[plugin.manifest.category] || TINTS.developer;
+ const permissions = describeToolPermissions(plugin);
+
+ return (
+
+
+
+
+
+
+
+ {plugin.manifest.name}
+
+
+ {plugin.manifest.category} / {permissions.join(" / ")}
+
+
+
+
+ {onClose && (
+
+ )}
+
+ );
+}
+
+export function ToolShell({
+ plugin,
+ onClose,
+ dark,
+}: {
+ plugin: UnknownPlimiPlugin;
+ onClose?: () => void;
+ dark?: boolean;
+}) {
+ const [input, setInput] = useState({});
+ const [options, setOptions] = useState({});
+ const { run, cancel, result, isExecuting, progress, error } =
+ useToolExecution(plugin);
+
+ const tints = TINTS[plugin.manifest.category] || TINTS.developer;
+ const isDark = dark ?? false;
+ const examples = getPluginExamples(plugin);
+ const primaryExample = examples[0];
+
+ const handleRun = useCallback(async () => {
+ await run(input, options);
+ }, [input, options, run]);
+
+ const handleApplyExample = useCallback(() => {
+ if (!primaryExample) return;
+
+ setInput(primaryExample.input);
+ if (primaryExample.options) {
+ const exampleOptions = Object.fromEntries(
+ Object.entries(primaryExample.options).filter(([, value]) => value !== undefined)
+ ) as ToolOptionsValue;
+
+ setOptions((currentOptions) => ({
+ ...currentOptions,
+ ...exampleOptions,
+ }));
+ }
+ }, [primaryExample]);
+
+ useEffect(() => {
+ if (plugin.capabilities?.customUi && plugin.customUi) {
+ return;
+ }
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
+ e.preventDefault();
+ if (!isExecuting) {
+ handleRun();
+ }
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [handleRun, isExecuting, plugin]);
+
+ if (plugin.capabilities?.customUi && plugin.customUi) {
+ const CustomUi = plugin.customUi;
+
+ return (
+ <>
+
+
+
+ Loading tool...
+
+ }
+ >
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ {isExecuting && (
+
+ )}
+
+ {error && (
+
+ Error: {error}
+
+ )}
+
+ {result && !isExecuting && (
+
+ )}
+
+
+
+ {plugin.optionsSchema && (
+
+ )}
+
+
+
+
+
+ ready when you are
+
+
+ {primaryExample && (
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/src/components/tool/useToolExecution.ts b/src/components/tool/useToolExecution.ts
new file mode 100644
index 0000000..e8eabc7
--- /dev/null
+++ b/src/components/tool/useToolExecution.ts
@@ -0,0 +1,82 @@
+import { useCallback, useRef, useState } from "react";
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { PlimiPlugin, ToolContext, ToolProgress } from "../../core/plugins/plugin-types";
+import { runPlugin } from "../../core/plugins/plugin-runner";
+
+function errorMessage(error: unknown): string {
+ if (error instanceof DOMException && error.name === "AbortError") {
+ return "Operation was cancelled.";
+ }
+
+ if (error instanceof Error) {
+ return error.message || "An error occurred during execution.";
+ }
+
+ return "An error occurred during execution.";
+}
+
+export function useToolExecution(plugin: PlimiPlugin) {
+ const [result, setResult] = useState(null);
+ const [isExecuting, setIsExecuting] = useState(false);
+ const [progress, setProgress] = useState({
+ percentage: 0,
+ message: "",
+ });
+ const [error, setError] = useState(null);
+
+ const abortControllerRef = useRef(null);
+
+ const run = useCallback(async (input: ToolInput, options: TOptions) => {
+ setIsExecuting(true);
+ setResult(null);
+ setError(null);
+ setProgress({ percentage: 0, message: "Initializing..." });
+
+ const abortController = new AbortController();
+ abortControllerRef.current = abortController;
+
+ const context: ToolContext = {
+ signal: abortController.signal,
+ reportProgress: (nextProgress) => {
+ setProgress({
+ percentage: nextProgress.percentage ?? 0,
+ message: nextProgress.message ?? "",
+ });
+ },
+ logger: console,
+ };
+
+ try {
+ const nextResult = await runPlugin(plugin, input, options, context);
+ setResult(nextResult);
+ return nextResult;
+ } catch (err) {
+ setError(errorMessage(err));
+ return null;
+ } finally {
+ setIsExecuting(false);
+ abortControllerRef.current = null;
+ }
+ }, [plugin]);
+
+ const cancel = useCallback(() => {
+ abortControllerRef.current?.abort();
+ }, []);
+
+ const reset = useCallback(() => {
+ setResult(null);
+ setError(null);
+ setProgress({ percentage: 0, message: "" });
+ }, []);
+
+ return {
+ run,
+ cancel,
+ reset,
+ result,
+ isExecuting,
+ progress,
+ error,
+ };
+}
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
new file mode 100644
index 0000000..7b0368e
--- /dev/null
+++ b/src/components/ui/Button.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+
+interface ButtonProps extends React.ButtonHTMLAttributes {
+ variant?: "primary" | "secondary" | "danger";
+}
+
+export function Button({ variant = "primary", className = "", ...props }: ButtonProps) {
+ const baseStyles = "inline-flex items-center justify-center px-[18px] py-[10px] rounded-[10px] font-sans font-semibold text-[13px] tracking-tight cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed transition-all";
+
+ let variantStyles = "";
+ switch (variant) {
+ case "primary":
+ variantStyles = "bg-[var(--p-accent)] text-[var(--p-accent-ink)] hover:brightness-110";
+ break;
+ case "secondary":
+ variantStyles = "bg-[var(--p-chip)] text-[var(--p-text)] border border-[var(--p-border)] hover:bg-[var(--p-border)]";
+ break;
+ case "danger":
+ variantStyles = "bg-red-500 text-white hover:bg-red-600";
+ break;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx
new file mode 100644
index 0000000..010da84
--- /dev/null
+++ b/src/components/ui/Card.tsx
@@ -0,0 +1,22 @@
+import React from "react";
+
+interface CardProps {
+ title?: string;
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function Card({ title, children, className = "" }: CardProps) {
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/ui/CategoryIcon.tsx b/src/components/ui/CategoryIcon.tsx
new file mode 100644
index 0000000..87616fa
--- /dev/null
+++ b/src/components/ui/CategoryIcon.tsx
@@ -0,0 +1,145 @@
+import React from "react";
+
+interface CategoryIconProps extends React.SVGProps {
+ category: string;
+ size?: number;
+ color?: string;
+ strokeWidth?: number;
+}
+
+export function CategoryIcon({
+ category,
+ size = 24,
+ color = "currentColor",
+ strokeWidth = 2,
+ ...props
+}: CategoryIconProps) {
+ const normCategory = category.toLowerCase();
+
+ switch (normCategory) {
+ case "developer":
+ return (
+
+ );
+ case "image":
+ return (
+
+ );
+ case "pdf":
+ return (
+
+ );
+ case "text":
+ return (
+
+ );
+ case "crypto":
+ return (
+
+ );
+ case "privacy":
+ return (
+
+ );
+ default:
+ // Fallback to the original triangle shape
+ return (
+
+ );
+ }
+}
diff --git a/src/components/ui/Dropzone.tsx b/src/components/ui/Dropzone.tsx
new file mode 100644
index 0000000..8300956
--- /dev/null
+++ b/src/components/ui/Dropzone.tsx
@@ -0,0 +1,140 @@
+import React, { useCallback, useState } from "react";
+
+interface DropzoneProps {
+ accept?: string;
+ multiple?: boolean;
+ maxFiles?: number;
+ maxSizeMb?: number;
+ onFilesDrop: (files: File[]) => void;
+ className?: string;
+}
+
+function matchesAccept(file: File, accept?: string): boolean {
+ if (!accept) return true;
+ const rules = accept.split(",").map((rule) => rule.trim()).filter(Boolean);
+ if (rules.length === 0) return true;
+
+ return rules.some((rule) => {
+ if (rule.endsWith("/*")) return file.type.startsWith(rule.slice(0, -1));
+ if (rule.startsWith(".")) return file.name.toLowerCase().endsWith(rule.toLowerCase());
+ return file.type === rule;
+ });
+}
+
+export function Dropzone({
+ accept,
+ multiple = false,
+ maxFiles,
+ maxSizeMb,
+ onFilesDrop,
+ className = "",
+}: DropzoneProps) {
+ const [isDragActive, setIsDragActive] = useState(false);
+ const [error, setError] = useState(null);
+
+ const normalizeFiles = useCallback((incomingFiles: File[]) => {
+ const pickedFiles = multiple ? incomingFiles : incomingFiles.slice(0, 1);
+ const limitedFiles = maxFiles ? pickedFiles.slice(0, maxFiles) : pickedFiles;
+ const maxBytes = maxSizeMb ? maxSizeMb * 1024 * 1024 : undefined;
+
+ const invalidType = limitedFiles.find((file) => !matchesAccept(file, accept));
+ if (invalidType) {
+ setError(`${invalidType.name} is not an accepted file type.`);
+ return null;
+ }
+
+ const oversized = maxBytes
+ ? limitedFiles.find((file) => file.size > maxBytes)
+ : undefined;
+ if (oversized) {
+ setError(`${oversized.name} is larger than ${maxSizeMb} MB.`);
+ return null;
+ }
+
+ setError(null);
+ return limitedFiles;
+ }, [accept, maxFiles, maxSizeMb, multiple]);
+
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragActive(true);
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragActive(false);
+ }, []);
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!isDragActive) {
+ setIsDragActive(true);
+ }
+ }, [isDragActive]);
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragActive(false);
+
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
+ const droppedFiles = normalizeFiles(Array.from(e.dataTransfer.files));
+ if (droppedFiles) onFilesDrop(droppedFiles);
+ e.dataTransfer.clearData();
+ }
+ },
+ [normalizeFiles, onFilesDrop]
+ );
+
+ const handleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ e.preventDefault();
+ if (e.target.files && e.target.files.length > 0) {
+ const selectedFiles = normalizeFiles(Array.from(e.target.files));
+ if (selectedFiles) onFilesDrop(selectedFiles);
+ }
+ },
+ [normalizeFiles, onFilesDrop]
+ );
+
+ return (
+
+
+
+
+
+ {isDragActive ? "Drop files now" : "Drop files here or click to browse"}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx
new file mode 100644
index 0000000..3de390b
--- /dev/null
+++ b/src/components/ui/Input.tsx
@@ -0,0 +1,41 @@
+import React from "react";
+
+interface InputProps extends React.InputHTMLAttributes {
+ label?: string;
+}
+
+export function Input({ label, className = "", ...props }: InputProps) {
+ return (
+
+ {label && (
+
+ )}
+
+
+ );
+}
+
+interface TextareaProps extends React.TextareaHTMLAttributes {
+ label?: string;
+}
+
+export function Textarea({ label, className = "", ...props }: TextareaProps) {
+ return (
+
+ {label && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx
new file mode 100644
index 0000000..2dd6e1a
--- /dev/null
+++ b/src/components/ui/Select.tsx
@@ -0,0 +1,33 @@
+import React from "react";
+
+interface SelectOption {
+ label: string;
+ value: string;
+}
+
+interface SelectProps extends React.SelectHTMLAttributes {
+ label?: string;
+ options: SelectOption[];
+}
+
+export function Select({ label, options, className = "", ...props }: SelectProps) {
+ return (
+
+ {label && (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/ui/Slider.tsx b/src/components/ui/Slider.tsx
new file mode 100644
index 0000000..d9757df
--- /dev/null
+++ b/src/components/ui/Slider.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+
+interface SliderProps extends React.InputHTMLAttributes {
+ label?: string;
+ value: number;
+}
+
+export function Slider({ label, className = "", ...props }: SliderProps) {
+ return (
+
+ {label && (
+
+
+
+ {props.value}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/core/io/input-types.ts b/src/core/io/input-types.ts
new file mode 100644
index 0000000..39dff52
--- /dev/null
+++ b/src/core/io/input-types.ts
@@ -0,0 +1,87 @@
+export interface ToolInputValue {
+ text?: string;
+ files?: File[];
+}
+
+interface BaseToolInputFieldDefinition {
+ key?: string;
+ label?: string;
+ description?: string;
+ example?: string;
+}
+
+export type ToolInputFieldDefinition =
+ | (BaseToolInputFieldDefinition & {
+ type: "text";
+ maxLength?: number;
+ placeholder?: string;
+ multiline?: boolean;
+ rows?: number;
+ })
+ | (BaseToolInputFieldDefinition & {
+ type: "files";
+ accept?: string[];
+ multiple?: boolean;
+ maxSizeMb?: number;
+ maxFiles?: number;
+ })
+ | (BaseToolInputFieldDefinition & {
+ type: "text-or-files";
+ accept?: string[];
+ maxSizeMb?: number;
+ });
+
+export type ToolInputDefinition =
+ | {
+ type: "none";
+ }
+ | ToolInputFieldDefinition
+ | {
+ type: "group";
+ fields: ToolInputFieldDefinition[];
+ };
+
+export interface ToolInput extends ToolInputValue {
+ values?: Record;
+}
+
+export function getInputValue(input: ToolInput, key = "input"): ToolInputValue {
+ if (key === "input") {
+ return {
+ text: input.text,
+ files: input.files,
+ };
+ }
+
+ return input.values?.[key] ?? {};
+}
+
+export function getTextInput(input: ToolInput, key = "input"): string {
+ return getInputValue(input, key).text ?? "";
+}
+
+export function getFilesInput(input: ToolInput, key = "input"): File[] | undefined {
+ return getInputValue(input, key).files;
+}
+
+export function setInputValue(
+ input: ToolInput,
+ key: string,
+ value: ToolInputValue
+): ToolInput {
+ if (key === "input") {
+ return {
+ ...input,
+ text: value.text,
+ files: value.files,
+ };
+ }
+
+ return {
+ ...input,
+ values: {
+ ...(input.values ?? {}),
+ [key]: value,
+ },
+ };
+}
diff --git a/src/core/io/output-types.ts b/src/core/io/output-types.ts
new file mode 100644
index 0000000..e792972
--- /dev/null
+++ b/src/core/io/output-types.ts
@@ -0,0 +1,33 @@
+export interface OutputFile {
+ name: string;
+ mimeType: string;
+ blob: Blob;
+ sizeBefore?: number;
+ sizeAfter?: number;
+}
+
+export type ToolOutputDefinition =
+ | { type: "text" }
+ | { type: "files" }
+ | { type: "json" }
+ | { type: "table" };
+
+export type ToolResult =
+ | {
+ type: "text";
+ value: string;
+ language?: "plain" | "json" | "html" | "markdown";
+ }
+ | {
+ type: "files";
+ files: OutputFile[];
+ }
+ | {
+ type: "json";
+ value: unknown;
+ }
+ | {
+ type: "table";
+ columns: string[];
+ rows: Array>;
+ };
diff --git a/src/core/plugins/plugin-examples.ts b/src/core/plugins/plugin-examples.ts
new file mode 100644
index 0000000..c715f85
--- /dev/null
+++ b/src/core/plugins/plugin-examples.ts
@@ -0,0 +1,70 @@
+import {
+ setInputValue,
+ type ToolInput,
+ type ToolInputDefinition,
+ type ToolInputFieldDefinition,
+} from "../io/input-types";
+import type {
+ ToolExample,
+ ToolOptionsValue,
+ UnknownPlimiPlugin,
+} from "./plugin-types";
+
+const DEFAULT_LABEL = "Try example";
+
+function exampleForField(
+ field: ToolInputFieldDefinition,
+ fallback?: string
+): string | undefined {
+ if (field.type === "files") return undefined;
+ return field.example ?? fallback;
+}
+
+function inputFromDefinition(
+ definition: ToolInputDefinition,
+ manifestExample?: string
+): ToolInput | undefined {
+ if (definition.type === "none") {
+ return manifestExample ? {} : undefined;
+ }
+
+ if (definition.type !== "group") {
+ const example = exampleForField(definition, manifestExample);
+ return example
+ ? setInputValue({}, definition.key ?? "input", { text: example })
+ : undefined;
+ }
+
+ let nextInput: ToolInput = {};
+ let hasExample = false;
+
+ definition.fields.forEach((field) => {
+ const example = exampleForField(field);
+ if (!example) return;
+
+ hasExample = true;
+ nextInput = setInputValue(nextInput, field.key ?? "input", {
+ text: example,
+ });
+ });
+
+ return hasExample ? nextInput : undefined;
+}
+
+export function getPluginExamples(
+ plugin: UnknownPlimiPlugin
+): ToolExample[] {
+ if (plugin.examples && plugin.examples.length > 0) {
+ return plugin.examples.map((example) => ({
+ ...example,
+ label: example.label ?? DEFAULT_LABEL,
+ }));
+ }
+
+ const input = inputFromDefinition(
+ plugin.manifest.input,
+ plugin.manifest.example
+ );
+
+ return input ? [{ label: DEFAULT_LABEL, input }] : [];
+}
diff --git a/src/core/plugins/plugin-loader.ts b/src/core/plugins/plugin-loader.ts
new file mode 100644
index 0000000..6b2f999
--- /dev/null
+++ b/src/core/plugins/plugin-loader.ts
@@ -0,0 +1,12 @@
+import type { UnknownPlimiPlugin } from "./plugin-types";
+import { getPluginById, getAllPlugins } from "./plugin-registry";
+
+// In V1, loading is static since we bundle the plugins.
+// In the future, this file will handle lazy loading of external plugins.
+export async function loadPlugin(id: string): Promise {
+ return getPluginById(id);
+}
+
+export async function loadAllPlugins(): Promise {
+ return getAllPlugins();
+}
diff --git a/src/core/plugins/plugin-permissions.ts b/src/core/plugins/plugin-permissions.ts
new file mode 100644
index 0000000..661068a
--- /dev/null
+++ b/src/core/plugins/plugin-permissions.ts
@@ -0,0 +1,35 @@
+import type { ToolPermissions, UnknownPlimiPlugin } from "./plugin-types";
+
+export const DEFAULT_PERMISSIONS: ToolPermissions = {
+ network: "none",
+ clipboard: "none",
+ fileSystem: "read",
+};
+
+export function getToolPermissions(plugin: UnknownPlimiPlugin): ToolPermissions {
+ return {
+ ...DEFAULT_PERMISSIONS,
+ ...plugin.permissions,
+ };
+}
+
+export function describeToolPermissions(plugin: UnknownPlimiPlugin): string[] {
+ const permissions = getToolPermissions(plugin);
+ const descriptions = ["Runs locally"];
+
+ if (permissions.network === "none") {
+ descriptions.push("No network");
+ } else {
+ descriptions.push(`${permissions.network} network`);
+ }
+
+ if (permissions.clipboard && permissions.clipboard !== "none") {
+ descriptions.push(`Clipboard ${permissions.clipboard}`);
+ }
+
+ if (permissions.fileSystem && permissions.fileSystem !== "none") {
+ descriptions.push(`Files ${permissions.fileSystem}`);
+ }
+
+ return descriptions;
+}
diff --git a/src/core/plugins/plugin-registry.ts b/src/core/plugins/plugin-registry.ts
new file mode 100644
index 0000000..cc00dba
--- /dev/null
+++ b/src/core/plugins/plugin-registry.ts
@@ -0,0 +1,74 @@
+import type { PlimiPlugin, UnknownPlimiPlugin } from "./plugin-types";
+import { base64Plugin } from "../../tools/base64";
+import { imageOptimizerPlugin } from "../../tools/image-optimizer";
+import { imageEditorPlugin } from "../../tools/image-editor";
+import { pdfMergerPlugin } from "../../tools/pdf-merger";
+import { hashGeneratorPlugin } from "../../tools/hash-generator";
+import { uuidGeneratorPlugin } from "../../tools/uuid-generator";
+import { jsonFormatterPlugin } from "../../tools/json-formatter";
+import { textCasePlugin } from "../../tools/text-case";
+import { urlEncoderPlugin } from "../../tools/url-encoder";
+import { loremIpsumPlugin } from "../../tools/lorem-ipsum";
+import { colorConverterPlugin } from "../../tools/color-converter";
+import { regexTesterPlugin } from "../../tools/regex-tester";
+import { numberBasePlugin } from "../../tools/number-base";
+import { timestampConverterPlugin } from "../../tools/timestamp-converter";
+import { markdownToHtmlPlugin } from "../../tools/markdown-to-html";
+import { textDiffPlugin } from "../../tools/text-diff";
+import { htmlEntityPlugin } from "../../tools/html-entity";
+import { variablesConverterPlugin } from "../../tools/variables-converter";
+import { pdfSplitterPlugin } from "../../tools/pdf-splitter";
+import { imageRedactorPlugin } from "../../tools/image-redactor";
+import { exifScrubberPlugin } from "../../tools/exif-scrubber";
+import { jwtDecoderPlugin } from "../../tools/jwt-decoder";
+import { csvToolsPlugin } from "../../tools/csv-tools";
+import { qrCodeGeneratorPlugin } from "../../tools/qr-code-generator";
+import { passwordGeneratorPlugin } from "../../tools/password-generator";
+import { fileChecksumVerifierPlugin } from "../../tools/file-checksum-verifier";
+
+function erasePlugin(plugin: PlimiPlugin): UnknownPlimiPlugin {
+ return plugin as unknown as UnknownPlimiPlugin;
+}
+
+export const pluginRegistry: UnknownPlimiPlugin[] = [
+ erasePlugin(base64Plugin),
+ erasePlugin(hashGeneratorPlugin),
+ erasePlugin(uuidGeneratorPlugin),
+ erasePlugin(jsonFormatterPlugin),
+ erasePlugin(textCasePlugin),
+ erasePlugin(urlEncoderPlugin),
+ erasePlugin(loremIpsumPlugin),
+ erasePlugin(colorConverterPlugin),
+ erasePlugin(regexTesterPlugin),
+ erasePlugin(numberBasePlugin),
+ erasePlugin(timestampConverterPlugin),
+ erasePlugin(variablesConverterPlugin),
+ erasePlugin(markdownToHtmlPlugin),
+ erasePlugin(textDiffPlugin),
+ erasePlugin(htmlEntityPlugin),
+ erasePlugin(imageEditorPlugin),
+ erasePlugin(imageOptimizerPlugin),
+ erasePlugin(pdfMergerPlugin),
+ erasePlugin(pdfSplitterPlugin),
+ erasePlugin(imageRedactorPlugin),
+ erasePlugin(exifScrubberPlugin),
+ erasePlugin(jwtDecoderPlugin),
+ erasePlugin(csvToolsPlugin),
+ erasePlugin(qrCodeGeneratorPlugin),
+ erasePlugin(passwordGeneratorPlugin),
+ erasePlugin(fileChecksumVerifierPlugin),
+];
+
+export function getAllPlugins(): UnknownPlimiPlugin[] {
+ return pluginRegistry;
+}
+
+export function getPluginById(id: string): UnknownPlimiPlugin | undefined {
+ return pluginRegistry.find((plugin) => plugin.manifest.id === id);
+}
+
+export function getPluginsByCategory(category: string): UnknownPlimiPlugin[] {
+ return pluginRegistry.filter(
+ (plugin) => plugin.manifest.category === category
+ );
+}
diff --git a/src/core/plugins/plugin-runner.ts b/src/core/plugins/plugin-runner.ts
new file mode 100644
index 0000000..ecd3ffe
--- /dev/null
+++ b/src/core/plugins/plugin-runner.ts
@@ -0,0 +1,84 @@
+import type { PlimiPlugin, ToolContext } from "./plugin-types";
+import type { ToolInput } from "../io/input-types";
+import type { ToolResult } from "../io/output-types";
+import type { ToolWorkerRequest, ToolWorkerResponse } from "./worker-protocol";
+import { normalizeToolOptions, validateToolInput } from "./plugin-validation";
+
+export async function runPlugin(
+ plugin: PlimiPlugin,
+ input: ToolInput,
+ options: TOptions,
+ context: ToolContext
+): Promise {
+ validateToolInput(plugin.manifest.input, input);
+ const normalizedOptions = plugin.optionsSchema
+ ? normalizeToolOptions(plugin.optionsSchema, options) as TOptions
+ : options;
+
+ if (plugin.capabilities?.worker && plugin.worker) {
+ return runPluginInWorker(plugin, input, normalizedOptions, context);
+ }
+
+ if (!plugin.run) {
+ throw new Error(`Plugin ${plugin.manifest.id} has no runner`);
+ }
+
+ return plugin.run(input, normalizedOptions, context);
+}
+
+function runPluginInWorker(
+ plugin: PlimiPlugin,
+ input: ToolInput,
+ options: TOptions,
+ context: ToolContext
+): Promise {
+ return new Promise((resolve, reject) => {
+ if (!plugin.worker) {
+ return reject(new Error("Worker is not defined for this plugin"));
+ }
+
+ const worker = plugin.worker();
+ const jobId = Math.random().toString(36).substring(7);
+
+ // Handle cancellation
+ const onAbort = () => {
+ worker.terminate();
+ reject(new DOMException("Operation cancelled by user.", "AbortError"));
+ };
+ context.signal.addEventListener("abort", onAbort);
+
+ worker.onmessage = (e: MessageEvent) => {
+ const { type, id } = e.data;
+
+ if (id !== jobId) return;
+
+ if (type === "progress") {
+ context.reportProgress(e.data.progress);
+ } else if (type === "success") {
+ context.signal.removeEventListener("abort", onAbort);
+ worker.terminate();
+ resolve(e.data.result);
+ } else if (type === "error") {
+ context.signal.removeEventListener("abort", onAbort);
+ worker.terminate();
+ reject(new Error(e.data.error));
+ }
+ };
+
+ worker.onerror = (e) => {
+ context.signal.removeEventListener("abort", onAbort);
+ worker.terminate();
+ reject(new Error("Worker error: " + e.message));
+ };
+
+ // Serialize input payload (can't send DOM elements or complex functions)
+ const request: ToolWorkerRequest = {
+ type: "run",
+ id: jobId,
+ input,
+ options,
+ };
+
+ worker.postMessage(request);
+ });
+}
diff --git a/src/core/plugins/plugin-types.ts b/src/core/plugins/plugin-types.ts
new file mode 100644
index 0000000..148003b
--- /dev/null
+++ b/src/core/plugins/plugin-types.ts
@@ -0,0 +1,155 @@
+import React from "react";
+import type { ToolInputDefinition, ToolInput } from "../io/input-types";
+import type { ToolOutputDefinition, ToolResult } from "../io/output-types";
+
+export type ToolCategory =
+ | "image"
+ | "pdf"
+ | "text"
+ | "developer"
+ | "crypto"
+ | "conversion"
+ | "privacy";
+
+export interface ToolManifest {
+ id: string;
+ name: string;
+ description: string;
+ category: ToolCategory;
+ version: string;
+
+ input: ToolInputDefinition;
+ output: ToolOutputDefinition;
+
+ tags?: string[];
+ icon?: string;
+ example?: string;
+ offlineReady: boolean;
+}
+
+export type ToolOptionField =
+ | {
+ type: "text";
+ key: string;
+ label: string;
+ defaultValue?: string;
+ placeholder?: string;
+ }
+ | {
+ type: "number";
+ key: string;
+ label: string;
+ defaultValue: number;
+ min?: number;
+ max?: number;
+ step?: number;
+ }
+ | {
+ type: "boolean";
+ key: string;
+ label: string;
+ defaultValue: boolean;
+ }
+ | {
+ type: "select";
+ key: string;
+ label: string;
+ defaultValue: string;
+ options: Array<{
+ label: string;
+ value: string;
+ }>;
+ }
+ | {
+ type: "slider";
+ key: string;
+ label: string;
+ defaultValue: number;
+ min: number;
+ max: number;
+ step?: number;
+ };
+
+export interface ToolOptionsSchema {
+ fields: ToolOptionField[];
+}
+
+export type ToolOptionValue = string | number | boolean;
+export type ToolOptionsValue = Record;
+
+export interface ToolCapabilities {
+ batch?: boolean;
+ worker?: boolean;
+ wasm?: boolean;
+ preview?: boolean;
+ streaming?: boolean;
+ cancelable?: boolean;
+ customUi?: boolean;
+}
+
+export interface ToolPermissions {
+ network: "none" | "optional" | "required";
+ clipboard?: "none" | "read" | "write" | "read-write";
+ fileSystem?: "none" | "read" | "write" | "read-write";
+}
+
+export interface ToolProgress {
+ percentage?: number;
+ message?: string;
+}
+
+export interface ToolExample {
+ label?: string;
+ description?: string;
+ input: ToolInput;
+ options?: Partial;
+}
+
+export interface ToolContext {
+ signal: AbortSignal;
+ reportProgress: (progress: ToolProgress) => void;
+ logger: {
+ info: (message: string) => void;
+ warn: (message: string) => void;
+ error: (message: string) => void;
+ };
+}
+
+export type ToolRunner = (
+ input: ToolInput,
+ options: TOptions,
+ context: ToolContext
+) => Promise;
+
+export interface ToolUiProps {
+ plugin: PlimiPlugin;
+ dark?: boolean;
+ examples?: ToolExample[];
+}
+
+export interface PlimiPlugin {
+ manifest: ToolManifest;
+ optionsSchema?: ToolOptionsSchema;
+ examples?: ToolExample[];
+ capabilities?: ToolCapabilities;
+ permissions?: ToolPermissions;
+
+ run?: ToolRunner;
+
+ worker?: () => Worker;
+
+ customUi?: React.LazyExoticComponent<
+ React.ComponentType>
+ >;
+}
+
+export type UnknownPlimiPlugin = Omit<
+ PlimiPlugin,
+ "run" | "customUi"
+> & {
+ run?: ToolRunner;
+ examples?: ToolExample[];
+ customUi?: React.LazyExoticComponent<
+ React.ComponentType>
+ >;
+};
diff --git a/src/core/plugins/plugin-validation.ts b/src/core/plugins/plugin-validation.ts
new file mode 100644
index 0000000..1c93ecc
--- /dev/null
+++ b/src/core/plugins/plugin-validation.ts
@@ -0,0 +1,151 @@
+import type {
+ ToolOptionField,
+ ToolOptionValue,
+ ToolOptionsSchema,
+ ToolOptionsValue,
+} from "./plugin-types";
+import type {
+ ToolInput,
+ ToolInputDefinition,
+ ToolInputFieldDefinition,
+} from "../io/input-types";
+import { getInputValue } from "../io/input-types";
+
+function describeInput(field: ToolInputFieldDefinition): string {
+ return field.label ?? field.key ?? "input";
+}
+
+function matchesAccept(file: File, accept: string[]): boolean {
+ if (accept.length === 0) return true;
+
+ return accept.some((rule) => {
+ if (rule.endsWith("/*")) {
+ return file.type.startsWith(rule.slice(0, -1));
+ }
+
+ if (rule.startsWith(".")) {
+ return file.name.toLowerCase().endsWith(rule.toLowerCase());
+ }
+
+ return file.type === rule;
+ });
+}
+
+function validateText(field: Extract, text?: string) {
+ if (field.maxLength !== undefined && (text?.length ?? 0) > field.maxLength) {
+ throw new Error(`${describeInput(field)} must be ${field.maxLength} characters or fewer.`);
+ }
+}
+
+function validateFiles(
+ field: Extract,
+ files?: File[]
+) {
+ if (!files || files.length === 0) return;
+
+ if ("multiple" in field && field.multiple === false && files.length > 1) {
+ throw new Error(`${describeInput(field)} accepts only one file.`);
+ }
+
+ if ("maxFiles" in field && field.maxFiles !== undefined && files.length > field.maxFiles) {
+ throw new Error(`${describeInput(field)} accepts at most ${field.maxFiles} files.`);
+ }
+
+ if (field.accept) {
+ const invalidFile = files.find((file) => !matchesAccept(file, field.accept ?? []));
+ if (invalidFile) {
+ throw new Error(`${invalidFile.name} is not an accepted file type.`);
+ }
+ }
+
+ if (field.maxSizeMb !== undefined) {
+ const maxBytes = field.maxSizeMb * 1024 * 1024;
+ const oversized = files.find((file) => file.size > maxBytes);
+ if (oversized) {
+ throw new Error(`${oversized.name} is larger than ${field.maxSizeMb} MB.`);
+ }
+ }
+}
+
+function validateField(field: ToolInputFieldDefinition, input: ToolInput) {
+ const value = getInputValue(input, field.key ?? "input");
+
+ if (field.type === "text") {
+ validateText(field, value.text);
+ return;
+ }
+
+ if (field.type === "files") {
+ validateFiles(field, value.files);
+ return;
+ }
+
+ validateFiles(field, value.files);
+}
+
+export function validateToolInput(definition: ToolInputDefinition, input: ToolInput) {
+ if (definition.type === "none") return;
+
+ const fields = definition.type === "group" ? definition.fields : [definition];
+ fields.forEach((field) => validateField(field, input));
+}
+
+function normalizeOptionField(
+ field: ToolOptionField,
+ rawValue: unknown
+): ToolOptionValue {
+ const value = rawValue ?? field.defaultValue;
+
+ if (field.type === "text") {
+ return typeof value === "string" ? value : String(value ?? "");
+ }
+
+ if (field.type === "boolean") {
+ if (typeof value !== "boolean") {
+ throw new Error(`${field.label} must be true or false.`);
+ }
+ return value;
+ }
+
+ if (field.type === "select") {
+ if (typeof value !== "string") {
+ throw new Error(`${field.label} must be one of the available options.`);
+ }
+
+ if (!field.options.some((option) => option.value === value)) {
+ throw new Error(`${field.label} has an invalid option.`);
+ }
+
+ return value;
+ }
+
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ throw new Error(`${field.label} must be a number.`);
+ }
+
+ if (field.min !== undefined && value < field.min) {
+ throw new Error(`${field.label} must be at least ${field.min}.`);
+ }
+
+ if (field.max !== undefined && value > field.max) {
+ throw new Error(`${field.label} must be at most ${field.max}.`);
+ }
+
+ return value;
+}
+
+export function normalizeToolOptions(
+ schema: ToolOptionsSchema | undefined,
+ options: unknown
+): ToolOptionsValue {
+ if (!schema) return {};
+
+ const raw = options && typeof options === "object"
+ ? (options as Record)
+ : {};
+
+ return schema.fields.reduce((acc, field) => {
+ acc[field.key] = normalizeOptionField(field, raw[field.key]);
+ return acc;
+ }, {});
+}
diff --git a/src/core/plugins/worker-protocol.ts b/src/core/plugins/worker-protocol.ts
new file mode 100644
index 0000000..54d50f0
--- /dev/null
+++ b/src/core/plugins/worker-protocol.ts
@@ -0,0 +1,27 @@
+import type { ToolInput } from "../io/input-types";
+import type { ToolProgress } from "./plugin-types";
+import type { ToolResult } from "../io/output-types";
+
+export interface ToolWorkerRequest {
+ type: "run";
+ id: string;
+ input: ToolInput;
+ options: TOptions;
+}
+
+export type ToolWorkerResponse =
+ | {
+ type: "progress";
+ id: string;
+ progress: ToolProgress;
+ }
+ | {
+ type: "success";
+ id: string;
+ result: ToolResult;
+ }
+ | {
+ type: "error";
+ id: string;
+ error: string;
+ };
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..09e388c
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,127 @@
+@import "tailwindcss";
+
+@theme {
+ --font-sans: 'Geist', system-ui, -apple-system, sans-serif;
+ --font-mono: 'Geist Mono', ui-monospace, monospace;
+}
+
+:root {
+ /* Light mode (warm paper) */
+ --p-bg-light: #F4EEE3;
+ --p-surface-light: #FBF6ED;
+ --p-surface-2-light: #FFFBF2;
+ --p-border-light: #E4DACA;
+ --p-chip-light: #EDE3D0;
+ --p-text-light: #1A1714;
+ --p-muted-light: #786E60;
+ --p-shadow-soft-light: rgba(60, 40, 20, 0.22);
+ --p-shadow-inset-light: rgba(255, 255, 255, 0.7);
+
+ /* Dark mode */
+ --p-bg-dark: #0F1117;
+ --p-surface-dark: #161922;
+ --p-surface-2-dark: #1B1F2A;
+ --p-border-dark: #262A36;
+ --p-chip-dark: #1E222D;
+ --p-text-dark: #F2F2EF;
+ --p-muted-dark: #8B919C;
+ --p-shadow-soft-dark: rgba(0, 0, 0, 0.6);
+ --p-shadow-inset-dark: rgba(255, 255, 255, 0.03);
+
+ /* Accent — default lime */
+ --p-accent-light: #5B8C3E;
+ --p-accent-dark: #A8FF60;
+ --p-accent-ink-light: #FFFBF2;
+ --p-accent-ink-dark: #0F1117;
+}
+
+.plimi-theme-light {
+ --p-bg: var(--p-bg-light);
+ --p-surface: var(--p-surface-light);
+ --p-surface-2: var(--p-surface-2-light);
+ --p-border: var(--p-border-light);
+ --p-chip: var(--p-chip-light);
+ --p-text: var(--p-text-light);
+ --p-muted: var(--p-muted-light);
+ --p-shadow-soft: var(--p-shadow-soft-light);
+ --p-shadow-inset: var(--p-shadow-inset-light);
+ --p-accent: var(--p-accent-light);
+ --p-accent-ink: var(--p-accent-ink-light);
+ --p-paper-noise: url("data:image/svg+xml;utf8,");
+}
+
+.plimi-theme-dark {
+ --p-bg: var(--p-bg-dark);
+ --p-surface: var(--p-surface-dark);
+ --p-surface-2: var(--p-surface-2-dark);
+ --p-border: var(--p-border-dark);
+ --p-chip: var(--p-chip-dark);
+ --p-text: var(--p-text-dark);
+ --p-muted: var(--p-muted-dark);
+ --p-shadow-soft: var(--p-shadow-soft-dark);
+ --p-shadow-inset: var(--p-shadow-inset-dark);
+ --p-accent: var(--p-accent-dark);
+ --p-accent-ink: var(--p-accent-ink-dark);
+ --p-paper-noise: url("data:image/svg+xml;utf8,");
+}
+
+/* Animations */
+@keyframes plimi-fade {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+@keyframes plimi-slide {
+ from { opacity: 0; transform: translateY(12px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.animate-plimi-fade {
+ animation: plimi-fade 0.18s ease-out;
+}
+
+.animate-plimi-slide {
+ animation: plimi-slide 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ background: var(--p-bg);
+ color: var(--p-text);
+ font-family: 'Geist', system-ui, -apple-system, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+#root {
+ min-height: 100svh;
+ display: flex;
+ flex-direction: column;
+}
+
+button:focus-visible {
+ outline: 2px solid var(--p-accent);
+ outline-offset: 2px;
+}
+
+input::placeholder {
+ color: var(--p-muted);
+ opacity: 0.7;
+}
+
+/* Scrollbar styling to match the sleek design */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--p-border);
+ border-radius: 4px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--p-muted);
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..fa41359
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,13 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./index.css";
+import { App } from "./app/App";
+import { ThemeProvider } from "./app/ThemeProvider";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+);
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
new file mode 100644
index 0000000..7059f23
--- /dev/null
+++ b/src/pages/HomePage.tsx
@@ -0,0 +1,5 @@
+import { Navigate } from "react-router-dom";
+
+export function HomePage() {
+ return ;
+}
diff --git a/src/pages/HowItWorksPage.tsx b/src/pages/HowItWorksPage.tsx
new file mode 100644
index 0000000..f7226b9
--- /dev/null
+++ b/src/pages/HowItWorksPage.tsx
@@ -0,0 +1,202 @@
+import { Link } from "react-router-dom";
+
+const workflow = [
+ {
+ label: "Input",
+ title: "Choose a tool",
+ copy: "Paste text, pick a file, or adjust options in a generated tool panel.",
+ icon: (
+
+ ),
+ },
+ {
+ label: "Run",
+ title: "Process locally",
+ copy: "The plugin runs in your browser using JavaScript, workers, Canvas, or PDF libraries.",
+ icon: (
+ <>
+
+
+ >
+ ),
+ },
+ {
+ label: "Output",
+ title: "Copy or download",
+ copy: "Results appear immediately in the page. Files stay on the device unless you export them.",
+ icon: (
+ <>
+
+
+
+ >
+ ),
+ },
+];
+
+const principles = [
+ {
+ title: "No upload step",
+ copy: "Plimi does not send your inputs to an application server for conversion.",
+ },
+ {
+ title: "Plugin boundaries",
+ copy: "Each utility declares its own inputs, options, permissions, and result type.",
+ },
+ {
+ title: "Responsive execution",
+ copy: "Heavier work can move off the main interface thread so the page stays usable.",
+ },
+ {
+ title: "Explicit results",
+ copy: "Tools return structured outputs: copied text, rendered previews, or downloadable files.",
+ },
+];
+
+const layers = [
+ { name: "Tool manifest", detail: "Inputs, options, permissions" },
+ { name: "Generated UI", detail: "Forms, sliders, selects, validation" },
+ { name: "Runner", detail: "Browser APIs, workers, local libraries" },
+ { name: "Result panel", detail: "Preview, copy, download" },
+];
+
+function IconFrame({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ );
+}
+
+export function HowItWorksPage() {
+ return (
+
+
+
+
+ browser-first architecture
+
+
+ Your files stay where they started.
+
+
+ Plimi is a collection of small local utilities. The interface loads the tool, runs the
+ work in the browser, then hands the result back to you without a server round trip.
+
+
+
+
+
+ {workflow.map((step, index) => (
+
+
+ {step.icon}
+
+ {String(index + 1).padStart(2, "0")}
+
+
+
+
+ {step.label}
+
+
+ {step.title}
+
+
{step.copy}
+
+
+ ))}
+
+
+
+
+
+ {principles.map((item) => (
+
+
+ {item.title}
+
+ {item.copy}
+
+ ))}
+
+
+
+
+
+ plugin model
+
+
+ One shell, many tools.
+
+
+ Plimi keeps the application shell simple. Each tool contributes a compact manifest and
+ a runner, so new utilities can share the same dependable interface.
+
+
+
+
+ {layers.map((layer, index) => (
+
+
+ {index + 1}
+
+
+
+ {layer.name}
+
+
+ {layer.detail}
+
+
+
+ ))}
+
+
+
+
+
+
+ Ready to run something?
+
+
+ Pick a utility and the same local workflow applies.
+
+
+
+ Browse tools
+
+
+
+ );
+}
diff --git a/src/pages/ToolDetailPage.tsx b/src/pages/ToolDetailPage.tsx
new file mode 100644
index 0000000..ea36384
--- /dev/null
+++ b/src/pages/ToolDetailPage.tsx
@@ -0,0 +1,47 @@
+import { useParams, useNavigate } from "react-router-dom";
+import { pluginRegistry } from "../core/plugins/plugin-registry";
+import { ToolShell } from "../components/tool/ToolShell";
+import { useTheme } from "../app/useTheme";
+
+export function ToolDetailPage() {
+ const { toolId } = useParams<{ toolId: string }>();
+ const navigate = useNavigate();
+ const { dark } = useTheme();
+
+ const plugin = pluginRegistry.find((p) => p.manifest.id === toolId);
+
+ if (!plugin) {
+ return (
+
+
Tool not found
+
+
+ );
+ }
+
+ const handleClose = () => {
+ navigate("/tools");
+ };
+
+ return (
+
+
e.stopPropagation()}
+ className="w-full md:w-[min(900px,100%)] h-[92%] md:h-auto md:max-h-full bg-[var(--p-surface)] rounded-t-[20px] md:rounded-[24px] border-[1.5px] border-[var(--p-border)] flex flex-col animate-plimi-slide self-end md:self-center overflow-hidden"
+ style={{
+ boxShadow: '0 40px 80px -30px var(--p-shadow-soft)',
+ }}
+ >
+
+
+
+ );
+}
diff --git a/src/pages/ToolsPage.tsx b/src/pages/ToolsPage.tsx
new file mode 100644
index 0000000..5e2943e
--- /dev/null
+++ b/src/pages/ToolsPage.tsx
@@ -0,0 +1,153 @@
+import { useState, useMemo } from "react";
+import { useNavigate } from "react-router-dom";
+import { pluginRegistry } from "../core/plugins/plugin-registry";
+import type { UnknownPlimiPlugin } from "../core/plugins/plugin-types";
+import { useTheme } from "../app/useTheme";
+import { PlimiSearch, CategoryChips, ToolTile, SectionHeader } from "../components/directory/DirectoryComponents";
+
+const PLIMI_CATEGORIES = [
+ { id: "all", label: "All tools" },
+ { id: "developer", label: "Developer" },
+ { id: "image", label: "Image" },
+ { id: "text", label: "Text" },
+ { id: "pdf", label: "PDF" },
+ { id: "crypto", label: "Crypto" },
+ { id: "privacy", label: "Privacy" },
+];
+
+export function ToolsPage() {
+ const navigate = useNavigate();
+ const { dark } = useTheme();
+
+ const [q, setQ] = useState("");
+ const [cat, setCat] = useState("all");
+ const [focusedIndex, setFocusedIndex] = useState(0);
+
+ const filtered = useMemo(() => {
+ return pluginRegistry.filter(
+ (p) =>
+ (cat === "all" || p.manifest.category === cat) &&
+ (p.manifest.name.toLowerCase().includes(q.toLowerCase()) ||
+ p.manifest.description.toLowerCase().includes(q.toLowerCase()))
+ );
+ }, [q, cat]);
+
+ const safeFocusedIndex =
+ filtered.length === 0 ? -1 : Math.min(focusedIndex, filtered.length - 1);
+
+ const counts = useMemo(() => {
+ const out: Record = { all: 0 };
+ PLIMI_CATEGORIES.forEach((c) => { out[c.id] = 0; });
+
+ pluginRegistry.forEach((p) => {
+ const matchSearch = p.manifest.name.toLowerCase().includes(q.toLowerCase()) ||
+ p.manifest.description.toLowerCase().includes(q.toLowerCase());
+ if (!matchSearch) return;
+
+ out.all += 1;
+ if (out[p.manifest.category] !== undefined) {
+ out[p.manifest.category] += 1;
+ } else {
+ out[p.manifest.category] = 1;
+ }
+ });
+ return out;
+ }, [q]);
+
+ const grouped = useMemo(() => {
+ if (cat !== 'all' || q) return null;
+ const map: Record = {};
+ filtered.forEach((t) => {
+ (map[t.manifest.category] = map[t.manifest.category] || []).push(t);
+ });
+ return PLIMI_CATEGORIES.filter((c) => c.id !== 'all' && map[c.id]?.length).map((c) => ({ cat: c, tools: map[c.id] }));
+ }, [filtered, cat, q]);
+
+ const handleOpenTool = (plugin: UnknownPlimiPlugin) => {
+ navigate(`/tools/${plugin.manifest.id}`);
+ };
+
+ return (
+
+
+
+
+ your digital pencil case
+
+
+ Small tools.
+ Big trust.
+
+
+ {pluginRegistry.length} utilities for files, text and code — running entirely in your browser. No upload. No account. No server.
+
+
+
+
{
+ setQ(nextQuery);
+ setFocusedIndex(0);
+ }}
+ count={filtered.length}
+ total={pluginRegistry.length}
+ onArrow={(dir) => {
+ if (filtered.length === 0) return;
+ setFocusedIndex((prev) => {
+ const next = prev + dir;
+ if (next < 0) return filtered.length - 1;
+ if (next >= filtered.length) return 0;
+ return next;
+ });
+ }}
+ onEnter={() => {
+ if (safeFocusedIndex >= 0 && safeFocusedIndex < filtered.length) {
+ handleOpenTool(filtered[safeFocusedIndex]);
+ }
+ }}
+ />
+
+
+
{
+ setCat(nextCategory);
+ setFocusedIndex(0);
+ }}
+ counts={counts}
+ categories={PLIMI_CATEGORIES}
+ />
+
+ {grouped ? (
+
+ {grouped.map(({ cat: c, tools }) => (
+
+
+
+ {tools.map((t) => {
+ const flatIdx = filtered.indexOf(t);
+ return (
+
+ );
+ })}
+
+
+ ))}
+
+ ) : filtered.length === 0 ? (
+
+ No tools found matching "{q}".
+
+ ) : (
+
+ {filtered.map((t) => {
+ const flatIdx = filtered.indexOf(t);
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/tools/base64/index.ts b/src/tools/base64/index.ts
new file mode 100644
index 0000000..92c6873
--- /dev/null
+++ b/src/tools/base64/index.ts
@@ -0,0 +1,43 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runBase64 } from "./run";
+
+export interface Base64Options {
+ mode: "encode" | "decode";
+}
+
+export const base64Plugin: PlimiPlugin = {
+ manifest: {
+ id: "base64",
+ name: "Base64 Encoder / Decoder",
+ description: "Encode or decode Base64 text locally.",
+ category: "developer",
+ version: "1.0.0",
+ tags: ["base64", "encode", "decode"],
+ input: { type: "text" },
+ output: { type: "text" },
+ example: "Hello, Plimi!",
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "select",
+ key: "mode",
+ label: "Mode",
+ defaultValue: "encode",
+ options: [
+ { label: "Encode", value: "encode" },
+ { label: "Decode", value: "decode" },
+ ],
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false, // Runs synchronously since it's very fast for normal text
+ },
+
+ run: runBase64,
+};
diff --git a/src/tools/base64/run.test.ts b/src/tools/base64/run.test.ts
new file mode 100644
index 0000000..374780c
--- /dev/null
+++ b/src/tools/base64/run.test.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect, vi } from "vitest";
+import { runBase64 } from "./run";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+describe("Base64 Plugin", () => {
+ const mockContext: ToolContext = {
+ signal: new AbortController().signal,
+ reportProgress: vi.fn(),
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+ };
+
+ it("should encode text correctly", async () => {
+ const result = await runBase64(
+ { text: "Hello World" },
+ { mode: "encode" },
+ mockContext
+ );
+ expect(result).toEqual({ type: "text", value: "SGVsbG8gV29ybGQ=" });
+ });
+
+ it("should decode text correctly", async () => {
+ const result = await runBase64(
+ { text: "SGVsbG8gV29ybGQ=" },
+ { mode: "decode" },
+ mockContext
+ );
+ expect(result).toEqual({ type: "text", value: "Hello World" });
+ });
+
+ it("should return empty string for empty input", async () => {
+ const result = await runBase64(
+ { text: "" },
+ { mode: "encode" },
+ mockContext
+ );
+ expect(result).toEqual({ type: "text", value: "" });
+ });
+
+ it("should throw error on invalid base64 decode", async () => {
+ await expect(
+ runBase64({ text: "NotBase64!" }, { mode: "decode" }, mockContext)
+ ).rejects.toThrow("Invalid Base64 input string or encoding error.");
+ });
+});
diff --git a/src/tools/base64/run.ts b/src/tools/base64/run.ts
new file mode 100644
index 0000000..df8f47b
--- /dev/null
+++ b/src/tools/base64/run.ts
@@ -0,0 +1,37 @@
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+import type { Base64Options } from "./index";
+
+export async function runBase64(
+ input: ToolInput,
+ options: Base64Options,
+ context: ToolContext
+): Promise {
+ const text = input.text ?? "";
+
+ if (!text) {
+ return {
+ type: "text",
+ value: "",
+ };
+ }
+
+ context.reportProgress({ percentage: 50, message: "Processing..." });
+
+ try {
+ const resultValue =
+ options.mode === "encode"
+ ? btoa(unescape(encodeURIComponent(text)))
+ : decodeURIComponent(escape(atob(text)));
+
+ context.reportProgress({ percentage: 100, message: "Done" });
+
+ return {
+ type: "text",
+ value: resultValue,
+ };
+ } catch (cause) {
+ throw new Error("Invalid Base64 input string or encoding error.", { cause });
+ }
+}
diff --git a/src/tools/color-converter/index.ts b/src/tools/color-converter/index.ts
new file mode 100644
index 0000000..e5d06b6
--- /dev/null
+++ b/src/tools/color-converter/index.ts
@@ -0,0 +1,44 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runColorConverter } from "./run";
+
+export interface ColorConverterOptions {
+ format: "hex" | "rgb" | "hsl";
+}
+
+export const colorConverterPlugin: PlimiPlugin = {
+ manifest: {
+ id: "dev-color",
+ name: "Color Converter",
+ description: "Convert colors between HEX, RGB, and HSL formats.",
+ category: "developer",
+ version: "1.0.0",
+ tags: ["color", "hex", "rgb", "hsl", "converter"],
+ input: { type: "text", placeholder: "#ff0000 or rgb(255,0,0) or hsl(0,100%,50%)", multiline: false },
+ output: { type: "json" },
+ example: "#3b82f6",
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "select",
+ key: "format",
+ label: "Output Format",
+ defaultValue: "hex",
+ options: [
+ { label: "HEX", value: "hex" },
+ { label: "RGB", value: "rgb" },
+ { label: "HSL", value: "hsl" },
+ ],
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runColorConverter,
+};
diff --git a/src/tools/color-converter/run.test.ts b/src/tools/color-converter/run.test.ts
new file mode 100644
index 0000000..50d5666
--- /dev/null
+++ b/src/tools/color-converter/run.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect, vi } from "vitest";
+import { runColorConverter } from "./run";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+describe("Color Converter", () => {
+ const mockContext: ToolContext = {
+ signal: new AbortController().signal,
+ reportProgress: vi.fn(),
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ };
+
+ it("should parse a 6-digit hex color and return all formats", async () => {
+ const result = await runColorConverter(
+ { text: "#ff0000" },
+ { format: "hex" },
+ mockContext
+ );
+ expect(result.type).toBe("json");
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.hex).toBe("#ff0000");
+ expect(value.rgb).toBe("rgb(255, 0, 0)");
+ expect(value.channels).toEqual({ r: 255, g: 0, b: 0 });
+ expect(value.primary).toBe("#ff0000");
+ });
+
+ it("should parse an RGB color string", async () => {
+ const result = await runColorConverter(
+ { text: "rgb(0, 128, 255)" },
+ { format: "hex" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.channels).toEqual({ r: 0, g: 128, b: 255 });
+ expect(value.hex).toBe("#0080ff");
+ });
+
+ it("should parse an HSL color string", async () => {
+ const result = await runColorConverter(
+ { text: "hsl(0, 100%, 50%)" },
+ { format: "rgb" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ const channels = value.channels as Record;
+ expect(channels.r).toBe(255);
+ expect(channels.g).toBe(0);
+ expect(channels.b).toBe(0);
+ expect(value.primary).toBe("rgb(255, 0, 0)");
+ });
+
+ it("should parse a 3-digit shorthand hex", async () => {
+ const result = await runColorConverter(
+ { text: "#f00" },
+ { format: "hex" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.hex).toBe("#ff0000");
+ });
+
+ it("should parse hex without hash prefix", async () => {
+ const result = await runColorConverter(
+ { text: "00ff00" },
+ { format: "rgb" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.channels).toEqual({ r: 0, g: 255, b: 0 });
+ expect(value.primary).toBe("rgb(0, 255, 0)");
+ });
+
+ it("should return primary in hsl format when requested", async () => {
+ const result = await runColorConverter(
+ { text: "#ffffff" },
+ { format: "hsl" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.primary).toContain("hsl(");
+ });
+
+ it("should throw on unrecognized color format", async () => {
+ await expect(
+ runColorConverter({ text: "not-a-color" }, { format: "hex" }, mockContext)
+ ).rejects.toThrow("Unrecognized color format");
+ });
+
+ it("should throw on empty input", async () => {
+ await expect(
+ runColorConverter({ text: "" }, { format: "hex" }, mockContext)
+ ).rejects.toThrow("No color value provided");
+ });
+});
diff --git a/src/tools/color-converter/run.ts b/src/tools/color-converter/run.ts
new file mode 100644
index 0000000..1d128f6
--- /dev/null
+++ b/src/tools/color-converter/run.ts
@@ -0,0 +1,151 @@
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+import type { ColorConverterOptions } from "./index";
+
+interface RGB { r: number; g: number; b: number; }
+
+function parseHex(input: string): RGB | null {
+ const match = input.match(/^#?([0-9a-f]{3,8})$/i);
+ if (!match) return null;
+ let hex = match[1];
+ if (hex.length === 3) {
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
+ }
+ if (hex.length === 4) {
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
+ }
+ if (hex.length >= 6) {
+ return {
+ r: parseInt(hex.substring(0, 2), 16),
+ g: parseInt(hex.substring(2, 4), 16),
+ b: parseInt(hex.substring(4, 6), 16),
+ };
+ }
+ return null;
+}
+
+function parseRgb(input: string): RGB | null {
+ const match = input.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/);
+ if (!match) return null;
+ return {
+ r: Math.min(255, parseInt(match[1], 10)),
+ g: Math.min(255, parseInt(match[2], 10)),
+ b: Math.min(255, parseInt(match[3], 10)),
+ };
+}
+
+function parseHsl(input: string): RGB | null {
+ const match = input.match(/hsla?\(\s*(\d{1,3})\s*,\s*(\d{1,3})%?\s*,\s*(\d{1,3})%?/);
+ if (!match) return null;
+ const h = parseInt(match[1], 10) / 360;
+ const s = parseInt(match[2], 10) / 100;
+ const l = parseInt(match[3], 10) / 100;
+
+ if (s === 0) {
+ const v = Math.round(l * 255);
+ return { r: v, g: v, b: v };
+ }
+
+ const hue2rgb = (p: number, q: number, t: number): number => {
+ if (t < 0) t += 1;
+ if (t > 1) t -= 1;
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
+ if (t < 1 / 2) return q;
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
+ return p;
+ };
+
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+ const p = 2 * l - q;
+
+ return {
+ r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
+ g: Math.round(hue2rgb(p, q, h) * 255),
+ b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
+ };
+}
+
+function parseColor(input: string): RGB | null {
+ const trimmed = input.trim().toLowerCase();
+ return parseHex(trimmed) || parseRgb(trimmed) || parseHsl(trimmed);
+}
+
+function rgbToHex(rgb: RGB): string {
+ const toHex = (n: number) => n.toString(16).padStart(2, "0");
+ return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
+}
+
+function rgbToHsl(rgb: RGB): { h: number; s: number; l: number } {
+ const r = rgb.r / 255;
+ const g = rgb.g / 255;
+ const b = rgb.b / 255;
+ const max = Math.max(r, g, b);
+ const min = Math.min(r, g, b);
+ const l = (max + min) / 2;
+
+ if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) };
+
+ const d = max - min;
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+
+ const h =
+ max === r
+ ? ((g - b) / d + (g < b ? 6 : 0)) / 6
+ : max === g
+ ? ((b - r) / d + 2) / 6
+ : ((r - g) / d + 4) / 6;
+
+ return {
+ h: Math.round(h * 360),
+ s: Math.round(s * 100),
+ l: Math.round(l * 100),
+ };
+}
+
+export async function runColorConverter(
+ input: ToolInput,
+ options: ColorConverterOptions,
+ context?: ToolContext
+): Promise {
+ void context;
+
+ const text = input.text || "";
+ if (!text.trim()) {
+ throw new Error("No color value provided.");
+ }
+
+ const rgb = parseColor(text);
+ if (!rgb) {
+ throw new Error("Unrecognized color format. Use HEX (#ff0000), RGB (rgb(255,0,0)), or HSL (hsl(0,100%,50%)).");
+ }
+
+ const hex = rgbToHex(rgb);
+ const hsl = rgbToHsl(rgb);
+
+ let primary: string;
+ switch (options.format) {
+ case "hex":
+ primary = hex;
+ break;
+ case "rgb":
+ primary = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
+ break;
+ case "hsl":
+ primary = `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`;
+ break;
+ default:
+ primary = hex;
+ }
+
+ return {
+ type: "json",
+ value: {
+ hex,
+ rgb: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
+ hsl: `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`,
+ primary,
+ channels: { r: rgb.r, g: rgb.g, b: rgb.b },
+ },
+ };
+}
diff --git a/src/tools/csv-tools/index.ts b/src/tools/csv-tools/index.ts
new file mode 100644
index 0000000..1c41b5c
--- /dev/null
+++ b/src/tools/csv-tools/index.ts
@@ -0,0 +1,75 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runCsvTools } from "./run";
+
+export interface CsvToolsOptions {
+ mode: "csv-to-json" | "json-to-csv";
+ delimiter: "," | ";" | "\t";
+ hasHeaderRow: boolean;
+ prettyJson: boolean;
+}
+
+export const csvToolsPlugin: PlimiPlugin = {
+ manifest: {
+ id: "csv-tools",
+ name: "CSV <-> JSON Converter",
+ description: "Convert CSV text to JSON objects, or convert JSON array of objects/arrays to CSV.",
+ category: "developer",
+ version: "1.0.0",
+ tags: ["csv", "json", "converter", "parser", "format"],
+ input: {
+ type: "text",
+ label: "Input Content",
+ placeholder: "Paste CSV text or JSON array here...",
+ multiline: true,
+ rows: 10,
+ },
+ output: { type: "text" },
+ example: "name,age,city\nJohn Doe,30,New York\nJane Smith,25,Los Angeles",
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "select",
+ key: "mode",
+ label: "Conversion Mode",
+ defaultValue: "csv-to-json",
+ options: [
+ { label: "CSV to JSON", value: "csv-to-json" },
+ { label: "JSON to CSV", value: "json-to-csv" },
+ ],
+ },
+ {
+ type: "select",
+ key: "delimiter",
+ label: "Delimiter / Separator",
+ defaultValue: ",",
+ options: [
+ { label: "Comma (,)", value: "," },
+ { label: "Semicolon (;)", value: ";" },
+ { label: "Tab (\\t)", value: "\t" },
+ ],
+ },
+ {
+ type: "boolean",
+ key: "hasHeaderRow",
+ label: "First row is header (CSV -> JSON)",
+ defaultValue: true,
+ },
+ {
+ type: "boolean",
+ key: "prettyJson",
+ label: "Pretty Print JSON",
+ defaultValue: true,
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runCsvTools,
+};
diff --git a/src/tools/csv-tools/run.test.ts b/src/tools/csv-tools/run.test.ts
new file mode 100644
index 0000000..6439e19
--- /dev/null
+++ b/src/tools/csv-tools/run.test.ts
@@ -0,0 +1,138 @@
+import { describe, it, expect, vi } from "vitest";
+import { runCsvTools } from "./run";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+describe("CSV <-> JSON Converter Plugin", () => {
+ const mockContext: ToolContext = {
+ signal: new AbortController().signal,
+ reportProgress: vi.fn(),
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+ };
+
+ it("should convert simple CSV to JSON with headers", async () => {
+ const csv = "name,age,city\nJohn Doe,30,New York\nJane Smith,25,Los Angeles";
+ const result = await runCsvTools(
+ { text: csv },
+ { mode: "csv-to-json", delimiter: ",", hasHeaderRow: true, prettyJson: false },
+ mockContext
+ );
+ expect(result.type).toBe("text");
+ if (result.type === "text") {
+ const parsed = JSON.parse(result.value);
+ expect(parsed).toEqual([
+ { name: "John Doe", age: "30", city: "New York" },
+ { name: "Jane Smith", age: "25", city: "Los Angeles" },
+ ]);
+ }
+ });
+
+ it("should handle commas and newlines inside quoted fields", async () => {
+ const csv = 'name,notes\nJohn,"Likes apples, oranges, and bananas"\nJane,"Likes reading\nand cycling"';
+ const result = await runCsvTools(
+ { text: csv },
+ { mode: "csv-to-json", delimiter: ",", hasHeaderRow: true, prettyJson: false },
+ mockContext
+ );
+ expect(result.type).toBe("text");
+ if (result.type === "text") {
+ const parsed = JSON.parse(result.value);
+ expect(parsed[0].notes).toBe("Likes apples, oranges, and bananas");
+ expect(parsed[1].notes).toBe("Likes reading\nand cycling");
+ }
+ });
+
+ it("should handle escaped quotes inside quotes", async () => {
+ const csv = 'name,description\nJohn,"Known as ""The Apple King"""';
+ const result = await runCsvTools(
+ { text: csv },
+ { mode: "csv-to-json", delimiter: ",", hasHeaderRow: true, prettyJson: false },
+ mockContext
+ );
+ expect(result.type).toBe("text");
+ if (result.type === "text") {
+ const parsed = JSON.parse(result.value);
+ expect(parsed[0].description).toBe('Known as "The Apple King"');
+ }
+ });
+
+ it("should support custom delimiters like Semicolon", async () => {
+ const csv = "name;age\nJohn Doe;30";
+ const result = await runCsvTools(
+ { text: csv },
+ { mode: "csv-to-json", delimiter: ";", hasHeaderRow: true, prettyJson: false },
+ mockContext
+ );
+ expect(result.type).toBe("text");
+ if (result.type === "text") {
+ const parsed = JSON.parse(result.value);
+ expect(parsed[0]).toEqual({ name: "John Doe", age: "30" });
+ }
+ });
+
+ it("should parse without header rows", async () => {
+ const csv = "John,30\nJane,25";
+ const result = await runCsvTools(
+ { text: csv },
+ { mode: "csv-to-json", delimiter: ",", hasHeaderRow: false, prettyJson: false },
+ mockContext
+ );
+ expect(result.type).toBe("text");
+ if (result.type === "text") {
+ const parsed = JSON.parse(result.value);
+ expect(parsed).toEqual([
+ ["John", "30"],
+ ["Jane", "25"],
+ ]);
+ }
+ });
+
+ it("should convert JSON array of objects to CSV", async () => {
+ const json = JSON.stringify([
+ { name: "John Doe", age: 30, city: "New York" },
+ { name: "Jane Smith", age: 25, city: "Los Angeles" },
+ ]);
+ const result = await runCsvTools(
+ { text: json },
+ { mode: "json-to-csv", delimiter: ",", hasHeaderRow: true, prettyJson: false },
+ mockContext
+ );
+ expect(result.type).toBe("text");
+ if (result.type === "text") {
+ expect(result.value).toBe(
+ "name,age,city\nJohn Doe,30,New York\nJane Smith,25,Los Angeles"
+ );
+ }
+ });
+
+ it("should convert JSON array of arrays to CSV", async () => {
+ const json = JSON.stringify([
+ ["John", 30],
+ ["Jane", 25],
+ ]);
+ const result = await runCsvTools(
+ { text: json },
+ { mode: "json-to-csv", delimiter: ";", hasHeaderRow: true, prettyJson: false },
+ mockContext
+ );
+ expect(result.type).toBe("text");
+ if (result.type === "text") {
+ expect(result.value).toBe("John;30\nJane;25");
+ }
+ });
+
+ it("should throw error for invalid JSON in JSON-to-CSV mode", async () => {
+ await expect(
+ runCsvTools({ text: "not-json" }, { mode: "json-to-csv", delimiter: ",", hasHeaderRow: true, prettyJson: false }, mockContext)
+ ).rejects.toThrow("Invalid input: Not a valid JSON string");
+ });
+
+ it("should throw error for non-array JSON", async () => {
+ await expect(
+ runCsvTools({ text: '{"a": 1}' }, { mode: "json-to-csv", delimiter: ",", hasHeaderRow: true, prettyJson: false }, mockContext)
+ ).rejects.toThrow("JSON content must be an array");
+ });
+});
diff --git a/src/tools/csv-tools/run.ts b/src/tools/csv-tools/run.ts
new file mode 100644
index 0000000..db4f959
--- /dev/null
+++ b/src/tools/csv-tools/run.ts
@@ -0,0 +1,199 @@
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+import type { CsvToolsOptions } from "./index";
+
+export function parseCsv(text: string, delimiter: string): string[][] {
+ const rows: string[][] = [];
+ let currentRow: string[] = [];
+ let currentField = "";
+ let inQuotes = false;
+
+ for (let i = 0; i < text.length; i++) {
+ const char = text[i];
+ const nextChar = text[i + 1];
+
+ if (inQuotes) {
+ if (char === '"') {
+ if (nextChar === '"') {
+ // Escaped quote
+ currentField += '"';
+ i++; // Skip next quote
+ } else {
+ // Closing quote
+ inQuotes = false;
+ }
+ } else {
+ currentField += char;
+ }
+ } else {
+ if (char === '"') {
+ inQuotes = true;
+ } else if (char === delimiter) {
+ currentRow.push(currentField);
+ currentField = "";
+ } else if (char === "\n" || char === "\r") {
+ currentRow.push(currentField);
+ currentField = "";
+ rows.push(currentRow);
+ currentRow = [];
+
+ if (char === "\r" && nextChar === "\n") {
+ i++; // Skip \n in \r\n
+ }
+ } else {
+ currentField += char;
+ }
+ }
+ }
+
+ // Push final field/row if anything remains
+ if (currentField !== "" || currentRow.length > 0) {
+ currentRow.push(currentField);
+ rows.push(currentRow);
+ }
+
+ // Filter out completely empty trailing row (e.g. from file ending with a newline)
+ if (rows.length > 0) {
+ const lastRow = rows[rows.length - 1];
+ if (lastRow.length === 1 && lastRow[0] === "") {
+ rows.pop();
+ }
+ }
+
+ return rows;
+}
+
+function escapeCsvField(val: unknown, delimiter: string): string {
+ if (val === null || val === undefined) return "";
+ const str = String(val);
+ const needsQuotes =
+ str.includes(delimiter) ||
+ str.includes('"') ||
+ str.includes("\n") ||
+ str.includes("\r");
+
+ if (needsQuotes) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+}
+
+export async function runCsvTools(
+ input: ToolInput,
+ options: CsvToolsOptions,
+ context: ToolContext
+): Promise {
+ const text = (input.text ?? "").trim();
+ if (!text) {
+ throw new Error("Please enter input text to convert.");
+ }
+
+ const { mode, delimiter, hasHeaderRow, prettyJson } = options;
+
+ if (mode === "csv-to-json") {
+ context.reportProgress({ percentage: 20, message: "Parsing CSV..." });
+ const parsedRows = parseCsv(text, delimiter);
+
+ if (parsedRows.length === 0) {
+ return {
+ type: "text",
+ value: "[]",
+ language: "json",
+ };
+ }
+
+ context.reportProgress({ percentage: 60, message: "Structuring JSON..." });
+
+ if (hasHeaderRow) {
+ const headers = parsedRows[0].map(h => h.trim());
+ const objects: Record[] = [];
+
+ for (let i = 1; i < parsedRows.length; i++) {
+ const row = parsedRows[i];
+ const obj: Record = {};
+
+ for (let j = 0; j < headers.length; j++) {
+ obj[headers[j]] = row[j] ?? "";
+ }
+ objects.push(obj);
+ }
+
+ context.reportProgress({ percentage: 100, message: "Done" });
+ return {
+ type: "text",
+ value: JSON.stringify(objects, null, prettyJson ? 2 : undefined),
+ language: "json",
+ };
+ } else {
+ context.reportProgress({ percentage: 100, message: "Done" });
+ return {
+ type: "text",
+ value: JSON.stringify(parsedRows, null, prettyJson ? 2 : undefined),
+ language: "json",
+ };
+ }
+ } else {
+ // json-to-csv mode
+ context.reportProgress({ percentage: 25, message: "Parsing JSON..." });
+ let data: unknown;
+ try {
+ data = JSON.parse(text);
+ } catch (err) {
+ throw new Error("Invalid input: Not a valid JSON string. JSON to CSV mode requires a valid JSON array.");
+ }
+
+ if (!Array.isArray(data)) {
+ throw new Error("Invalid input: JSON content must be an array of objects or an array of arrays.");
+ }
+
+ if (data.length === 0) {
+ return {
+ type: "text",
+ value: "",
+ language: "plain",
+ };
+ }
+
+ context.reportProgress({ percentage: 65, message: "Serializing to CSV..." });
+
+ const firstItem = data[0];
+
+ if (Array.isArray(firstItem)) {
+ // Array of arrays
+ const csvLines = (data as unknown[][]).map(row =>
+ row.map(cell => escapeCsvField(cell, delimiter)).join(delimiter)
+ );
+ context.reportProgress({ percentage: 100, message: "Done" });
+ return {
+ type: "text",
+ value: csvLines.join("\n"),
+ language: "plain",
+ };
+ } else if (typeof firstItem === "object" && firstItem !== null) {
+ // Array of objects
+ // Collect unique keys across all objects to ensure all properties are included
+ const keysSet = new Set();
+ data.forEach(item => {
+ if (typeof item === "object" && item !== null) {
+ Object.keys(item).forEach(k => keysSet.add(k));
+ }
+ });
+ const keys = Array.from(keysSet);
+
+ const headerLine = keys.map(k => escapeCsvField(k, delimiter)).join(delimiter);
+ const csvLines = (data as Record[]).map(item =>
+ keys.map(key => escapeCsvField(item[key], delimiter)).join(delimiter)
+ );
+
+ context.reportProgress({ percentage: 100, message: "Done" });
+ return {
+ type: "text",
+ value: [headerLine, ...csvLines].join("\n"),
+ language: "plain",
+ };
+ } else {
+ throw new Error("Invalid input: Array elements must be either objects or arrays.");
+ }
+ }
+}
diff --git a/src/tools/exif-scrubber/index.ts b/src/tools/exif-scrubber/index.ts
new file mode 100644
index 0000000..d9a0f28
--- /dev/null
+++ b/src/tools/exif-scrubber/index.ts
@@ -0,0 +1,27 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runExifScrubber } from "./run";
+
+export const exifScrubberPlugin: PlimiPlugin = {
+ manifest: {
+ id: "exif-scrubber",
+ name: "EXIF Scrubber",
+ description: "Instantly strip all GPS coordinates, camera logs, and timestamps from your photos locally.",
+ category: "privacy",
+ version: "1.0.0",
+ tags: ["privacy", "metadata", "exif", "gps", "strip", "clear", "photo"],
+ input: {
+ type: "files",
+ accept: ["image/jpeg", "image/png", "image/webp"],
+ multiple: true,
+ },
+ output: { type: "files" },
+ offlineReady: true,
+ },
+
+ capabilities: {
+ cancelable: true,
+ worker: false,
+ },
+
+ run: runExifScrubber,
+};
diff --git a/src/tools/exif-scrubber/run.test.ts b/src/tools/exif-scrubber/run.test.ts
new file mode 100644
index 0000000..8149efe
--- /dev/null
+++ b/src/tools/exif-scrubber/run.test.ts
@@ -0,0 +1,25 @@
+import { describe, it, expect, vi } from "vitest";
+import { runExifScrubber } from "./run";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+describe("EXIF Scrubber Plugin", () => {
+ const mockContext: ToolContext = {
+ signal: new AbortController().signal,
+ reportProgress: vi.fn(),
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+ };
+
+ it("should throw error if no files provided", async () => {
+ await expect(
+ runExifScrubber(
+ { files: [] },
+ null,
+ mockContext
+ )
+ ).rejects.toThrow("No files uploaded for scrubbing.");
+ });
+});
diff --git a/src/tools/exif-scrubber/run.ts b/src/tools/exif-scrubber/run.ts
new file mode 100644
index 0000000..c7c08db
--- /dev/null
+++ b/src/tools/exif-scrubber/run.ts
@@ -0,0 +1,103 @@
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+function loadImage(url: string): Promise {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.onerror = () => reject(new Error("Failed to load image."));
+ img.src = url;
+ });
+}
+
+function canvasToBlob(
+ canvas: HTMLCanvasElement,
+ mimeType: string,
+ quality = 0.95
+): Promise {
+ return new Promise((resolve, reject) => {
+ canvas.toBlob(
+ (blob) => {
+ if (blob) {
+ resolve(blob);
+ } else {
+ reject(new Error("Failed to export image from canvas."));
+ }
+ },
+ mimeType,
+ quality
+ );
+ });
+}
+
+export async function runExifScrubber(
+ input: ToolInput,
+ _options: unknown,
+ context: ToolContext
+): Promise {
+ const files = input.files;
+ if (!files || !Array.isArray(files) || files.length === 0) {
+ throw new Error("No files uploaded for scrubbing.");
+ }
+
+ const outFiles = [];
+
+ for (let i = 0; i < files.length; i++) {
+ if (context.signal?.aborted) {
+ throw new DOMException("Operation cancelled", "AbortError");
+ }
+
+ const file = files[i];
+ context.reportProgress({
+ percentage: (i / files.length) * 100,
+ message: `Scrubbing metadata for ${file.name} (${i + 1}/${files.length})...`,
+ });
+
+ const url = URL.createObjectURL(file);
+ try {
+ const img = await loadImage(url);
+
+ const canvas = document.createElement("canvas");
+ canvas.width = img.naturalWidth;
+ canvas.height = img.naturalHeight;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ throw new Error("Could not acquire 2D canvas context.");
+ }
+
+ // Drawing to canvas discards EXIF headers
+ ctx.drawImage(img, 0, 0);
+
+ const mimeType = file.type || "image/jpeg";
+ const blob = await canvasToBlob(canvas, mimeType);
+
+ // Create new clean file name
+ const dotIdx = file.name.lastIndexOf(".");
+ const name = dotIdx !== -1
+ ? `${file.name.substring(0, dotIdx)}_scrubbed${file.name.substring(dotIdx)}`
+ : `${file.name}_scrubbed.jpg`;
+
+ outFiles.push({
+ name: name,
+ mimeType: mimeType,
+ blob: blob,
+ sizeAfter: blob.size,
+ sizeBefore: file.size,
+ });
+ } catch (err) {
+ console.error(`Error scrubbing file ${file.name}:`, err);
+ // Fallback or rethrow depending on strict requirements; we rethrow for safety
+ throw err;
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+ }
+
+ context.reportProgress({ percentage: 100, message: "Metadata scrubbed successfully!" });
+ return {
+ type: "files",
+ files: outFiles,
+ };
+}
diff --git a/src/tools/file-checksum-verifier/index.ts b/src/tools/file-checksum-verifier/index.ts
new file mode 100644
index 0000000..9dfe8db
--- /dev/null
+++ b/src/tools/file-checksum-verifier/index.ts
@@ -0,0 +1,60 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runFileChecksumVerifier } from "./run";
+
+export interface FileChecksumVerifierOptions {
+ algorithm: "SHA-256" | "SHA-512";
+}
+
+export const fileChecksumVerifierPlugin: PlimiPlugin = {
+ manifest: {
+ id: "file-checksum-verifier",
+ name: "File Checksum Verifier",
+ description: "Calculate cryptographic SHA-256 or SHA-512 checksums of local files entirely offline in the browser.",
+ category: "crypto",
+ version: "1.0.0",
+ tags: ["checksum", "verifier", "hash", "sha256", "sha512", "file"],
+ input: {
+ type: "group",
+ fields: [
+ {
+ type: "files",
+ key: "files",
+ label: "Select File(s)",
+ multiple: true,
+ description: "Select one or more files to calculate checksums for.",
+ },
+ {
+ type: "text",
+ key: "expectedChecksum",
+ label: "Expected Checksum (Optional)",
+ placeholder: "Paste expected hash to compare against...",
+ description: "Case-insensitive. Will be compared against computed hashes.",
+ },
+ ],
+ },
+ output: { type: "table" },
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "select",
+ key: "algorithm",
+ label: "Hash Algorithm",
+ defaultValue: "SHA-256",
+ options: [
+ { label: "SHA-256", value: "SHA-256" },
+ { label: "SHA-512", value: "SHA-512" },
+ ],
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runFileChecksumVerifier,
+};
diff --git a/src/tools/file-checksum-verifier/run.test.ts b/src/tools/file-checksum-verifier/run.test.ts
new file mode 100644
index 0000000..823ca6a
--- /dev/null
+++ b/src/tools/file-checksum-verifier/run.test.ts
@@ -0,0 +1,87 @@
+import { describe, it, expect, vi } from "vitest";
+import { runFileChecksumVerifier } from "./run";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+describe("File Checksum Verifier Plugin", () => {
+ const mockContext: ToolContext = {
+ signal: new AbortController().signal,
+ reportProgress: vi.fn(),
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+ };
+
+ const textFile = new File(
+ [new TextEncoder().encode("hello world")],
+ "test.txt",
+ { type: "text/plain" }
+ );
+
+ const helloWorldSha256 = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
+
+ it("should calculate SHA-256 checksum and verify successfully", async () => {
+ const result = await runFileChecksumVerifier(
+ {
+ values: {
+ files: { files: [textFile] },
+ expectedChecksum: { text: helloWorldSha256 },
+ },
+ },
+ { algorithm: "SHA-256" },
+ mockContext
+ );
+
+ expect(result.type).toBe("table");
+ if (result.type === "table") {
+ expect(result.columns).toEqual(["File Name", "Size", "Computed Hash", "Expected Hash", "Status"]);
+ expect(result.rows).toHaveLength(1);
+ expect(result.rows[0][0]).toBe("test.txt");
+ expect(result.rows[0][1]).toBe("11 B");
+ expect(result.rows[0][2]).toBe(helloWorldSha256);
+ expect(result.rows[0][3]).toBe(helloWorldSha256);
+ expect(result.rows[0][4]).toBe("✅ Match");
+ }
+ });
+
+ it("should detect checksum mismatch", async () => {
+ const result = await runFileChecksumVerifier(
+ {
+ values: {
+ files: { files: [textFile] },
+ expectedChecksum: { text: "wrongchecksum" },
+ },
+ },
+ { algorithm: "SHA-256" },
+ mockContext
+ );
+
+ if (result.type === "table") {
+ expect(result.rows[0][4]).toBe("❌ Mismatch");
+ }
+ });
+
+ it("should run with no expected checksum and mark status as N/A", async () => {
+ const result = await runFileChecksumVerifier(
+ {
+ values: {
+ files: { files: [textFile] },
+ },
+ },
+ { algorithm: "SHA-256" },
+ mockContext
+ );
+
+ if (result.type === "table") {
+ expect(result.rows[0][3]).toBe("(none)");
+ expect(result.rows[0][4]).toBe("N/A");
+ }
+ });
+
+ it("should throw error if no files are supplied", async () => {
+ await expect(
+ runFileChecksumVerifier({}, { algorithm: "SHA-256" }, mockContext)
+ ).rejects.toThrow("Please select at least one file to verify.");
+ });
+});
diff --git a/src/tools/file-checksum-verifier/run.ts b/src/tools/file-checksum-verifier/run.ts
new file mode 100644
index 0000000..077c79f
--- /dev/null
+++ b/src/tools/file-checksum-verifier/run.ts
@@ -0,0 +1,82 @@
+import { getFilesInput, getTextInput } from "../../core/io/input-types";
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+import type { FileChecksumVerifierOptions } from "./index";
+
+function formatSize(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+}
+
+export async function runFileChecksumVerifier(
+ input: ToolInput,
+ options: FileChecksumVerifierOptions,
+ context: ToolContext
+): Promise {
+ const files = getFilesInput(input, "files");
+ const expectedChecksum = getTextInput(input, "expectedChecksum").trim().toLowerCase();
+
+ if (!files || files.length === 0) {
+ throw new Error("Please select at least one file to verify.");
+ }
+
+ const { algorithm } = options;
+ const rows: Array<[string, string, string, string, string]> = [];
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ const fileNum = i + 1;
+ const totalFiles = files.length;
+
+ context.reportProgress({
+ percentage: Math.round(((i) / totalFiles) * 100),
+ message: `Reading ${file.name} (${fileNum}/${totalFiles})...`,
+ });
+
+ let arrayBuffer: ArrayBuffer;
+ try {
+ arrayBuffer = await file.arrayBuffer();
+ } catch (err: any) {
+ throw new Error(`Failed to read file "${file.name}": ${err.message ?? err}`);
+ }
+
+ context.reportProgress({
+ percentage: Math.round(((i + 0.5) / totalFiles) * 100),
+ message: `Computing ${algorithm} for ${file.name}...`,
+ });
+
+ let hashHex = "";
+ try {
+ const hashBuffer = await crypto.subtle.digest(algorithm, arrayBuffer);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
+ } catch (err: any) {
+ throw new Error(`Failed to compute hash for file "${file.name}": ${err.message ?? err}`);
+ }
+
+ let matchStatus = "N/A";
+ if (expectedChecksum) {
+ matchStatus = hashHex === expectedChecksum ? "✅ Match" : "❌ Mismatch";
+ }
+
+ rows.push([
+ file.name,
+ formatSize(file.size),
+ hashHex,
+ expectedChecksum || "(none)",
+ matchStatus,
+ ]);
+ }
+
+ context.reportProgress({ percentage: 100, message: "Done" });
+
+ return {
+ type: "table",
+ columns: ["File Name", "Size", "Computed Hash", "Expected Hash", "Status"],
+ rows,
+ };
+}
diff --git a/src/tools/hash-generator/index.ts b/src/tools/hash-generator/index.ts
new file mode 100644
index 0000000..b17d60d
--- /dev/null
+++ b/src/tools/hash-generator/index.ts
@@ -0,0 +1,56 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runHashGenerator } from "./run";
+
+export interface HashOptions {
+ algorithm: "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512";
+ output: "hex" | "base64";
+}
+
+export const hashGeneratorPlugin: PlimiPlugin = {
+ manifest: {
+ id: "crypto-hash",
+ name: "Hash Generator",
+ description: "Generate cryptographic hashes securely in your browser.",
+ category: "crypto",
+ version: "1.0.0",
+ tags: ["hash", "sha256", "crypto", "digest"],
+ input: { type: "text" },
+ output: { type: "text" },
+ example: "Hello, Plimi!",
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "select",
+ key: "algorithm",
+ label: "Algorithm",
+ defaultValue: "SHA-256",
+ options: [
+ { label: "SHA-1", value: "SHA-1" },
+ { label: "SHA-256", value: "SHA-256" },
+ { label: "SHA-384", value: "SHA-384" },
+ { label: "SHA-512", value: "SHA-512" },
+ ],
+ },
+ {
+ type: "select",
+ key: "output",
+ label: "Output Format",
+ defaultValue: "hex",
+ options: [
+ { label: "Hex", value: "hex" },
+ { label: "Base64", value: "base64" },
+ ],
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runHashGenerator,
+};
diff --git a/src/tools/hash-generator/run.ts b/src/tools/hash-generator/run.ts
new file mode 100644
index 0000000..2ad3cc1
--- /dev/null
+++ b/src/tools/hash-generator/run.ts
@@ -0,0 +1,33 @@
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { HashOptions } from "./index";
+
+export async function runHashGenerator(
+ input: ToolInput,
+ options: HashOptions
+): Promise {
+ const text = input.text || "";
+ if (!text) {
+ throw new Error("No text provided.");
+ }
+
+ const encoder = new TextEncoder();
+ const data = encoder.encode(text);
+
+ const hashBuffer = await crypto.subtle.digest(options.algorithm, data);
+
+ let outputStr = "";
+ if (options.output === "hex") {
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ outputStr = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
+ } else if (options.output === "base64") {
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ const binStr = String.fromCharCode(...hashArray);
+ outputStr = btoa(binStr);
+ }
+
+ return {
+ type: "text",
+ value: outputStr,
+ };
+}
diff --git a/src/tools/html-entity/index.ts b/src/tools/html-entity/index.ts
new file mode 100644
index 0000000..273ba12
--- /dev/null
+++ b/src/tools/html-entity/index.ts
@@ -0,0 +1,43 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runHtmlEntity } from "./run";
+
+export interface HtmlEntityOptions {
+ mode: "encode" | "decode";
+}
+
+export const htmlEntityPlugin: PlimiPlugin = {
+ manifest: {
+ id: "dev-htmlentity",
+ name: "HTML Entity Encoder",
+ description: "Encode special characters to HTML entities or decode them back.",
+ category: "developer",
+ version: "1.0.0",
+ tags: ["html", "entity", "encode", "decode", "escape"],
+ input: { type: "text", placeholder: "Enter text with < > & \" characters..." },
+ output: { type: "text" },
+ example: 'Hello & "World"
',
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "select",
+ key: "mode",
+ label: "Mode",
+ defaultValue: "encode",
+ options: [
+ { label: "Encode", value: "encode" },
+ { label: "Decode", value: "decode" },
+ ],
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runHtmlEntity,
+};
diff --git a/src/tools/html-entity/run.test.ts b/src/tools/html-entity/run.test.ts
new file mode 100644
index 0000000..7efbc32
--- /dev/null
+++ b/src/tools/html-entity/run.test.ts
@@ -0,0 +1,109 @@
+import { describe, it, expect, vi } from "vitest";
+import { runHtmlEntity } from "./run";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+describe("HTML Entity Encoder/Decoder", () => {
+ const mockContext: ToolContext = {
+ signal: new AbortController().signal,
+ reportProgress: vi.fn(),
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ };
+
+ it("should encode basic HTML special characters", async () => {
+ const result = await runHtmlEntity(
+ { text: 'Hello & World
' },
+ { mode: "encode" },
+ mockContext
+ );
+ expect(result.type).toBe("text");
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toBe("<div class="test">Hello & World</div>");
+ });
+
+ it("should decode basic HTML entities", async () => {
+ const result = await runHtmlEntity(
+ { text: "<div>Hello & World</div>" },
+ { mode: "decode" },
+ mockContext
+ );
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toBe("Hello & World
");
+ });
+
+ it("should encode special symbols", async () => {
+ const result = await runHtmlEntity(
+ { text: "Price: 10€ © 2024" },
+ { mode: "encode" },
+ mockContext
+ );
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toContain("€");
+ expect(value).toContain("©");
+ });
+
+ it("should decode special symbol entities", async () => {
+ const result = await runHtmlEntity(
+ { text: "© 2024 — All rights reserved" },
+ { mode: "decode" },
+ mockContext
+ );
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toContain("©");
+ expect(value).toContain("—");
+ });
+
+ it("should decode numeric character references (decimal)", async () => {
+ const result = await runHtmlEntity(
+ { text: "ABC" },
+ { mode: "decode" },
+ mockContext
+ );
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toBe("ABC");
+ });
+
+ it("should decode numeric character references (hex)", async () => {
+ const result = await runHtmlEntity(
+ { text: "ABC" },
+ { mode: "decode" },
+ mockContext
+ );
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toBe("ABC");
+ });
+
+ it("should encode single quotes", async () => {
+ const result = await runHtmlEntity(
+ { text: "it's a test" },
+ { mode: "encode" },
+ mockContext
+ );
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toContain("'");
+ });
+
+ it("should return empty string for empty input", async () => {
+ const result = await runHtmlEntity(
+ { text: "" },
+ { mode: "encode" },
+ mockContext
+ );
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toBe("");
+ });
+
+ it("should be reversible: encode then decode gives original", async () => {
+ const original = 'Hello & "World"
';
+ const encoded = await runHtmlEntity(
+ { text: original },
+ { mode: "encode" },
+ mockContext
+ );
+ const decoded = await runHtmlEntity(
+ { text: (encoded as { type: "text"; value: string }).value },
+ { mode: "decode" },
+ mockContext
+ );
+ expect((decoded as { type: "text"; value: string }).value).toBe(original);
+ });
+});
diff --git a/src/tools/html-entity/run.ts b/src/tools/html-entity/run.ts
new file mode 100644
index 0000000..6cf0104
--- /dev/null
+++ b/src/tools/html-entity/run.ts
@@ -0,0 +1,82 @@
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+import type { HtmlEntityOptions } from "./index";
+
+const ENTITY_MAP: Record = {
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+ "©": "©",
+ "®": "®",
+ "™": "™",
+ "—": "—",
+ "–": "–",
+ "«": "«",
+ "»": "»",
+ "°": "°",
+ "±": "±",
+ "×": "×",
+ "÷": "÷",
+ "€": "€",
+ "£": "£",
+ "¥": "¥",
+ "¢": "¢",
+ "§": "§",
+ "¶": "¶",
+ "•": "•",
+ "…": "…",
+};
+
+const REVERSE_ENTITY_MAP: Record = {};
+for (const [char, entity] of Object.entries(ENTITY_MAP)) {
+ REVERSE_ENTITY_MAP[entity] = char;
+}
+
+function encodeHtmlEntities(text: string): string {
+ return text.replace(/[&<>"']|[©®™—–«»°±×÷€£¥¢§¶•…]/g, (char) => {
+ return ENTITY_MAP[char] || char;
+ });
+}
+
+function decodeHtmlEntities(text: string): string {
+ let result = text;
+
+ for (const [entity, char] of Object.entries(REVERSE_ENTITY_MAP)) {
+ result = result.replaceAll(entity, char);
+ }
+
+ result = result.replace(/(\d+);/g, (_, code) => {
+ return String.fromCharCode(parseInt(code, 10));
+ });
+
+ result = result.replace(/([0-9a-fA-F]+);/g, (_, code) => {
+ return String.fromCharCode(parseInt(code, 16));
+ });
+
+ return result;
+}
+
+export async function runHtmlEntity(
+ input: ToolInput,
+ options: HtmlEntityOptions,
+ context: ToolContext
+): Promise {
+ const text = input.text ?? "";
+ if (!text) {
+ return { type: "text", value: "" };
+ }
+
+ context.reportProgress({ percentage: 50, message: "Processing..." });
+
+ const result = options.mode === "encode" ? encodeHtmlEntities(text) : decodeHtmlEntities(text);
+
+ context.reportProgress({ percentage: 100, message: "Done" });
+
+ return {
+ type: "text",
+ value: result,
+ };
+}
diff --git a/src/tools/image-editor/ImageEditorUi.tsx b/src/tools/image-editor/ImageEditorUi.tsx
new file mode 100644
index 0000000..55653e8
--- /dev/null
+++ b/src/tools/image-editor/ImageEditorUi.tsx
@@ -0,0 +1,780 @@
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type PointerEvent as ReactPointerEvent,
+ type WheelEvent as ReactWheelEvent,
+} from "react";
+import {
+ Canvas,
+ Circle,
+ FabricImage,
+ Line,
+ PencilBrush,
+ Rect,
+ Textbox,
+ Triangle,
+ type FabricObject,
+} from "fabric";
+import type { ToolUiProps } from "../../core/plugins/plugin-types";
+import { Button } from "../../components/ui/Button";
+import { Dropzone } from "../../components/ui/Dropzone";
+import { Select } from "../../components/ui/Select";
+import { Slider } from "../../components/ui/Slider";
+import { ToolResultPanel } from "../../components/tool/ToolResultPanel";
+import { useToolExecution } from "../../components/tool/useToolExecution";
+import type { ImageEditorOptions } from "./index";
+
+type ToolMode = "select" | "draw" | "pan";
+type ShapeKind = "rect" | "circle" | "triangle" | "line";
+
+interface EditorState {
+ selectedType: string;
+ fill: string;
+ stroke: string;
+ fontSize: number;
+ brushWidth: number;
+ mode: ToolMode;
+ zoom: number;
+}
+
+interface LayerRow {
+ object: FabricObject;
+ id: string;
+ label: string;
+}
+
+const DEFAULT_WIDTH = 960;
+const DEFAULT_HEIGHT = 640;
+const HISTORY_LIMIT = 40;
+
+const FONT_OPTIONS = [
+ { label: "Inter", value: "Inter, system-ui, sans-serif" },
+ { label: "Georgia", value: "Georgia, serif" },
+ { label: "Mono", value: "ui-monospace, SFMono-Regular, Menlo, monospace" },
+ { label: "Arial", value: "Arial, sans-serif" },
+];
+
+const FORMAT_OPTIONS = [
+ { label: "PNG", value: "image/png" },
+ { label: "JPEG", value: "image/jpeg" },
+ { label: "WebP", value: "image/webp" },
+];
+
+function objectType(object: FabricObject | undefined): string {
+ if (!object) return "None";
+ if (object.type === "textbox" || object.type === "i-text") return "Text";
+ if (object.type === "path") return "Drawing";
+ return object.type ? object.type.charAt(0).toUpperCase() + object.type.slice(1) : "Object";
+}
+
+function useElementSize() {
+ const ref = useRef(null);
+ const [size, setSize] = useState({ width: 0, height: 0 });
+
+ useEffect(() => {
+ if (!ref.current) return;
+
+ const observer = new ResizeObserver(([entry]) => {
+ setSize({
+ width: entry.contentRect.width,
+ height: entry.contentRect.height,
+ });
+ });
+
+ observer.observe(ref.current);
+ return () => observer.disconnect();
+ }, []);
+
+ return { ref, size };
+}
+
+export default function ImageEditorUi({
+ plugin,
+}: ToolUiProps) {
+ const canvasElRef = useRef(null);
+ const canvasRef = useRef\n\n");
+ html = `
${html}
`;
+
+ html = html.replace(/\s*<(h[1-6]|ul|ol|blockquote|hr)/g, "<$1");
+ html = html.replace(/<\/(h[1-6]|ul|ol|blockquote|hr)>\s*<\/p>/g, "$1>");
+
+ html = html.replace(/
\s*<\/p>/g, "");
+
+ return html.trim();
+}
+
+export async function runMarkdownToHtml(
+ input: ToolInput,
+ _options: MarkdownToHtmlOptions,
+ context: ToolContext
+): Promise {
+ const text = input.text || "";
+ if (!text.trim()) {
+ throw new Error("No Markdown text provided.");
+ }
+
+ context.reportProgress({ percentage: 50, message: "Converting..." });
+
+ const html = convertMarkdown(text);
+
+ context.reportProgress({ percentage: 100, message: "Done" });
+
+ return {
+ type: "text",
+ value: html,
+ language: "html",
+ };
+}
diff --git a/src/tools/number-base/index.ts b/src/tools/number-base/index.ts
new file mode 100644
index 0000000..6e3e597
--- /dev/null
+++ b/src/tools/number-base/index.ts
@@ -0,0 +1,58 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runNumberBase } from "./run";
+
+export interface NumberBaseOptions {
+ fromBase: "2" | "8" | "10" | "16";
+ toBase: "2" | "8" | "10" | "16";
+}
+
+export const numberBasePlugin: PlimiPlugin = {
+ manifest: {
+ id: "dev-numbase",
+ name: "Number Base Converter",
+ description: "Convert numbers between binary, octal, decimal, and hexadecimal.",
+ category: "developer",
+ version: "1.0.0",
+ tags: ["number", "binary", "hex", "decimal", "octal", "base"],
+ input: { type: "text", placeholder: "Enter a number...", multiline: false },
+ output: { type: "json" },
+ example: "255",
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "select",
+ key: "fromBase",
+ label: "From Base",
+ defaultValue: "10",
+ options: [
+ { label: "Binary (2)", value: "2" },
+ { label: "Octal (8)", value: "8" },
+ { label: "Decimal (10)", value: "10" },
+ { label: "Hexadecimal (16)", value: "16" },
+ ],
+ },
+ {
+ type: "select",
+ key: "toBase",
+ label: "To Base",
+ defaultValue: "16",
+ options: [
+ { label: "Binary (2)", value: "2" },
+ { label: "Octal (8)", value: "8" },
+ { label: "Decimal (10)", value: "10" },
+ { label: "Hexadecimal (16)", value: "16" },
+ ],
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runNumberBase,
+};
diff --git a/src/tools/number-base/run.test.ts b/src/tools/number-base/run.test.ts
new file mode 100644
index 0000000..f2f009c
--- /dev/null
+++ b/src/tools/number-base/run.test.ts
@@ -0,0 +1,102 @@
+import { describe, it, expect, vi } from "vitest";
+import { runNumberBase } from "./run";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+describe("Number Base Converter", () => {
+ const mockContext: ToolContext = {
+ signal: new AbortController().signal,
+ reportProgress: vi.fn(),
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ };
+
+ it("should convert decimal to hexadecimal", async () => {
+ const result = await runNumberBase(
+ { text: "255" },
+ { fromBase: "10", toBase: "16" },
+ mockContext
+ );
+ expect(result.type).toBe("json");
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.decimal).toBe("255");
+ expect(value.hexadecimal).toBe("0xFF");
+ expect(value.primary).toBe("0xFF");
+ });
+
+ it("should convert hexadecimal to decimal", async () => {
+ const result = await runNumberBase(
+ { text: "0xFF" },
+ { fromBase: "16", toBase: "10" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.decimal).toBe("255");
+ expect(value.primary).toBe("255");
+ });
+
+ it("should convert decimal to binary", async () => {
+ const result = await runNumberBase(
+ { text: "10" },
+ { fromBase: "10", toBase: "2" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.binary).toBe("0b1010");
+ expect(value.primary).toBe("0b1010");
+ });
+
+ it("should convert binary to decimal", async () => {
+ const result = await runNumberBase(
+ { text: "1010" },
+ { fromBase: "2", toBase: "10" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.decimal).toBe("10");
+ });
+
+ it("should convert decimal to octal", async () => {
+ const result = await runNumberBase(
+ { text: "64" },
+ { fromBase: "10", toBase: "8" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.octal).toBe("0o100");
+ });
+
+ it("should return all base representations in every conversion", async () => {
+ const result = await runNumberBase(
+ { text: "42" },
+ { fromBase: "10", toBase: "16" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.binary).toBeDefined();
+ expect(value.octal).toBeDefined();
+ expect(value.decimal).toBeDefined();
+ expect(value.hexadecimal).toBeDefined();
+ });
+
+ it("should throw on empty input", async () => {
+ await expect(
+ runNumberBase({ text: "" }, { fromBase: "10", toBase: "16" }, mockContext)
+ ).rejects.toThrow("No number provided");
+ });
+
+ it("should throw on invalid number for given base", async () => {
+ await expect(
+ runNumberBase({ text: "xyz" }, { fromBase: "10", toBase: "16" }, mockContext)
+ ).rejects.toThrow();
+ });
+
+ it("should handle zero", async () => {
+ const result = await runNumberBase(
+ { text: "0" },
+ { fromBase: "10", toBase: "2" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.decimal).toBe("0");
+ expect(value.binary).toBe("0b0");
+ });
+});
diff --git a/src/tools/number-base/run.ts b/src/tools/number-base/run.ts
new file mode 100644
index 0000000..2ff4337
--- /dev/null
+++ b/src/tools/number-base/run.ts
@@ -0,0 +1,53 @@
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+import type { NumberBaseOptions } from "./index";
+
+function formatNumber(value: bigint, base: number): string {
+ if (base === 2) return "0b" + value.toString(2);
+ if (base === 8) return "0o" + value.toString(8);
+ if (base === 10) return value.toString(10);
+ if (base === 16) return "0x" + value.toString(16).toUpperCase();
+ return value.toString(base);
+}
+
+export async function runNumberBase(
+ input: ToolInput,
+ options: NumberBaseOptions,
+ context?: ToolContext
+): Promise {
+ void context;
+
+ const text = (input.text || "").trim();
+ if (!text) {
+ throw new Error("No number provided.");
+ }
+
+ const fromBase = parseInt(options.fromBase, 10);
+ const toBase = parseInt(options.toBase, 10);
+
+ const cleaned = text.replace(/^0[box]/i, "");
+
+ let value: bigint;
+ try {
+ value = BigInt(parseInt(cleaned, fromBase));
+ } catch {
+ throw new Error(`Invalid number "${text}" for base ${fromBase}.`);
+ }
+
+ if (isNaN(Number(value))) {
+ throw new Error(`Invalid number "${text}" for base ${fromBase}.`);
+ }
+
+ return {
+ type: "json",
+ value: {
+ input: text,
+ binary: formatNumber(value, 2),
+ octal: formatNumber(value, 8),
+ decimal: formatNumber(value, 10),
+ hexadecimal: formatNumber(value, 16),
+ primary: formatNumber(value, toBase),
+ },
+ };
+}
diff --git a/src/tools/password-generator/index.ts b/src/tools/password-generator/index.ts
new file mode 100644
index 0000000..21604dc
--- /dev/null
+++ b/src/tools/password-generator/index.ts
@@ -0,0 +1,97 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runPasswordGenerator } from "./run";
+
+export interface PasswordGeneratorOptions {
+ mode: "password" | "passphrase";
+ length: number;
+ uppercase: boolean;
+ lowercase: boolean;
+ numbers: boolean;
+ symbols: boolean;
+ excludeAmbiguous: boolean;
+ count: number;
+}
+
+export const passwordGeneratorPlugin: PlimiPlugin = {
+ manifest: {
+ id: "password-generator",
+ name: "Password & Passphrase Generator",
+ description: "Generate highly secure, random passwords or word-based passphrases locally in your browser.",
+ category: "crypto",
+ version: "1.0.0",
+ tags: ["password", "passphrase", "security", "generator", "random"],
+ input: { type: "none" },
+ output: { type: "table" },
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "select",
+ key: "mode",
+ label: "Mode",
+ defaultValue: "password",
+ options: [
+ { label: "Random Password", value: "password" },
+ { label: "Word Passphrase", value: "passphrase" },
+ ],
+ },
+ {
+ type: "slider",
+ key: "length",
+ label: "Password Length / Word Count",
+ defaultValue: 16,
+ min: 6,
+ max: 64,
+ step: 1,
+ },
+ {
+ type: "boolean",
+ key: "uppercase",
+ label: "Include Uppercase Letters (A-Z)",
+ defaultValue: true,
+ },
+ {
+ type: "boolean",
+ key: "lowercase",
+ label: "Include Lowercase Letters (a-z)",
+ defaultValue: true,
+ },
+ {
+ type: "boolean",
+ key: "numbers",
+ label: "Include Numbers (0-9)",
+ defaultValue: true,
+ },
+ {
+ type: "boolean",
+ key: "symbols",
+ label: "Include Symbols (!@#$%^&*)",
+ defaultValue: true,
+ },
+ {
+ type: "boolean",
+ key: "excludeAmbiguous",
+ label: "Exclude Ambiguous Characters (e.g. l, 1, I, o, 0, O)",
+ defaultValue: false,
+ },
+ {
+ type: "slider",
+ key: "count",
+ label: "Quantity to Generate",
+ defaultValue: 5,
+ min: 1,
+ max: 20,
+ step: 1,
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runPasswordGenerator,
+};
diff --git a/src/tools/password-generator/run.test.ts b/src/tools/password-generator/run.test.ts
new file mode 100644
index 0000000..ccf5ac1
--- /dev/null
+++ b/src/tools/password-generator/run.test.ts
@@ -0,0 +1,124 @@
+import { describe, it, expect, vi } from "vitest";
+import { runPasswordGenerator } from "./run";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+describe("Password & Passphrase Generator Plugin", () => {
+ const mockContext: ToolContext = {
+ signal: new AbortController().signal,
+ reportProgress: vi.fn(),
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+ };
+
+ const defaultOpts = {
+ mode: "password" as const,
+ length: 16,
+ uppercase: true,
+ lowercase: true,
+ numbers: true,
+ symbols: true,
+ excludeAmbiguous: false,
+ count: 5,
+ };
+
+ it("should generate the requested quantity of passwords", async () => {
+ const result = await runPasswordGenerator({}, defaultOpts, mockContext);
+ expect(result.type).toBe("table");
+ if (result.type === "table") {
+ expect(result.columns).toEqual(["Password", "Length", "Entropy (bits)"]);
+ expect(result.rows).toHaveLength(5);
+ expect(result.rows[0][0]).toBeTypeOf("string");
+ expect(result.rows[0][1]).toBe(16);
+ expect(result.rows[0][2]).toContain("bits");
+ }
+ });
+
+ it("should enforce selected character rules", async () => {
+ // Generate only numbers
+ const result = await runPasswordGenerator(
+ {},
+ {
+ ...defaultOpts,
+ uppercase: false,
+ lowercase: false,
+ numbers: true,
+ symbols: false,
+ },
+ mockContext
+ );
+ if (result.type === "table") {
+ result.rows.forEach(row => {
+ const password = String(row[0]);
+ expect(password).toMatch(/^[0-9]+$/);
+ });
+ }
+ });
+
+ it("should exclude ambiguous characters", async () => {
+ const result = await runPasswordGenerator(
+ {},
+ {
+ ...defaultOpts,
+ uppercase: true,
+ lowercase: true,
+ numbers: true,
+ symbols: false,
+ excludeAmbiguous: true,
+ count: 20, // larger sample size
+ },
+ mockContext
+ );
+ if (result.type === "table") {
+ result.rows.forEach(row => {
+ const password = String(row[0]);
+ // Ambiguous characters list: l, 1, I, o, 0, O
+ expect(password).not.toContain("l");
+ expect(password).not.toContain("1");
+ expect(password).not.toContain("I");
+ expect(password).not.toContain("o");
+ expect(password).not.toContain("0");
+ expect(password).not.toContain("O");
+ });
+ }
+ });
+
+ it("should generate correct passphrase count and word length", async () => {
+ const result = await runPasswordGenerator(
+ {},
+ {
+ ...defaultOpts,
+ mode: "passphrase",
+ length: 6, // 6 words
+ count: 3,
+ },
+ mockContext
+ );
+ expect(result.type).toBe("table");
+ if (result.type === "table") {
+ expect(result.columns).toEqual(["Passphrase", "Length (chars)", "Entropy (bits)"]);
+ expect(result.rows).toHaveLength(3);
+ const passphrase = String(result.rows[0][0]);
+ const words = passphrase.split("-");
+ expect(words).toHaveLength(6);
+ }
+ });
+
+ it("should throw error if all character classes are disabled", async () => {
+ await expect(
+ runPasswordGenerator(
+ {},
+ {
+ ...defaultOpts,
+ uppercase: false,
+ lowercase: false,
+ numbers: false,
+ symbols: false,
+ },
+ mockContext
+ )
+ ).rejects.toThrow("Please select at least one character set");
+ });
+});
diff --git a/src/tools/password-generator/run.ts b/src/tools/password-generator/run.ts
new file mode 100644
index 0000000..97ba164
--- /dev/null
+++ b/src/tools/password-generator/run.ts
@@ -0,0 +1,146 @@
+import { PASSPHRASE_WORDS } from "./words";
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+import type { PasswordGeneratorOptions } from "./index";
+
+function secureRandomInt(max: number): number {
+ const arr = new Uint32Array(1);
+ // Ensure we don't introduce modulo bias for small numbers
+ // max is pool size which is typically small (< 100), so standard modulo is perfectly safe in practice.
+ crypto.getRandomValues(arr);
+ return arr[0] % max;
+}
+
+function secureShuffle(arr: string[]): string[] {
+ const result = [...arr];
+ const rand = new Uint32Array(result.length);
+ crypto.getRandomValues(rand);
+ for (let i = result.length - 1; i > 0; i--) {
+ const j = rand[i] % (i + 1);
+ const temp = result[i];
+ result[i] = result[j];
+ result[j] = temp;
+ }
+ return result;
+}
+
+export async function runPasswordGenerator(
+ _input: ToolInput,
+ options: PasswordGeneratorOptions,
+ context: ToolContext
+): Promise {
+ const {
+ mode,
+ length,
+ uppercase,
+ lowercase,
+ numbers,
+ symbols,
+ excludeAmbiguous,
+ count,
+ } = options;
+
+ const qty = count || 5;
+
+ if (mode === "passphrase") {
+ context.reportProgress({ percentage: 20, message: "Generating passphrases..." });
+
+ const rows: Array<[string, number, string]> = [];
+ const wordlistSize = PASSPHRASE_WORDS.length;
+ // log2(250) is ~7.96578
+ const entropyPerWord = Math.log2(wordlistSize);
+ const totalEntropy = Math.round(length * entropyPerWord * 10) / 10;
+
+ for (let i = 0; i < qty; i++) {
+ const words: string[] = [];
+ for (let j = 0; j < length; j++) {
+ const idx = secureRandomInt(wordlistSize);
+ words.push(PASSPHRASE_WORDS[idx]);
+ }
+ const passphrase = words.join("-");
+ rows.push([passphrase, passphrase.length, `${totalEntropy} bits`]);
+ }
+
+ context.reportProgress({ percentage: 100, message: "Done" });
+ return {
+ type: "table",
+ columns: ["Passphrase", "Length (chars)", "Entropy (bits)"],
+ rows,
+ };
+ } else {
+ // Password mode
+ context.reportProgress({ percentage: 20, message: "Building character pools..." });
+
+ let uppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ let lowercaseChars = "abcdefghijklmnopqrstuvwxyz";
+ let numberChars = "0123456789";
+ let symbolChars = "!@#$%^&*()_+-=[]{}|;:,.<>?";
+
+ if (excludeAmbiguous) {
+ const ambiguous = /[l1Io0O]/g;
+ uppercaseChars = uppercaseChars.replace(ambiguous, "");
+ lowercaseChars = lowercaseChars.replace(ambiguous, "");
+ numberChars = numberChars.replace(ambiguous, "");
+ // Also remove |, :, ; etc. from symbols if they are considered ambiguous,
+ // but standard is to remove characters resembling each other: l, 1, I, o, 0, O, etc.
+ }
+
+ const pools: string[] = [];
+ if (uppercase) pools.push(uppercaseChars);
+ if (lowercase) pools.push(lowercaseChars);
+ if (numbers) pools.push(numberChars);
+ if (symbols) pools.push(symbolChars);
+
+ if (pools.length === 0) {
+ throw new Error("Invalid options: Please select at least one character set (Uppercase, Lowercase, Numbers, or Symbols) to generate passwords.");
+ }
+
+ const fullPool = pools.join("");
+ const poolSize = fullPool.length;
+ const entropyPerChar = Math.log2(poolSize);
+ const totalEntropy = Math.round(length * entropyPerChar * 10) / 10;
+
+ context.reportProgress({ percentage: 50, message: "Generating random passwords..." });
+
+ const rows: Array<[string, number, string]> = [];
+
+ for (let i = 0; i < qty; i++) {
+ const passwordChars: string[] = [];
+
+ // Guarantee at least one character from each selected character class
+ // if password length permits it
+ if (length >= pools.length) {
+ pools.forEach(pool => {
+ const idx = secureRandomInt(pool.length);
+ passwordChars.push(pool[idx]);
+ });
+
+ // Fill remaining length
+ const remainingLength = length - pools.length;
+ for (let j = 0; j < remainingLength; j++) {
+ const idx = secureRandomInt(poolSize);
+ passwordChars.push(fullPool[idx]);
+ }
+
+ // Shuffle securely to mix the guaranteed characters
+ const shuffled = secureShuffle(passwordChars);
+ rows.push([shuffled.join(""), length, `${totalEntropy} bits`]);
+ } else {
+ // Fallback for short length: pick entirely randomly from combined pool
+ for (let j = 0; j < length; j++) {
+ const idx = secureRandomInt(poolSize);
+ passwordChars.push(fullPool[idx]);
+ }
+ rows.push([passwordChars.join(""), length, `${totalEntropy} bits`]);
+ }
+ }
+
+ context.reportProgress({ percentage: 100, message: "Done" });
+ return {
+ type: "table",
+ columns: ["Password", "Length", "Entropy (bits)"],
+ rows,
+ };
+ }
+}
diff --git a/src/tools/password-generator/words.ts b/src/tools/password-generator/words.ts
new file mode 100644
index 0000000..377fca5
--- /dev/null
+++ b/src/tools/password-generator/words.ts
@@ -0,0 +1,30 @@
+export const PASSPHRASE_WORDS = [
+ "actor", "agree", "album", "alert", "apple", "armor", "arrow", "badge", "baker", "beach",
+ "beast", "berry", "blend", "block", "board", "brain", "brave", "brick", "bride", "brown",
+ "brush", "cabin", "cable", "camel", "cargo", "chain", "chair", "chalk", "charm", "chart",
+ "chief", "child", "clock", "clown", "coach", "coast", "cream", "crown", "cycle", "dance",
+ "depth", "diary", "draft", "drama", "dream", "dress", "drift", "drill", "drink", "earth",
+ "elbow", "empty", "entry", "equal", "event", "faith", "fancy", "feast", "fiber", "field",
+ "flame", "flash", "flute", "focus", "forest", "frame", "frost", "fruit", "giant", "glass",
+ "globe", "glove", "grace", "grand", "grape", "grass", "green", "group", "guard", "guide",
+ "habit", "heart", "heavy", "honey", "horse", "hotel", "house", "image", "index", "irony",
+ "ivory", "jeans", "joint", "judge", "juice", "knife", "labor", "lemon", "light", "limit",
+ "lunch", "magic", "major", "maple", "march", "match", "metal", "model", "money", "motor",
+ "mount", "mouse", "mouth", "music", "night", "noise", "north", "novel", "nurse", "ocean",
+ "onion", "order", "organ", "owner", "paint", "paper", "party", "patch", "peach", "pearl",
+ "pedal", "piano", "pilot", "pitch", "pizza", "plant", "plate", "poem", "poet", "point",
+ "pound", "power", "press", "price", "pride", "prize", "proud", "pulse", "queen", "quick",
+ "quiet", "radio", "rainy", "range", "ratio", "reply", "river", "rough", "round", "route",
+ "royal", "rugby", "ruler", "salad", "scale", "scene", "scent", "score", "scout", "shade",
+ "shadow", "shark", "sharp", "sheep", "shelf", "shell", "shirt", "shock", "shore", "sight",
+ "silk", "silver", "skate", "skill", "skirt", "slate", "sleep", "slice", "slide", "slope",
+ "smart", "smile", "smoke", "snake", "solid", "sound", "south", "space", "speak", "speed",
+ "spend", "spice", "spider", "spill", "spine", "spoon", "sport", "stage", "stamp", "stand",
+ "stare", "start", "state", "steam", "steel", "steer", "stick", "still", "stone", "store",
+ "storm", "story", "strap", "straw", "strip", "study", "sugar", "suite", "sunny", "super",
+ "sweet", "swift", "swing", "table", "tiger", "title", "toast", "token", "topic", "torch",
+ "tower", "track", "trade", "train", "trash", "trend", "trial", "truck", "truth", "tulip",
+ "uncle", "union", "unity", "value", "vapor", "vault", "venue", "voice", "vowel", "wagon",
+ "waste", "watch", "water", "wheel", "white", "width", "windy", "witch", "woman", "world",
+ "wrist", "write", "yeast", "young", "zebra", "zones"
+];
diff --git a/src/tools/pdf-merger/index.ts b/src/tools/pdf-merger/index.ts
new file mode 100644
index 0000000..bed5f33
--- /dev/null
+++ b/src/tools/pdf-merger/index.ts
@@ -0,0 +1,30 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runPdfMerger } from "./run";
+import PdfWorker from "./worker?worker";
+
+export const pdfMergerPlugin: PlimiPlugin = {
+ manifest: {
+ id: "pdf-merger",
+ name: "PDF Merger",
+ description:
+ "Merge multiple PDF files securely in your browser. Large files will not freeze your UI.",
+ category: "pdf",
+ version: "1.0.0",
+ tags: ["pdf", "merge", "join"],
+ input: {
+ type: "files",
+ accept: ["application/pdf"],
+ multiple: true,
+ },
+ output: { type: "files" },
+ offlineReady: true,
+ },
+
+ capabilities: {
+ cancelable: true,
+ worker: true,
+ },
+
+ run: runPdfMerger,
+ worker: () => new PdfWorker(),
+};
diff --git a/src/tools/pdf-merger/run.test.ts b/src/tools/pdf-merger/run.test.ts
new file mode 100644
index 0000000..f3248c4
--- /dev/null
+++ b/src/tools/pdf-merger/run.test.ts
@@ -0,0 +1,32 @@
+import { describe, it, expect, vi } from "vitest";
+import { pdfMergerPlugin } from "./index";
+import { runPdfMerger } from "./run";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+describe("PDF Merger Plugin", () => {
+ const mockContext: ToolContext = {
+ signal: new AbortController().signal,
+ reportProgress: vi.fn(),
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+ };
+
+ it("should have correct manifest", () => {
+ expect(pdfMergerPlugin.manifest.id).toBe("pdf-merger");
+ expect(pdfMergerPlugin.manifest.input.type).toBe("files");
+ expect(pdfMergerPlugin.manifest.output.type).toBe("files");
+ });
+
+ it("should have run function", () => {
+ expect(pdfMergerPlugin.run).toBeDefined();
+ });
+
+ it("should throw error if no files provided", async () => {
+ await expect(
+ runPdfMerger({ files: [] }, {}, mockContext)
+ ).rejects.toThrow("No files provided for merging.");
+ });
+});
diff --git a/src/tools/pdf-merger/run.ts b/src/tools/pdf-merger/run.ts
new file mode 100644
index 0000000..335b912
--- /dev/null
+++ b/src/tools/pdf-merger/run.ts
@@ -0,0 +1,62 @@
+import { PDFDocument } from "pdf-lib";
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+export async function runPdfMerger(
+ input: ToolInput,
+ _options: unknown,
+ context: ToolContext
+): Promise {
+ const files = input.files;
+
+ if (!files || !Array.isArray(files) || files.length === 0) {
+ throw new Error("No files provided for merging.");
+ }
+
+ try {
+ const mergedPdf = await PDFDocument.create();
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+
+ context.reportProgress({
+ percentage: (i / files.length) * 100,
+ message: `Merging ${file.name} (${i + 1}/${files.length})...`,
+ });
+
+ const fileBuffer = await file.arrayBuffer();
+ const pdfDoc = await PDFDocument.load(new Uint8Array(fileBuffer));
+
+ const copiedPages = await mergedPdf.copyPages(
+ pdfDoc,
+ pdfDoc.getPageIndices()
+ );
+ copiedPages.forEach((page) => mergedPdf.addPage(page));
+ }
+
+ context.reportProgress({
+ percentage: 100,
+ message: "Finalizing PDF...",
+ });
+
+ const mergedPdfBytes = await mergedPdf.save();
+ // TS DOM lib workaround for Uint8Array
+ const blob = new Blob([mergedPdfBytes as unknown as BlobPart], { type: "application/pdf" });
+
+ return {
+ type: "files",
+ files: [
+ {
+ name: "merged_document.pdf",
+ mimeType: "application/pdf",
+ blob: blob,
+ sizeAfter: blob.size,
+ },
+ ],
+ };
+ } catch (error) {
+ console.error("PDF Merge Error:", error);
+ throw error instanceof Error ? error : new Error("Unknown error occurred during PDF merging");
+ }
+}
diff --git a/src/tools/pdf-merger/worker.ts b/src/tools/pdf-merger/worker.ts
new file mode 100644
index 0000000..9071cab
--- /dev/null
+++ b/src/tools/pdf-merger/worker.ts
@@ -0,0 +1,87 @@
+import { PDFDocument } from "pdf-lib";
+import type {
+ ToolWorkerRequest,
+ ToolWorkerResponse,
+} from "../../core/plugins/worker-protocol";
+
+function post(response: ToolWorkerResponse) {
+ self.postMessage(response);
+}
+
+self.addEventListener("message", async (e: MessageEvent) => {
+ const { id, input } = e.data;
+ const files = input.files;
+
+ if (!files || !Array.isArray(files) || files.length === 0) {
+ post({
+ type: "error",
+ id,
+ error: "No files provided for merging.",
+ });
+ return;
+ }
+
+ try {
+ const mergedPdf = await PDFDocument.create();
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+
+ post({
+ type: "progress",
+ id,
+ progress: {
+ percentage: (i / files.length) * 100,
+ message: `Merging ${file.name} (${i + 1}/${files.length})...`,
+ },
+ });
+
+ const fileBuffer = await file.arrayBuffer();
+ const pdfDoc = await PDFDocument.load(new Uint8Array(fileBuffer));
+ const copiedPages = await mergedPdf.copyPages(
+ pdfDoc,
+ pdfDoc.getPageIndices()
+ );
+ copiedPages.forEach((page) => mergedPdf.addPage(page));
+ }
+
+ post({
+ type: "progress",
+ id,
+ progress: {
+ percentage: 100,
+ message: "Finalizing PDF...",
+ },
+ });
+
+ const mergedPdfBytes = await mergedPdf.save();
+ const blob = new Blob([mergedPdfBytes as unknown as BlobPart], {
+ type: "application/pdf",
+ });
+
+ post({
+ type: "success",
+ id,
+ result: {
+ type: "files",
+ files: [
+ {
+ name: "merged_document.pdf",
+ mimeType: "application/pdf",
+ blob,
+ sizeAfter: blob.size,
+ },
+ ],
+ },
+ });
+ } catch (error) {
+ post({
+ type: "error",
+ id,
+ error:
+ error instanceof Error
+ ? error.message
+ : "Unknown error occurred during PDF merging",
+ });
+ }
+});
diff --git a/src/tools/pdf-splitter/index.ts b/src/tools/pdf-splitter/index.ts
new file mode 100644
index 0000000..b1b7709
--- /dev/null
+++ b/src/tools/pdf-splitter/index.ts
@@ -0,0 +1,54 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runPdfSplitter } from "./run";
+
+export interface PdfSplitterOptions {
+ splitMode: "extract" | "burst";
+ pageRange: string;
+}
+
+export const pdfSplitterPlugin: PlimiPlugin = {
+ manifest: {
+ id: "pdf-splitter",
+ name: "PDF Splitter",
+ description: "Extract page ranges or split a PDF document into individual pages safely in your browser.",
+ category: "pdf",
+ version: "1.0.0",
+ tags: ["pdf", "split", "extract", "pages"],
+ input: {
+ type: "files",
+ accept: ["application/pdf"],
+ multiple: false,
+ },
+ output: { type: "files" },
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "select",
+ key: "splitMode",
+ label: "Split Mode",
+ defaultValue: "extract",
+ options: [
+ { label: "Extract Range", value: "extract" },
+ { label: "Split Every Page", value: "burst" },
+ ],
+ },
+ {
+ type: "text",
+ key: "pageRange",
+ label: "Page Range",
+ defaultValue: "1",
+ placeholder: "e.g. 1-3, 5 (for Extract mode)",
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: true,
+ worker: false,
+ },
+
+ run: runPdfSplitter,
+};
diff --git a/src/tools/pdf-splitter/run.test.ts b/src/tools/pdf-splitter/run.test.ts
new file mode 100644
index 0000000..2eeacce
--- /dev/null
+++ b/src/tools/pdf-splitter/run.test.ts
@@ -0,0 +1,117 @@
+import { describe, it, expect, vi } from "vitest";
+import { parsePageRange, runPdfSplitter } from "./run";
+import { PDFDocument } from "pdf-lib";
+import type { ToolResult } from "../../core/io/output-types";
+
+describe("PDF Splitter Utilities", () => {
+ describe("parsePageRange", () => {
+ it("should parse single pages correctly", () => {
+ expect(parsePageRange("1", 5)).toEqual([0]);
+ expect(parsePageRange("3", 5)).toEqual([2]);
+ });
+
+ it("should parse simple ranges correctly", () => {
+ expect(parsePageRange("1-3", 5)).toEqual([0, 1, 2]);
+ });
+
+ it("should parse reverse ranges correctly", () => {
+ expect(parsePageRange("3-1", 5)).toEqual([2, 1, 0]);
+ });
+
+ it("should parse comma-separated lists and ranges", () => {
+ expect(parsePageRange("1-3, 5", 5)).toEqual([0, 1, 2, 4]);
+ expect(parsePageRange("1, 3, 5", 10)).toEqual([0, 2, 4]);
+ });
+
+ it("should filter out pages out of bounds", () => {
+ expect(parsePageRange("1-10", 3)).toEqual([0, 1, 2]);
+ expect(parsePageRange("0, 5", 3)).toEqual([]); // 0 is invalid (1-indexed page bounds)
+ });
+ });
+
+ describe("runPdfSplitter", () => {
+ const createMockPdf = async (pagesCount: number): Promise => {
+ const pdfDoc = await PDFDocument.create();
+ for (let i = 0; i < pagesCount; i++) {
+ pdfDoc.addPage([200, 200]);
+ }
+ return pdfDoc.save();
+ };
+
+ it("should extract a range of pages into a single PDF", async () => {
+ const pdfBytes = await createMockPdf(5);
+ const mockFile = new File([pdfBytes as unknown as BlobPart], "source.pdf", { type: "application/pdf" });
+
+ const mockContext = {
+ reportProgress: vi.fn(),
+ signal: new AbortController().signal,
+ logger: console,
+ };
+
+ const result = await runPdfSplitter(
+ { files: [mockFile] },
+ { splitMode: "extract", pageRange: "2-4" },
+ mockContext
+ ) as Extract;
+
+ expect(result.type).toBe("files");
+ expect(result.files).toHaveLength(1);
+ expect(result.files[0].name).toBe("source_extracted.pdf");
+ expect(result.files[0].blob).toBeInstanceOf(Blob);
+
+ // Verify the generated PDF has exactly 3 pages
+ const resBytes = await result.files[0].blob.arrayBuffer();
+ const resDoc = await PDFDocument.load(new Uint8Array(resBytes));
+ expect(resDoc.getPageCount()).toBe(3);
+ });
+
+ it("should burst a PDF into separate page files", async () => {
+ const pdfBytes = await createMockPdf(3);
+ const mockFile = new File([pdfBytes as unknown as BlobPart], "source.pdf", { type: "application/pdf" });
+
+ const mockContext = {
+ reportProgress: vi.fn(),
+ signal: new AbortController().signal,
+ logger: console,
+ };
+
+ const result = await runPdfSplitter(
+ { files: [mockFile] },
+ { splitMode: "burst", pageRange: "" },
+ mockContext
+ ) as Extract;
+
+ expect(result.type).toBe("files");
+ expect(result.files).toHaveLength(3);
+ expect(result.files[0].name).toBe("source_page_1.pdf");
+ expect(result.files[1].name).toBe("source_page_2.pdf");
+ expect(result.files[2].name).toBe("source_page_3.pdf");
+
+ // Verify each generated PDF has exactly 1 page
+ for (const fileObj of result.files) {
+ const resBytes = await fileObj.blob.arrayBuffer();
+ const resDoc = await PDFDocument.load(new Uint8Array(resBytes));
+ expect(resDoc.getPageCount()).toBe(1);
+ }
+ });
+
+ it("should throw error if invalid range is supplied", async () => {
+ const pdfBytes = await createMockPdf(3);
+ const mockFile = new File([pdfBytes as unknown as BlobPart], "source.pdf", { type: "application/pdf" });
+
+ const mockContext = {
+ reportProgress: vi.fn(),
+ signal: new AbortController().signal,
+ logger: console,
+ };
+
+ await expect(
+ runPdfSplitter(
+ { files: [mockFile] },
+ { splitMode: "extract", pageRange: "10-12" }, // Out of bounds
+ mockContext
+ )
+ ).rejects.toThrow("No valid pages selected.");
+ });
+ });
+});
diff --git a/src/tools/pdf-splitter/run.ts b/src/tools/pdf-splitter/run.ts
new file mode 100644
index 0000000..aaa8130
--- /dev/null
+++ b/src/tools/pdf-splitter/run.ts
@@ -0,0 +1,134 @@
+import { PDFDocument } from "pdf-lib";
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+import type { PdfSplitterOptions } from "./index";
+
+export function parsePageRange(rangeStr: string, maxPages: number): number[] {
+ const indices: number[] = [];
+ const parts = rangeStr.replace(/\s+/g, "").split(",");
+
+ for (const part of parts) {
+ if (!part) continue;
+
+ if (part.includes("-")) {
+ const bounds = part.split("-");
+ if (bounds.length === 2) {
+ const start = parseInt(bounds[0], 10);
+ const end = parseInt(bounds[1], 10);
+
+ if (!isNaN(start) && !isNaN(end)) {
+ const step = start <= end ? 1 : -1;
+ for (let i = start; start <= end ? i <= end : i >= end; i += step) {
+ // Convert to 0-based index
+ const idx = i - 1;
+ if (idx >= 0 && idx < maxPages) {
+ indices.push(idx);
+ }
+ }
+ }
+ }
+ } else {
+ const page = parseInt(part, 10);
+ if (!isNaN(page)) {
+ const idx = page - 1;
+ if (idx >= 0 && idx < maxPages) {
+ indices.push(idx);
+ }
+ }
+ }
+ }
+
+ return indices;
+}
+
+export async function runPdfSplitter(
+ input: ToolInput,
+ options: PdfSplitterOptions,
+ context: ToolContext
+): Promise {
+ const files = input.files;
+ if (!files || !Array.isArray(files) || files.length === 0) {
+ throw new Error("No PDF file uploaded.");
+ }
+
+ const file = files[0];
+ const fileBuffer = await file.arrayBuffer();
+
+ try {
+ context.reportProgress({ percentage: 10, message: "Loading PDF document..." });
+ const srcDoc = await PDFDocument.load(new Uint8Array(fileBuffer));
+ const totalPages = srcDoc.getPageCount();
+
+ const baseName = file.name.endsWith(".pdf")
+ ? file.name.substring(0, file.name.length - 4)
+ : file.name;
+
+ if (options.splitMode === "burst") {
+ const outFiles = [];
+ for (let i = 0; i < totalPages; i++) {
+ if (context.signal?.aborted) {
+ throw new DOMException("Operation cancelled", "AbortError");
+ }
+
+ context.reportProgress({
+ percentage: 10 + (i / totalPages) * 80,
+ message: `Splitting page ${i + 1} of ${totalPages}...`,
+ });
+
+ const newDoc = await PDFDocument.create();
+ const [copiedPage] = await newDoc.copyPages(srcDoc, [i]);
+ newDoc.addPage(copiedPage);
+
+ const bytes = await newDoc.save();
+ const blob = new Blob([bytes as unknown as BlobPart], { type: "application/pdf" });
+
+ outFiles.push({
+ name: `${baseName}_page_${i + 1}.pdf`,
+ mimeType: "application/pdf",
+ blob: blob,
+ sizeAfter: blob.size,
+ });
+ }
+
+ context.reportProgress({ percentage: 100, message: "Done!" });
+ return {
+ type: "files",
+ files: outFiles,
+ };
+ } else {
+ // Extract Range mode
+ context.reportProgress({ percentage: 30, message: "Parsing page range..." });
+ const targetIndices = parsePageRange(options.pageRange || "1", totalPages);
+
+ if (targetIndices.length === 0) {
+ throw new Error(`No valid pages selected. The PDF only has ${totalPages} page(s).`);
+ }
+
+ context.reportProgress({ percentage: 50, message: "Copying requested pages..." });
+ const newDoc = await PDFDocument.create();
+ const copiedPages = await newDoc.copyPages(srcDoc, targetIndices);
+ copiedPages.forEach((page) => newDoc.addPage(page));
+
+ context.reportProgress({ percentage: 80, message: "Saving new PDF..." });
+ const bytes = await newDoc.save();
+ const blob = new Blob([bytes as unknown as BlobPart], { type: "application/pdf" });
+
+ context.reportProgress({ percentage: 100, message: "Done!" });
+ return {
+ type: "files",
+ files: [
+ {
+ name: `${baseName}_extracted.pdf`,
+ mimeType: "application/pdf",
+ blob: blob,
+ sizeAfter: blob.size,
+ },
+ ],
+ };
+ }
+ } catch (error) {
+ console.error("PDF Split Error:", error);
+ throw error instanceof Error ? error : new Error("Unknown error occurred during PDF splitting");
+ }
+}
diff --git a/src/tools/qr-code-generator/index.ts b/src/tools/qr-code-generator/index.ts
new file mode 100644
index 0000000..b461103
--- /dev/null
+++ b/src/tools/qr-code-generator/index.ts
@@ -0,0 +1,82 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runQrCodeGenerator } from "./run";
+
+export interface QrCodeGeneratorOptions {
+ size: number;
+ margin: number;
+ errorCorrection: "L" | "M" | "Q" | "H";
+ format: "svg" | "png";
+}
+
+export const qrCodeGeneratorPlugin: PlimiPlugin = {
+ manifest: {
+ id: "qr-code-generator",
+ name: "QR Code Generator (WASM)",
+ description: "Generate high-performance, offline-ready QR codes locally in the browser using WebAssembly.",
+ category: "image",
+ version: "1.0.0",
+ tags: ["qr", "qrcode", "generator", "wasm", "barcode"],
+ input: {
+ type: "text",
+ label: "Text / URL",
+ placeholder: "Enter text or URL to encode...",
+ multiline: true,
+ rows: 4,
+ },
+ output: { type: "files" },
+ example: "https://plimi.app",
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "slider",
+ key: "size",
+ label: "Size (pixels)",
+ defaultValue: 256,
+ min: 128,
+ max: 1024,
+ step: 32,
+ },
+ {
+ type: "slider",
+ key: "margin",
+ label: "Margin (modules)",
+ defaultValue: 4,
+ min: 0,
+ max: 10,
+ step: 1,
+ },
+ {
+ type: "select",
+ key: "errorCorrection",
+ label: "Error Correction Level",
+ defaultValue: "M",
+ options: [
+ { label: "Low (7% recovery)", value: "L" },
+ { label: "Medium (15% recovery)", value: "M" },
+ { label: "Quartile (25% recovery)", value: "Q" },
+ { label: "High (30% recovery)", value: "H" },
+ ],
+ },
+ {
+ type: "select",
+ key: "format",
+ label: "Output Format",
+ defaultValue: "svg",
+ options: [
+ { label: "SVG Vector File", value: "svg" },
+ { label: "PNG Image File", value: "png" },
+ ],
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runQrCodeGenerator,
+};
diff --git a/src/tools/qr-code-generator/run.test.ts b/src/tools/qr-code-generator/run.test.ts
new file mode 100644
index 0000000..a4a8d46
--- /dev/null
+++ b/src/tools/qr-code-generator/run.test.ts
@@ -0,0 +1,51 @@
+import { describe, it, expect, vi } from "vitest";
+import { runQrCodeGenerator } from "./run";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+describe("QR Code Generator Plugin", () => {
+ const mockContext: ToolContext = {
+ signal: new AbortController().signal,
+ reportProgress: vi.fn(),
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+ };
+
+ it("should generate SVG QR Code successfully", async () => {
+ const result = await runQrCodeGenerator(
+ { text: "https://plimi.app" },
+ { size: 256, margin: 4, errorCorrection: "M", format: "svg" },
+ mockContext
+ );
+ expect(result.type).toBe("files");
+ if (result.type === "files") {
+ expect(result.files).toHaveLength(1);
+ expect(result.files[0].name).toBe("qrcode.svg");
+ expect(result.files[0].mimeType).toBe("image/svg+xml");
+ expect(result.files[0].blob).toBeInstanceOf(Blob);
+ }
+ });
+
+ it("should generate PNG QR Code successfully", async () => {
+ const result = await runQrCodeGenerator(
+ { text: "https://plimi.app" },
+ { size: 256, margin: 4, errorCorrection: "M", format: "png" },
+ mockContext
+ );
+ expect(result.type).toBe("files");
+ if (result.type === "files") {
+ expect(result.files).toHaveLength(1);
+ expect(result.files[0].name).toBe("qrcode.png");
+ expect(result.files[0].mimeType).toBe("image/png");
+ expect(result.files[0].blob).toBeInstanceOf(Blob);
+ }
+ });
+
+ it("should throw error for empty input text", async () => {
+ await expect(
+ runQrCodeGenerator({ text: "" }, { size: 256, margin: 4, errorCorrection: "M", format: "svg" }, mockContext)
+ ).rejects.toThrow("Please enter text or a URL to encode in the QR Code.");
+ });
+});
diff --git a/src/tools/qr-code-generator/run.ts b/src/tools/qr-code-generator/run.ts
new file mode 100644
index 0000000..820b9d5
--- /dev/null
+++ b/src/tools/qr-code-generator/run.ts
@@ -0,0 +1,107 @@
+import { prepareZXingModule, writeBarcode } from "zxing-wasm/writer";
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+import type { QrCodeGeneratorOptions } from "./index";
+
+const isBrowser = typeof window !== "undefined";
+let isPrepared = false;
+
+async function prepareModuleOnce() {
+ if (isPrepared) return;
+
+ if (isBrowser) {
+ prepareZXingModule({
+ overrides: {
+ locateFile: (pathName: string) => {
+ if (pathName.endsWith(".wasm")) {
+ return "/zxing_writer.wasm";
+ }
+ return pathName;
+ },
+ },
+ });
+ } else {
+ const path = (await import("path" as string)) as any;
+ const fs = (await import("fs" as string)) as any;
+ const resolvedPath = path.resolve(
+ (globalThis as any).process.cwd(),
+ "node_modules/zxing-wasm/dist/writer/zxing_writer.wasm"
+ );
+ const wasmBinary = fs.readFileSync(resolvedPath);
+ prepareZXingModule({
+ overrides: {
+ wasmBinary,
+ },
+ });
+ }
+ isPrepared = true;
+}
+
+export async function runQrCodeGenerator(
+ input: ToolInput,
+ options: QrCodeGeneratorOptions,
+ context: ToolContext
+): Promise {
+ await prepareModuleOnce();
+
+ const text = (input.text ?? "").trim();
+ if (!text) {
+ throw new Error("Please enter text or a URL to encode in the QR Code.");
+ }
+
+ const { size, margin, errorCorrection, format } = options;
+
+ context.reportProgress({ percentage: 30, message: "Encoding QR Code data..." });
+
+ try {
+ const result = await writeBarcode(text, {
+ format: "QRCode",
+ scale: -size,
+ ecLevel: errorCorrection,
+ options: `margin=${margin}`,
+ });
+
+ if (result.error) {
+ throw new Error(result.error);
+ }
+
+ context.reportProgress({ percentage: 75, message: "Creating output file..." });
+
+ if (format === "svg") {
+ const blob = new Blob([result.svg], { type: "image/svg+xml" });
+ context.reportProgress({ percentage: 100, message: "Done" });
+ return {
+ type: "files",
+ files: [
+ {
+ name: "qrcode.svg",
+ mimeType: "image/svg+xml",
+ blob,
+ sizeAfter: blob.size,
+ },
+ ],
+ };
+ } else {
+ // png format
+ const blob = result.image;
+ if (!blob) {
+ throw new Error("WebAssembly failed to render the PNG image blob.");
+ }
+ context.reportProgress({ percentage: 100, message: "Done" });
+ return {
+ type: "files",
+ files: [
+ {
+ name: "qrcode.png",
+ mimeType: "image/png",
+ blob,
+ sizeAfter: blob.size,
+ },
+ ],
+ };
+ }
+ } catch (err: any) {
+ throw new Error(`Failed to generate QR Code: ${err.message ?? err}`);
+ }
+}
diff --git a/src/tools/regex-tester/index.ts b/src/tools/regex-tester/index.ts
new file mode 100644
index 0000000..abc3ec3
--- /dev/null
+++ b/src/tools/regex-tester/index.ts
@@ -0,0 +1,58 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runRegexTester } from "./run";
+
+export interface RegexTesterOptions {
+ flags: string;
+}
+
+export const regexTesterPlugin: PlimiPlugin = {
+ manifest: {
+ id: "dev-regex",
+ name: "Regex Tester",
+ description: "Test regular expressions against text and see matches highlighted.",
+ category: "developer",
+ version: "1.0.0",
+ tags: ["regex", "regexp", "test", "match"],
+ input: {
+ type: "group",
+ fields: [
+ {
+ key: "pattern",
+ label: "Regex Pattern",
+ type: "text",
+ multiline: false,
+ placeholder: "Enter regex pattern here (e.g. [A-Z]\\w+)...",
+ example: "[A-Z]\\w+",
+ },
+ {
+ key: "text",
+ label: "Test Text",
+ type: "text",
+ placeholder: "Enter test text here...",
+ example: "Hello World, This Is A Test.",
+ },
+ ],
+ },
+ output: { type: "json" },
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "text",
+ key: "flags",
+ label: "Regex Flags",
+ defaultValue: "gi",
+ placeholder: "e.g. gi, g, i, gm",
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runRegexTester,
+};
diff --git a/src/tools/regex-tester/run.test.ts b/src/tools/regex-tester/run.test.ts
new file mode 100644
index 0000000..e89d351
--- /dev/null
+++ b/src/tools/regex-tester/run.test.ts
@@ -0,0 +1,101 @@
+import { describe, it, expect, vi } from "vitest";
+import { runRegexTester } from "./run";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+describe("Regex Tester", () => {
+ const mockContext: ToolContext = {
+ signal: new AbortController().signal,
+ reportProgress: vi.fn(),
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ };
+
+ it("should find all matches with global flag", async () => {
+ const result = await runRegexTester(
+ { text: "\\d+\nabc123def456ghi789" },
+ { flags: "gi" },
+ mockContext
+ );
+ expect(result.type).toBe("json");
+ const value = (result as { type: "json"; value: Record }).value;
+ const matches = value.matches as unknown[];
+ expect(value.matchCount).toBe(3);
+ expect(matches[0]).toEqual({ match: "123", index: 3, groups: null });
+ expect(matches[1]).toEqual({ match: "456", index: 9, groups: null });
+ expect(matches[2]).toEqual({ match: "789", index: 15, groups: null });
+ });
+
+ it("should find a single match without global flag", async () => {
+ const result = await runRegexTester(
+ { text: "hello\nsay hello world" },
+ { flags: "i" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ const matches = value.matches as Array>;
+ expect(value.matchCount).toBe(1);
+ expect(matches[0].match).toBe("hello");
+ expect(matches[0].index).toBe(4);
+ });
+
+ it("should return empty matches when test text is empty", async () => {
+ const result = await runRegexTester(
+ { text: "\\d+" },
+ { flags: "gi" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.matchCount).toBe(0);
+ expect(value.matches).toEqual([]);
+ });
+
+ it("should throw on invalid regex pattern", async () => {
+ await expect(
+ runRegexTester({ text: "[invalid" }, { flags: "gi" }, mockContext)
+ ).rejects.toThrow("Invalid regex");
+ });
+
+ it("should throw when no pattern is provided", async () => {
+ await expect(
+ runRegexTester({ text: "" }, { flags: "gi" }, mockContext)
+ ).rejects.toThrow("Enter a regex pattern");
+ });
+
+ it("should parse /pattern/flags syntax from input", async () => {
+ const result = await runRegexTester(
+ { text: "/hello/gi\nhello Hello HELLO" },
+ { flags: "" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.matchCount).toBe(3);
+ });
+
+ it("should handle no matches gracefully", async () => {
+ const result = await runRegexTester(
+ { text: "xyz\nhello world" },
+ { flags: "gi" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.matchCount).toBe(0);
+ expect(value.matches).toEqual([]);
+ });
+
+ it("should find matches when using group input values", async () => {
+ const result = await runRegexTester(
+ {
+ values: {
+ pattern: { text: "\\d+" },
+ text: { text: "abc123def456ghi789" },
+ },
+ },
+ { flags: "gi" },
+ mockContext
+ );
+ expect(result.type).toBe("json");
+ const value = (result as { type: "json"; value: Record }).value;
+ const matches = value.matches as unknown[];
+ expect(value.matchCount).toBe(3);
+ expect(matches[0]).toEqual({ match: "123", index: 3, groups: null });
+ });
+});
diff --git a/src/tools/regex-tester/run.ts b/src/tools/regex-tester/run.ts
new file mode 100644
index 0000000..0e99498
--- /dev/null
+++ b/src/tools/regex-tester/run.ts
@@ -0,0 +1,107 @@
+import { getTextInput, type ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+import type { RegexTesterOptions } from "./index";
+
+export async function runRegexTester(
+ input: ToolInput,
+ options: RegexTesterOptions,
+ context?: ToolContext
+): Promise {
+ void context;
+
+ let pattern = getTextInput(input, "pattern");
+ let testText = getTextInput(input, "text");
+
+ // Fallback to legacy single text parsing (for backwards compatibility / unit tests)
+ if ((!pattern || !testText) && input.text) {
+ const text = input.text;
+ const firstLine = text.indexOf("\n") >= 0 ? text.substring(0, text.indexOf("\n")).trim() : text.trim();
+ const rest = text.indexOf("\n") >= 0 ? text.substring(text.indexOf("\n") + 1) : "";
+ pattern = pattern || firstLine;
+ testText = testText || rest;
+ }
+
+ if (!pattern) {
+ throw new Error("Enter a regex pattern.");
+ }
+
+ if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
+ const lastSlash = pattern.lastIndexOf("/");
+ const inlineFlags = pattern.substring(lastSlash + 1);
+ pattern = pattern.substring(1, lastSlash);
+ if (!options.flags) {
+ (options as unknown as Record).flags = inlineFlags;
+ }
+ }
+
+ const flags = options.flags || "gi";
+
+ let regex: RegExp;
+ try {
+ regex = new RegExp(pattern, flags);
+ } catch (cause) {
+ const msg = cause instanceof Error ? cause.message : String(cause);
+ throw new Error(`Invalid regex: ${msg}`, { cause });
+ }
+
+ if (!testText) {
+ return {
+ type: "json",
+ value: {
+ pattern,
+ flags,
+ matches: [],
+ matchCount: 0,
+ groups: [],
+ },
+ };
+ }
+
+ const matches: Array<{ match: string; index: number; groups: Record | null }> = [];
+ let matchResult: RegExpExecArray | null;
+
+ if (regex.global) {
+ while ((matchResult = regex.exec(testText)) !== null) {
+ matches.push({
+ match: matchResult[0],
+ index: matchResult.index,
+ groups: matchResult.groups ? { ...matchResult.groups } : null,
+ });
+ if (matchResult[0].length === 0) {
+ regex.lastIndex++;
+ }
+ }
+ } else {
+ matchResult = regex.exec(testText);
+ if (matchResult) {
+ matches.push({
+ match: matchResult[0],
+ index: matchResult.index,
+ groups: matchResult.groups ? { ...matchResult.groups } : null,
+ });
+ }
+ }
+
+ const allGroups = new Set();
+ const groupRegex = /\(\?<(?[a-zA-Z][a-zA-Z0-9]*)>/g;
+ let groupMatch: RegExpExecArray | null;
+ const tempRegex = new RegExp(groupRegex);
+ while ((groupMatch = tempRegex.exec(pattern)) !== null) {
+ if (groupMatch.groups?.name) {
+ allGroups.add(groupMatch.groups.name);
+ }
+ }
+
+ return {
+ type: "json",
+ value: {
+ pattern,
+ flags,
+ matches,
+ matchCount: matches.length,
+ testTextLength: testText.length,
+ groups: Array.from(allGroups),
+ },
+ };
+}
diff --git a/src/tools/text-case/index.ts b/src/tools/text-case/index.ts
new file mode 100644
index 0000000..15726da
--- /dev/null
+++ b/src/tools/text-case/index.ts
@@ -0,0 +1,46 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runTextCase } from "./run";
+
+export interface TextCaseOptions {
+ to: "UPPERCASE" | "lowercase" | "camelCase" | "snake_case" | "kebab-case";
+}
+
+export const textCasePlugin: PlimiPlugin = {
+ manifest: {
+ id: "txt-case",
+ name: "Case Converter",
+ description: "Convert text between camelCase, snake_case, etc.",
+ category: "text",
+ version: "1.0.0",
+ tags: ["case", "text", "camel", "snake"],
+ input: { type: "text" },
+ output: { type: "text" },
+ example: "hello world example text",
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "select",
+ key: "to",
+ label: "Convert to",
+ defaultValue: "snake_case",
+ options: [
+ { label: "UPPERCASE", value: "UPPERCASE" },
+ { label: "lowercase", value: "lowercase" },
+ { label: "camelCase", value: "camelCase" },
+ { label: "snake_case", value: "snake_case" },
+ { label: "kebab-case", value: "kebab-case" },
+ ],
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runTextCase,
+};
diff --git a/src/tools/text-case/run.ts b/src/tools/text-case/run.ts
new file mode 100644
index 0000000..411f2ea
--- /dev/null
+++ b/src/tools/text-case/run.ts
@@ -0,0 +1,41 @@
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { TextCaseOptions } from "./index";
+
+export async function runTextCase(
+ input: ToolInput,
+ options: TextCaseOptions
+): Promise {
+ const text = input.text || "";
+ if (!text) {
+ throw new Error("No text provided.");
+ }
+
+ // A simple tokenizer that splits by whitespace or punctuation
+ const words = text.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+/g) || [];
+
+ const outputStr = (() => {
+ switch (options.to) {
+ case "UPPERCASE":
+ return text.toUpperCase();
+ case "lowercase":
+ return text.toLowerCase();
+ case "camelCase":
+ return words.map((w, i) => {
+ const lower = w.toLowerCase();
+ return i === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1);
+ }).join("");
+ case "snake_case":
+ return words.map(w => w.toLowerCase()).join("_");
+ case "kebab-case":
+ return words.map(w => w.toLowerCase()).join("-");
+ default:
+ return text;
+ }
+ })();
+
+ return {
+ type: "text",
+ value: outputStr,
+ };
+}
diff --git a/src/tools/text-diff/index.ts b/src/tools/text-diff/index.ts
new file mode 100644
index 0000000..1b0d510
--- /dev/null
+++ b/src/tools/text-diff/index.ts
@@ -0,0 +1,56 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runTextDiff } from "./run";
+
+export interface TextDiffOptions {
+ ignoreWhitespace: boolean;
+}
+
+export const textDiffPlugin: PlimiPlugin = {
+ manifest: {
+ id: "txt-diff",
+ name: "Text Diff",
+ description: "Compare two texts and see the differences line by line.",
+ category: "text",
+ version: "1.0.0",
+ tags: ["diff", "compare", "text"],
+ input: {
+ type: "group",
+ fields: [
+ {
+ key: "original",
+ label: "original",
+ type: "text",
+ placeholder: "Paste the original text…",
+ example: "function greet(name) {\n return 'Hello ' + name;\n}",
+ },
+ {
+ key: "modified",
+ label: "modified",
+ type: "text",
+ placeholder: "Paste the modified text…",
+ example: "function greet(name) {\n return `Hello, ${name}!`;\n}\n\nfunction farewell(name) {\n return `Bye, ${name}!`;\n}",
+ },
+ ],
+ },
+ output: { type: "text" },
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "boolean",
+ key: "ignoreWhitespace",
+ label: "Ignore whitespace",
+ defaultValue: false,
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runTextDiff,
+};
diff --git a/src/tools/text-diff/run.test.ts b/src/tools/text-diff/run.test.ts
new file mode 100644
index 0000000..e38d7ff
--- /dev/null
+++ b/src/tools/text-diff/run.test.ts
@@ -0,0 +1,107 @@
+import { describe, it, expect, vi } from "vitest";
+import { runTextDiff } from "./run";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+describe("Text Diff", () => {
+ const mockContext: ToolContext = {
+ signal: new AbortController().signal,
+ reportProgress: vi.fn(),
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ };
+
+ it("should detect added lines", async () => {
+ const result = await runTextDiff(
+ { values: { original: { text: "line 1" }, modified: { text: "line 1\nline 2" } } },
+ { ignoreWhitespace: false },
+ mockContext
+ );
+ expect(result.type).toBe("text");
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toContain("+ line 2");
+ expect(value).toContain(" line 1");
+ });
+
+ it("should detect removed lines", async () => {
+ const result = await runTextDiff(
+ { values: { original: { text: "line 1\nline 2" }, modified: { text: "line 1" } } },
+ { ignoreWhitespace: false },
+ mockContext
+ );
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toContain("- line 2");
+ expect(value).toContain(" line 1");
+ });
+
+ it("should detect identical texts", async () => {
+ const result = await runTextDiff(
+ { values: { original: { text: "same text" }, modified: { text: "same text" } } },
+ { ignoreWhitespace: false },
+ mockContext
+ );
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toContain(" same text");
+ expect(value).toContain("0 added, 0 removed");
+ });
+
+ it("should show summary with counts", async () => {
+ const result = await runTextDiff(
+ { values: { original: { text: "a\nb\nc" }, modified: { text: "a\nx\nc\nd" } } },
+ { ignoreWhitespace: false },
+ mockContext
+ );
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toContain("Summary:");
+ expect(value).toContain("added");
+ expect(value).toContain("removed");
+ expect(value).toContain("unchanged");
+ });
+
+ it("should ignore whitespace when option is set", async () => {
+ const result = await runTextDiff(
+ { values: { original: { text: "hello world" }, modified: { text: "hello world" } } },
+ { ignoreWhitespace: true },
+ mockContext
+ );
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toContain("0 added, 0 removed");
+ });
+
+ it("should throw when one side is missing", async () => {
+ await expect(
+ runTextDiff(
+ { values: { original: { text: "just one text" } } },
+ { ignoreWhitespace: false },
+ mockContext
+ )
+ ).rejects.toThrow("Both original and modified text are required");
+ });
+
+ it("should throw on empty input", async () => {
+ await expect(
+ runTextDiff({ values: {} }, { ignoreWhitespace: false }, mockContext)
+ ).rejects.toThrow("No text provided");
+ });
+
+ it("should handle completely different texts", async () => {
+ const result = await runTextDiff(
+ { values: { original: { text: "aaa\nbbb" }, modified: { text: "xxx\nyyy" } } },
+ { ignoreWhitespace: false },
+ mockContext
+ );
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toContain("- aaa");
+ expect(value).toContain("- bbb");
+ expect(value).toContain("+ xxx");
+ expect(value).toContain("+ yyy");
+ });
+
+ it("should still support legacy separator-based input", async () => {
+ const result = await runTextDiff(
+ { text: "line 1\n---\nline 1\nline 2" },
+ { ignoreWhitespace: false },
+ mockContext
+ );
+ const value = (result as { type: "text"; value: string }).value;
+ expect(value).toContain("+ line 2");
+ });
+});
diff --git a/src/tools/text-diff/run.ts b/src/tools/text-diff/run.ts
new file mode 100644
index 0000000..387d33c
--- /dev/null
+++ b/src/tools/text-diff/run.ts
@@ -0,0 +1,103 @@
+import { getTextInput, type ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+import type { TextDiffOptions } from "./index";
+
+function lcs(a: string[], b: string[]): number[][] {
+ const m = a.length;
+ const n = b.length;
+ const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
+
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ if (a[i - 1] === b[j - 1]) {
+ dp[i][j] = dp[i - 1][j - 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ }
+
+ return dp;
+}
+
+function computeDiff(a: string[], b: string[], dp: number[][]): Array<{ type: "same" | "added" | "removed"; line: string }> {
+ const result: Array<{ type: "same" | "added" | "removed"; line: string }> = [];
+ let i = a.length;
+ let j = b.length;
+
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
+ result.unshift({ type: "same", line: a[i - 1] });
+ i--;
+ j--;
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+ result.unshift({ type: "added", line: b[j - 1] });
+ j--;
+ } else if (i > 0) {
+ result.unshift({ type: "removed", line: a[i - 1] });
+ i--;
+ }
+ }
+
+ return result;
+}
+
+export async function runTextDiff(
+ input: ToolInput,
+ options: TextDiffOptions,
+ _context: ToolContext
+): Promise {
+ void _context;
+ let textA = getTextInput(input, "original");
+ let textB = getTextInput(input, "modified");
+
+ if ((!textA || !textB) && input.text) {
+ const separatorIndex = input.text.indexOf("\n---\n");
+ if (separatorIndex >= 0) {
+ textA = input.text.substring(0, separatorIndex);
+ textB = input.text.substring(separatorIndex + 5);
+ }
+ }
+
+ if (!textA.trim() && !textB.trim()) {
+ throw new Error("No text provided. Fill both original and modified text.");
+ }
+
+ if (!textA.trim() || !textB.trim()) {
+ throw new Error("Both original and modified text are required.");
+ }
+
+ if (options.ignoreWhitespace) {
+ textA = textA.replace(/\s+/g, " ").trim();
+ textB = textB.replace(/\s+/g, " ").trim();
+ }
+
+ const linesA = textA.split("\n");
+ const linesB = textB.split("\n");
+
+ const dp = lcs(linesA, linesB);
+ const diff = computeDiff(linesA, linesB, dp);
+
+ const output = diff.map((entry) => {
+ switch (entry.type) {
+ case "added":
+ return `+ ${entry.line}`;
+ case "removed":
+ return `- ${entry.line}`;
+ case "same":
+ return ` ${entry.line}`;
+ }
+ }).join("\n");
+
+ const added = diff.filter((d) => d.type === "added").length;
+ const removed = diff.filter((d) => d.type === "removed").length;
+ const same = diff.filter((d) => d.type === "same").length;
+
+ const summary = `\n\n--- Summary: ${added} added, ${removed} removed, ${same} unchanged ---`;
+
+ return {
+ type: "text",
+ value: output + summary,
+ };
+}
diff --git a/src/tools/timestamp-converter/index.ts b/src/tools/timestamp-converter/index.ts
new file mode 100644
index 0000000..6ad3ec6
--- /dev/null
+++ b/src/tools/timestamp-converter/index.ts
@@ -0,0 +1,54 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runTimestampConverter } from "./run";
+
+export interface TimestampOptions {
+ mode: "timestamp-to-date" | "date-to-timestamp";
+ unit: "seconds" | "milliseconds";
+}
+
+export const timestampConverterPlugin: PlimiPlugin = {
+ manifest: {
+ id: "dev-timestamp",
+ name: "Timestamp Converter",
+ description: "Convert between Unix timestamps and human-readable dates.",
+ category: "developer",
+ version: "1.0.0",
+ tags: ["timestamp", "unix", "date", "time", "epoch"],
+ input: { type: "text", placeholder: "e.g. 1700000000 or 2024-01-15T12:00:00Z", multiline: false },
+ output: { type: "json" },
+ example: "1700000000",
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "select",
+ key: "mode",
+ label: "Mode",
+ defaultValue: "timestamp-to-date",
+ options: [
+ { label: "Timestamp → Date", value: "timestamp-to-date" },
+ { label: "Date → Timestamp", value: "date-to-timestamp" },
+ ],
+ },
+ {
+ type: "select",
+ key: "unit",
+ label: "Timestamp Unit",
+ defaultValue: "seconds",
+ options: [
+ { label: "Seconds", value: "seconds" },
+ { label: "Milliseconds", value: "milliseconds" },
+ ],
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runTimestampConverter,
+};
diff --git a/src/tools/timestamp-converter/run.test.ts b/src/tools/timestamp-converter/run.test.ts
new file mode 100644
index 0000000..24c0d5a
--- /dev/null
+++ b/src/tools/timestamp-converter/run.test.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect, vi } from "vitest";
+import { runTimestampConverter } from "./run";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+describe("Timestamp Converter", () => {
+ const mockContext: ToolContext = {
+ signal: new AbortController().signal,
+ reportProgress: vi.fn(),
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ };
+
+ it("should convert a Unix seconds timestamp to date", async () => {
+ const result = await runTimestampConverter(
+ { text: "0" },
+ { mode: "timestamp-to-date", unit: "seconds" },
+ mockContext
+ );
+ expect(result.type).toBe("json");
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.iso).toBe("1970-01-01T00:00:00.000Z");
+ expect(value.unixSeconds).toBe(0);
+ expect(value.dayOfWeek).toBe("Thursday");
+ });
+
+ it("should convert a known timestamp correctly", async () => {
+ const result = await runTimestampConverter(
+ { text: "1700000000" },
+ { mode: "timestamp-to-date", unit: "seconds" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.iso).toBe("2023-11-14T22:13:20.000Z");
+ expect(value.unixSeconds).toBe(1700000000);
+ });
+
+ it("should convert milliseconds timestamp to date", async () => {
+ const result = await runTimestampConverter(
+ { text: "1700000000000" },
+ { mode: "timestamp-to-date", unit: "milliseconds" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.iso).toBe("2023-11-14T22:13:20.000Z");
+ });
+
+ it("should convert an ISO date string to Unix timestamp (seconds)", async () => {
+ const result = await runTimestampConverter(
+ { text: "2024-01-15T12:00:00Z" },
+ { mode: "date-to-timestamp", unit: "seconds" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ const both = value.both as Record;
+ expect(value.unixSeconds).toBe(1705320000);
+ expect(both.seconds).toBe(1705320000);
+ });
+
+ it("should convert an ISO date string to Unix timestamp (milliseconds)", async () => {
+ const result = await runTimestampConverter(
+ { text: "2024-01-15T12:00:00Z" },
+ { mode: "date-to-timestamp", unit: "milliseconds" },
+ mockContext
+ );
+ const value = (result as { type: "json"; value: Record }).value;
+ expect(value.unixMillis).toBe(1705320000000);
+ });
+
+ it("should throw on empty input", async () => {
+ await expect(
+ runTimestampConverter(
+ { text: "" },
+ { mode: "timestamp-to-date", unit: "seconds" },
+ mockContext
+ )
+ ).rejects.toThrow("No input provided");
+ });
+
+ it("should throw on invalid timestamp", async () => {
+ await expect(
+ runTimestampConverter(
+ { text: "not-a-number" },
+ { mode: "timestamp-to-date", unit: "seconds" },
+ mockContext
+ )
+ ).rejects.toThrow("Invalid timestamp");
+ });
+
+ it("should throw on invalid date string", async () => {
+ await expect(
+ runTimestampConverter(
+ { text: "not-a-date" },
+ { mode: "date-to-timestamp", unit: "seconds" },
+ mockContext
+ )
+ ).rejects.toThrow("Invalid date string");
+ });
+});
diff --git a/src/tools/timestamp-converter/run.ts b/src/tools/timestamp-converter/run.ts
new file mode 100644
index 0000000..7a31253
--- /dev/null
+++ b/src/tools/timestamp-converter/run.ts
@@ -0,0 +1,77 @@
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+import type { TimestampOptions } from "./index";
+
+export async function runTimestampConverter(
+ input: ToolInput,
+ options: TimestampOptions,
+ context?: ToolContext
+): Promise {
+ void context;
+
+ const text = (input.text || "").trim();
+ if (!text) {
+ throw new Error("No input provided.");
+ }
+
+ if (options.mode === "timestamp-to-date") {
+ let ts = Number(text);
+ if (isNaN(ts)) {
+ throw new Error("Invalid timestamp. Enter a numeric Unix timestamp.");
+ }
+
+ if (options.unit === "seconds") {
+ ts = ts * 1000;
+ }
+
+ const date = new Date(ts);
+ if (isNaN(date.getTime())) {
+ throw new Error("Invalid timestamp value.");
+ }
+
+ return {
+ type: "json",
+ value: {
+ input: text,
+ iso: date.toISOString(),
+ utc: date.toUTCString(),
+ local: date.toLocaleString(),
+ date: date.toISOString().split("T")[0],
+ time: date.toISOString().split("T")[1].replace("Z", ""),
+ unixSeconds: Math.floor(date.getTime() / 1000),
+ unixMillis: date.getTime(),
+ dayOfWeek: date.toLocaleDateString("en-US", { weekday: "long" }),
+ },
+ };
+ } else {
+ let date: Date;
+ if (/^\d{4}-\d{2}-\d{2}(T|\s)/.test(text)) {
+ date = new Date(text);
+ } else {
+ date = new Date(text);
+ }
+
+ if (isNaN(date.getTime())) {
+ throw new Error("Invalid date string. Use ISO 8601 format like 2024-01-15T12:00:00Z.");
+ }
+
+ const unixSeconds = Math.floor(date.getTime() / 1000);
+ const unixMillis = date.getTime();
+
+ return {
+ type: "json",
+ value: {
+ input: text,
+ iso: date.toISOString(),
+ utc: date.toUTCString(),
+ unixSeconds: options.unit === "seconds" ? unixSeconds : undefined,
+ unixMillis: options.unit === "milliseconds" ? unixMillis : undefined,
+ both: {
+ seconds: unixSeconds,
+ milliseconds: unixMillis,
+ },
+ },
+ };
+ }
+}
diff --git a/src/tools/url-encoder/index.ts b/src/tools/url-encoder/index.ts
new file mode 100644
index 0000000..e804a49
--- /dev/null
+++ b/src/tools/url-encoder/index.ts
@@ -0,0 +1,54 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runUrlEncoder } from "./run";
+
+export interface UrlEncoderOptions {
+ mode: "encode" | "decode";
+ component: "full" | "component";
+}
+
+export const urlEncoderPlugin: PlimiPlugin = {
+ manifest: {
+ id: "dev-url",
+ name: "URL Encoder",
+ description: "Encode or decode URLs and URI components.",
+ category: "developer",
+ version: "1.0.0",
+ tags: ["url", "encode", "decode", "uri"],
+ input: { type: "text" },
+ output: { type: "text" },
+ example: "https://plimi.app/search?q=hello world&lang=en",
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "select",
+ key: "mode",
+ label: "Mode",
+ defaultValue: "encode",
+ options: [
+ { label: "Encode", value: "encode" },
+ { label: "Decode", value: "decode" },
+ ],
+ },
+ {
+ type: "select",
+ key: "component",
+ label: "Target",
+ defaultValue: "component",
+ options: [
+ { label: "Full URL", value: "full" },
+ { label: "URI Component", value: "component" },
+ ],
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runUrlEncoder,
+};
diff --git a/src/tools/url-encoder/run.ts b/src/tools/url-encoder/run.ts
new file mode 100644
index 0000000..8873f80
--- /dev/null
+++ b/src/tools/url-encoder/run.ts
@@ -0,0 +1,28 @@
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { UrlEncoderOptions } from "./index";
+
+export async function runUrlEncoder(
+ input: ToolInput,
+ options: UrlEncoderOptions
+): Promise {
+ const text = input.text || "";
+ if (!text) {
+ throw new Error("No text provided.");
+ }
+
+ try {
+ const outputStr =
+ options.mode === "encode"
+ ? options.component === "full" ? encodeURI(text) : encodeURIComponent(text)
+ : options.component === "full" ? decodeURI(text) : decodeURIComponent(text);
+
+ return {
+ type: "text",
+ value: outputStr,
+ };
+ } catch (cause) {
+ const message = cause instanceof Error ? cause.message : String(cause);
+ throw new Error(`Failed to process URL: ${message}`, { cause });
+ }
+}
diff --git a/src/tools/uuid-generator/index.ts b/src/tools/uuid-generator/index.ts
new file mode 100644
index 0000000..55cc5d1
--- /dev/null
+++ b/src/tools/uuid-generator/index.ts
@@ -0,0 +1,41 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runUuidGenerator } from "./run";
+
+export interface UuidOptions {
+ count: number;
+}
+
+export const uuidGeneratorPlugin: PlimiPlugin = {
+ manifest: {
+ id: "crypto-uuid",
+ name: "UUID Generator",
+ description: "Generate cryptographically secure v4 UUIDs.",
+ category: "crypto",
+ version: "1.0.0",
+ tags: ["uuid", "guid", "crypto", "random"],
+ input: { type: "none" },
+ output: { type: "text" },
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "slider",
+ key: "count",
+ label: "Count",
+ defaultValue: 1,
+ min: 1,
+ max: 100,
+ step: 1,
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runUuidGenerator,
+};
diff --git a/src/tools/uuid-generator/run.ts b/src/tools/uuid-generator/run.ts
new file mode 100644
index 0000000..17f26c6
--- /dev/null
+++ b/src/tools/uuid-generator/run.ts
@@ -0,0 +1,24 @@
+import type { ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+import type { UuidOptions } from "./index";
+
+export async function runUuidGenerator(
+ _input: ToolInput,
+ options: UuidOptions,
+ _context: ToolContext
+): Promise {
+ void _input;
+ void _context;
+ const count = options.count || 1;
+ const uuids: string[] = [];
+
+ for (let i = 0; i < count; i++) {
+ uuids.push(crypto.randomUUID());
+ }
+
+ return {
+ type: "text",
+ value: uuids.join("\n"),
+ };
+}
diff --git a/src/tools/variables-converter/index.ts b/src/tools/variables-converter/index.ts
new file mode 100644
index 0000000..5f4fd39
--- /dev/null
+++ b/src/tools/variables-converter/index.ts
@@ -0,0 +1,52 @@
+import type { PlimiPlugin } from "../../core/plugins/plugin-types";
+import { runVariablesConverter } from "./run";
+
+export interface VariablesConverterOptions {
+ toFormat: "auto" | "constant" | "dot" | "camel" | "pascal" | "snake" | "kebab";
+}
+
+export const variablesConverterPlugin: PlimiPlugin = {
+ manifest: {
+ id: "dev-varconvert",
+ name: "Variables Converter",
+ description: "Convert variable names between camelCase, PascalCase, snake_case, constant_case (A_B_C), dot.notation (a.b.c), and kebab-case.",
+ category: "developer",
+ version: "1.0.0",
+ tags: ["variable", "casing", "convert", "camelcase", "snakecase", "naming"],
+ input: {
+ type: "text",
+ placeholder: "e.g. user.profile.name or USER_PROFILE_NAME...",
+ multiline: false,
+ },
+ output: { type: "json" },
+ example: "user.profile.name",
+ offlineReady: true,
+ },
+
+ optionsSchema: {
+ fields: [
+ {
+ type: "select",
+ key: "toFormat",
+ label: "Primary Format",
+ defaultValue: "auto",
+ options: [
+ { label: "Auto (Detect)", value: "auto" },
+ { label: "Constant Case (A_B_C)", value: "constant" },
+ { label: "Dot Notation (a.b.c)", value: "dot" },
+ { label: "Camel Case (aBC)", value: "camel" },
+ { label: "Pascal Case (ABC)", value: "pascal" },
+ { label: "Snake Case (a_b_c)", value: "snake" },
+ { label: "Kebab Case (a-b-c)", value: "kebab" },
+ ],
+ },
+ ],
+ },
+
+ capabilities: {
+ cancelable: false,
+ worker: false,
+ },
+
+ run: runVariablesConverter,
+};
diff --git a/src/tools/variables-converter/run.test.ts b/src/tools/variables-converter/run.test.ts
new file mode 100644
index 0000000..a590cf3
--- /dev/null
+++ b/src/tools/variables-converter/run.test.ts
@@ -0,0 +1,86 @@
+import { describe, it, expect, vi } from "vitest";
+import { runVariablesConverter } from "./run";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+
+describe("Variables Converter", () => {
+ const mockContext: ToolContext = {
+ signal: new AbortController().signal,
+ reportProgress: vi.fn(),
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ };
+
+ it("should convert dot notation to constant case and others", async () => {
+ const result = await runVariablesConverter(
+ { text: "a.b.c" },
+ { toFormat: "constant" },
+ mockContext
+ );
+ expect(result.type).toBe("json");
+ const val = (result as { type: "json"; value: Record }).value;
+ expect(val.constant).toBe("A_B_C");
+ expect(val.dot).toBe("a.b.c");
+ expect(val.camel).toBe("aBC");
+ expect(val.pascal).toBe("ABC");
+ expect(val.snake).toBe("a_b_c");
+ expect(val.kebab).toBe("a-b-c");
+ expect(val.primary).toBe("A_B_C");
+ });
+
+ it("should convert screaming snake case (constant) to dot notation", async () => {
+ const result = await runVariablesConverter(
+ { text: "USER_PROFILE_NAME" },
+ { toFormat: "dot" },
+ mockContext
+ );
+ const val = (result as { type: "json"; value: Record }).value;
+ expect(val.constant).toBe("USER_PROFILE_NAME");
+ expect(val.dot).toBe("user.profile.name");
+ expect(val.camel).toBe("userProfileName");
+ expect(val.pascal).toBe("UserProfileName");
+ expect(val.primary).toBe("user.profile.name");
+ });
+
+ it("should handle camelCase inputs correctly", async () => {
+ const result = await runVariablesConverter(
+ { text: "myApiKey" },
+ { toFormat: "kebab" },
+ mockContext
+ );
+ const val = (result as { type: "json"; value: Record }).value;
+ expect(val.constant).toBe("MY_API_KEY");
+ expect(val.kebab).toBe("my-api-key");
+ expect(val.primary).toBe("my-api-key");
+ });
+
+ it("should handle auto mode detection correctly", async () => {
+ // 1. Dot notation auto-converts to Constant Case
+ const resDot = await runVariablesConverter(
+ { text: "a.b.c" },
+ { toFormat: "auto" },
+ mockContext
+ );
+ expect((resDot as { type: "json"; value: Record }).value.primary).toBe("A_B_C");
+
+ // 2. Kebab notation auto-converts to Constant Case
+ const resKebab = await runVariablesConverter(
+ { text: "a-b-c" },
+ { toFormat: "auto" },
+ mockContext
+ );
+ expect((resKebab as { type: "json"; value: Record }).value.primary).toBe("A_B_C");
+
+ // 3. Constant notation auto-converts to Dot Notation
+ const resConstant = await runVariablesConverter(
+ { text: "A_B_C" },
+ { toFormat: "auto" },
+ mockContext
+ );
+ expect((resConstant as { type: "json"; value: Record }).value.primary).toBe("a.b.c");
+ });
+
+ it("should throw on empty input", async () => {
+ await expect(
+ runVariablesConverter({ text: "" }, { toFormat: "auto" }, mockContext)
+ ).rejects.toThrow("No variable name provided");
+ });
+});
diff --git a/src/tools/variables-converter/run.ts b/src/tools/variables-converter/run.ts
new file mode 100644
index 0000000..0aff479
--- /dev/null
+++ b/src/tools/variables-converter/run.ts
@@ -0,0 +1,84 @@
+import { getTextInput, type ToolInput } from "../../core/io/input-types";
+import type { ToolResult } from "../../core/io/output-types";
+import type { ToolContext } from "../../core/plugins/plugin-types";
+import type { VariablesConverterOptions } from "./index";
+
+function tokenize(input: string): string[] {
+ // Add space boundaries for camelCase/PascalCase transitions
+ const spaced = input
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1 $2");
+
+ // Split on dots, underscores, hyphens, or spaces
+ return spaced
+ .split(/[\s._-]+/)
+ .map((w) => w.trim().toLowerCase())
+ .filter(Boolean);
+}
+
+export async function runVariablesConverter(
+ input: ToolInput,
+ options: VariablesConverterOptions,
+ context?: ToolContext
+): Promise {
+ void context;
+
+ const text = getTextInput(input).trim();
+ if (!text) {
+ throw new Error("No variable name provided.");
+ }
+
+ const words = tokenize(text);
+ if (words.length === 0) {
+ throw new Error("Could not parse any variable names from input.");
+ }
+
+ const dot = words.join(".");
+ const constant = words.map((w) => w.toUpperCase()).join("_");
+ const camel = words
+ .map((w, idx) => (idx === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1)))
+ .join("");
+ const pascal = words
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+ .join("");
+ const snake = words.join("_");
+ const kebab = words.join("-");
+
+ const primary = (() => {
+ if (options.toFormat === "auto") {
+ // If input is uppercase or contains underscores, auto-convert to dot notation
+ const isUpperOrSnake = text === text.toUpperCase() || text.includes("_");
+ return isUpperOrSnake ? dot : constant;
+ }
+
+ switch (options.toFormat) {
+ case "dot":
+ return dot;
+ case "camel":
+ return camel;
+ case "pascal":
+ return pascal;
+ case "snake":
+ return snake;
+ case "kebab":
+ return kebab;
+ case "constant":
+ default:
+ return constant;
+ }
+ })();
+
+ return {
+ type: "json",
+ value: {
+ input: text,
+ constant,
+ dot,
+ camel,
+ pascal,
+ snake,
+ kebab,
+ primary,
+ },
+ };
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..7f42e5f
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "esnext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..d3c52ea
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023"],
+ "module": "esnext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..4ff4f8f
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+});
Connect with us
+Join the Vite community
++-
+
+
+ GitHub
+
+
+ -
+
+
+ Discord
+
+
+ -
+
+
+ X.com
+
+
+ -
+
+
+ Bluesky
+
+
+
+