First implementation of Plimi

This commit is contained in:
achraf
2026-06-02 19:32:51 +02:00
commit be635b1828
136 changed files with 13663 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@@ -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

6
.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5"
}

215
README.md Normal file
View File

@@ -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<MyToolOptions> = {
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<ToolResult> {
// 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<any>[] = [
// ... 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.

22
eslint.config.js Normal file
View File

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

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<title>Plimi</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

42
package.json Normal file
View File

@@ -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"
}
}

2979
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

60
public/favicon.svg Normal file
View File

@@ -0,0 +1,60 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<style>
/* Global classes */
.tool-outline { stroke: #1A1714; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.pouch-outline { stroke: #1A1714; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.pouch-fill { fill: #5B8C3E; }
.pencil-shaft { fill: #EDE3D0; }
.pencil-wood { fill: #FBF6ED; }
.pencil-lead { fill: #5B8C3E; }
.ruler-fill { fill: #FFFBF2; }
.pen-fill { fill: #1A1714; }
.zipper-line { stroke: #FFFBF2; stroke-width: 1.2; stroke-dasharray: 1.2, 1.2; }
.zipper-pull { fill: #FFFBF2; stroke: #1A1714; stroke-width: 1.2; stroke-linejoin: round; }
.zipper-hole { fill: #1A1714; }
@media (prefers-color-scheme: dark) {
.tool-outline { stroke: #F2F2EF; }
.pouch-outline { stroke: #F2F2EF; }
.pouch-fill { fill: #A8FF60; }
.pencil-shaft { fill: #1E222D; }
.pencil-wood { fill: #161922; }
.pencil-lead { fill: #A8FF60; }
.ruler-fill { fill: #161922; }
.pen-fill { fill: #F2F2EF; }
.zipper-line { stroke: #0F1117; stroke-width: 1.2; stroke-dasharray: 1.2, 1.2; }
.zipper-pull { fill: #0F1117; stroke: #F2F2EF; stroke-width: 1.2; }
.zipper-hole { fill: #F2F2EF; }
}
</style>
<!-- Pencils, Pens, Rulers peeking from behind -->
<g id="tools">
<!-- Pencil (Left) -->
<rect class="pencil-shaft tool-outline" x="7" y="10" width="4" height="7" />
<polygon class="pencil-wood tool-outline" points="7,10 9,6 11,10" />
<polygon class="pencil-lead" points="8.2,8.4 9,6 9.8,8.4" />
<!-- Ruler (Center) -->
<rect class="ruler-fill tool-outline" x="14" y="5" width="4" height="12" />
<line class="tool-outline" x1="14.5" y1="8" x2="16.5" y2="8" stroke-width="1" />
<line class="tool-outline" x1="14.5" y1="11" x2="17.5" y2="11" stroke-width="1" />
<line class="tool-outline" x1="14.5" y1="14" x2="16.5" y2="14" stroke-width="1" />
<!-- Pen (Right) -->
<path class="pen-fill tool-outline" d="M 21,9 A 1.5,1.5 0 0,1 24,9 L 24,17 L 21,17 Z" />
<path class="tool-outline" d="M 24,11 H 25.5 V 14.5 H 24" fill="none" stroke-width="1.2" />
</g>
<!-- Pouch body in front -->
<g id="pouch">
<rect class="pouch-fill pouch-outline" x="4" y="15" width="24" height="12" rx="3.5" />
<line class="zipper-line" x1="4.5" y1="18.5" x2="27.5" y2="18.5" />
<rect class="zipper-pull" x="8" y="17" width="2.5" height="4.5" rx="0.8" />
<circle class="zipper-hole" cx="9.25" cy="19.25" r="0.6" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
public/zxing_writer.wasm Normal file

Binary file not shown.

184
src/App.css Normal file
View File

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

122
src/App.tsx Normal file
View File

@@ -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 (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
);
}
export default App;

6
src/app/App.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { RouterProvider } from "react-router-dom";
import { router } from "./router";
export function App() {
return <RouterProvider router={router} />;
}

8
src/app/ThemeContext.ts Normal file
View File

@@ -0,0 +1,8 @@
import { createContext } from "react";
export type ThemeContextType = {
dark: boolean;
toggleTheme: () => void;
};
export const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

25
src/app/ThemeProvider.tsx Normal file
View File

@@ -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 (
<ThemeContext.Provider value={{ dark, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}

31
src/app/router.tsx Normal file
View File

@@ -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: <AppShell />,
children: [
{
index: true,
element: <HomePage />,
},
{
path: "tools",
element: <ToolsPage />,
},
{
path: "tools/:toolId",
element: <ToolDetailPage />,
},
{
path: "how-it-works",
element: <HowItWorksPage />,
},
],
},
]);

10
src/app/useTheme.ts Normal file
View File

@@ -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;
}

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -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<HTMLInputElement>(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 (
<div className="w-full">
<div
className="relative bg-[var(--p-surface)] border-[1.5px] border-[var(--p-border)] rounded-[20px] px-[22px] py-[20px] flex items-center gap-[14px] transition-colors cursor-text"
style={{
boxShadow: '0 1px 0 0 var(--p-shadow-inset), 0 12px 30px -22px var(--p-shadow-soft)',
}}
onClick={() => inputRef.current?.focus()}
>
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="var(--p-muted)"
strokeWidth="2"
strokeLinecap="round"
className="shrink-0"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<input
ref={inputRef}
value={value}
onChange={(e) => 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"
/>
<span className="shrink-0 inline-flex items-center gap-1.5 font-mono text-xs text-[var(--p-muted)]">
{value ? (
<button
onClick={(e) => {
e.stopPropagation();
onChange('');
}}
className="px-2 py-1 rounded-lg bg-[var(--p-chip)] text-[var(--p-muted)] cursor-pointer"
>
clear
</button>
) : (
<kbd className="px-2 py-1.5 rounded-md bg-[var(--p-chip)] border border-[var(--p-border)] text-[var(--p-muted)]">
{shortcutText}
</kbd>
)}
</span>
</div>
<div className="mt-2.5 flex justify-between font-mono text-[11px] text-[var(--p-muted)] tracking-wider uppercase">
<span>{value ? `${count} match${count === 1 ? '' : 'es'}` : `${total} tools`}</span>
<span> to browse · enter to open</span>
</div>
</div>
);
}
export function CategoryChips({
active,
onPick,
counts,
categories,
}: {
active: string;
onPick: (cat: string) => void;
counts: Record<string, number>;
categories: { id: string; label: string }[];
}) {
return (
<div className="flex gap-2 flex-wrap">
{categories.map((c) => {
const on = active === c.id;
return (
<button
key={c.id}
onClick={() => onPick(c.id)}
className={`cursor-pointer px-3.5 py-2 rounded-full font-sans text-[13px] font-medium tracking-tight inline-flex items-center gap-2 transition-all ${
on
? 'bg-[var(--p-accent)] text-[var(--p-accent-ink)] border-[var(--p-accent)]'
: 'bg-[var(--p-surface)] text-[var(--p-text)] border-[var(--p-border)]'
} border`}
>
{c.label}
<span
className={`font-mono text-[11px] ${
on ? 'opacity-70' : 'opacity-55'
}`}
>
{counts[c.id] || 0}
</span>
</button>
);
})}
</div>
);
}
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<string, { paper: string; ink: string; neon: string }> = {
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 (
<button
onClick={() => onClick(plugin)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className="relative flex flex-col gap-3.5 px-[18px] py-[20px] rounded-[18px] text-left overflow-hidden cursor-pointer"
style={{
background: bg,
border: `1.5px solid ${lifted ? stickerEdge : 'var(--p-border)'}`,
boxShadow: lifted
? '0 18px 38px -22px var(--p-shadow-soft), 0 1px 0 0 var(--p-shadow-inset)'
: '0 6px 18px -16px var(--p-shadow-soft), 0 1px 0 0 var(--p-shadow-inset)',
transform: `rotate(${lifted ? 0 : t}deg) translateY(${lifted ? -2 : 0}px)`,
transition: 'transform .18s cubic-bezier(.2,.8,.2,1), box-shadow .18s, border-color .18s',
minHeight: 158,
}}
>
<span
style={{
position: 'absolute',
top: -10,
right: 18,
width: 26,
height: 18,
borderRadius: '0 0 6px 6px',
background: dark ? tints.neon : tints.paper,
opacity: dark ? 0.5 : 1,
transform: `rotate(${t * 1.5}deg)`,
boxShadow: '0 2px 6px -2px var(--p-shadow-soft)',
}}
/>
<div
className="inline-flex items-center justify-center w-[52px] h-[52px] rounded-[14px]"
style={{
background: dark
? `color-mix(in oklab, ${tints.neon} 8%, transparent)`
: tints.paper,
border: dark ? `1px solid color-mix(in oklab, ${tints.neon} 20%, transparent)` : 'none',
}}
>
<CategoryIcon category={plugin.manifest.category} color={iconColor} size={24} strokeWidth={2} />
</div>
<div className="flex flex-col gap-1">
<div className="font-sans font-semibold text-base tracking-tight text-[var(--p-text)]">
{plugin.manifest.name}
</div>
<div className="font-sans font-normal text-[13px] text-[var(--p-muted)] leading-snug text-balance">
{plugin.manifest.description}
</div>
</div>
</button>
);
}
export function SectionHeader({ catLabel, catId, n }: { catLabel: string; catId: string; n: number }) {
const tints = TINTS[catId] || TINTS.developer;
return (
<div className="flex items-baseline justify-between pb-1.5">
<div className="inline-flex items-baseline gap-2.5 font-mono text-xs text-[var(--p-muted)] tracking-wider uppercase">
<span
className="inline-block w-2.5 h-2.5 rounded-sm transform translate-y-px -rotate-6"
style={{
background: tints.paper,
border: `1.5px solid color-mix(in oklab, ${tints.ink} 40%, transparent)`,
}}
/>
<span>{catLabel}</span>
<span className="opacity-50">{String(n).padStart(2, '0')}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { Outlet } from "react-router-dom";
import { Header } from "./Header";
export function AppShell() {
return (
<div className="relative w-full min-h-screen bg-[var(--p-bg)] text-[var(--p-text)] font-sans bg-[image:var(--p-paper-noise)] bg-[size:180px_180px]">
<Header />
<main className="px-6 py-8 md:px-14 md:py-9">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { Link, useLocation } from "react-router-dom";
import { useTheme } from "../../app/useTheme";
function PlimiMark({ size = 28 }: { size?: number }) {
return (
<span
style={{
display: "inline-flex",
alignItems: "baseline",
gap: 6,
fontFamily: "Geist, system-ui, sans-serif",
fontWeight: 700,
fontSize: size,
letterSpacing: "-0.04em",
color: "var(--p-text)",
position: "relative",
}}
>
<span style={{ position: "relative" }}>
plimi
<span
style={{
position: "absolute",
left: "74%",
top: "-0.12em",
width: "0.32em",
height: "0.32em",
borderRadius: "50%",
background: "var(--p-accent)",
transform: "rotate(-12deg)",
}}
/>
</span>
</span>
);
}
function ThemeToggle({ dark, onClick }: { dark: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
aria-label="Toggle theme"
className="flex items-center justify-center w-9 h-8 rounded-lg bg-[var(--p-surface)] border border-[var(--p-border)] text-[var(--p-text)] cursor-pointer"
>
{dark ? (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)}
</button>
);
}
export function Header() {
const { dark, toggleTheme } = useTheme();
const location = useLocation();
return (
<div className="flex items-center justify-between px-6 py-4 md:px-8 md:py-5 border-b border-[var(--p-border)] bg-[var(--p-bg)]">
<div className="flex items-center gap-5">
<Link to="/">
<PlimiMark size={26} />
</Link>
</div>
<nav className="hidden md:flex gap-6">
{[
{ label: "Tools", to: "/tools" },
{ label: "How it works", to: "/how-it-works" },
{ label: "Privacy", to: "#" },
{ label: "Changelog", to: "#" },
].map((item) => {
const isActive = item.to !== "#" && (
item.to === "/tools"
? location.pathname.startsWith("/tools")
: location.pathname === item.to
);
return (
<Link
key={item.label}
to={item.to}
className={`font-sans text-[13px] no-underline transition-colors ${
isActive
? "text-[var(--p-text)] font-semibold"
: "text-[var(--p-muted)] hover:text-[var(--p-text)] font-normal"
}`}
>
{item.label}
</Link>
);
})}
</nav>
<div className="flex items-center gap-3">
<ThemeToggle dark={dark} onClick={toggleTheme} />
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { NavLink } from "react-router-dom";
export function Sidebar() {
return (
<aside className="w-64 border-r border-gray-200 bg-gray-50 flex flex-col h-[calc(100vh-4rem)] sticky top-16 overflow-y-auto">
<nav className="p-4 flex flex-col space-y-1">
<NavLink
to="/"
className={({ isActive }) =>
`px-3 py-2 rounded-md font-medium text-sm ${isActive ? "bg-purple-100 text-purple-700" : "text-gray-700 hover:bg-gray-200"}`
}
end
>
Home
</NavLink>
<NavLink
to="/tools"
className={({ isActive }) =>
`px-3 py-2 rounded-md font-medium text-sm ${isActive ? "bg-purple-100 text-purple-700" : "text-gray-700 hover:bg-gray-200"}`
}
>
Tools
</NavLink>
</nav>
</aside>
);
}

View File

@@ -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 (
<Button
type="button"
variant="secondary"
disabled={disabled}
onClick={onClick}
className={className}
>
{label}
</Button>
);
}

View File

@@ -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 (
<div
className="flex min-h-[160px] flex-col justify-center rounded-[14px] border border-[var(--p-border)] p-[24px]"
style={{
background: isDark ? "var(--p-bg)" : "var(--p-surface-2)",
}}
>
<div className="mb-[8px] font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
no input required
</div>
<p className="m-0 font-sans text-[14px] leading-[1.6] text-[var(--p-muted)]">
This tool runs from its options only.
</p>
</div>
);
}
const renderFileField = (
field: Extract<ToolInputFieldDefinition, { type: "files" }>,
index: number
) => {
const fieldKey = field.key ?? "input";
const fieldValue = getInputValue(value, fieldKey);
return (
<div
key={`${fieldKey}-${index}`}
className="flex min-h-[240px] flex-col rounded-[14px] border border-[var(--p-border)] p-[24px] transition-colors"
style={{
background: isDark ? "var(--p-bg)" : "var(--p-surface-2)",
}}
>
<div className="mb-[16px] flex items-center justify-between font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
<span>{field.label ?? "input files"}</span>
</div>
{field.description && (
<p className="mb-[16px] mt-0 font-sans text-[13px] text-[var(--p-muted)]">
{field.description}
</p>
)}
{fieldValue.files && fieldValue.files.length > 0 ? (
<div className="flex flex-1 flex-col gap-[12px]">
<ul className="m-0 flex max-h-[160px] list-none flex-col gap-[8px] overflow-y-auto p-0">
{fieldValue.files.map((file, i) => (
<li
key={i}
className="flex items-center justify-between rounded-[10px] border border-[var(--p-border)] bg-[var(--p-surface)] p-[12px]"
>
<div className="flex items-center gap-[12px] overflow-hidden">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="var(--p-muted)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
</svg>
<span className="truncate font-sans text-[13px] text-[var(--p-text)]">
{file.name}
</span>
</div>
<span className="ml-[12px] whitespace-nowrap font-mono text-[10px] text-[var(--p-muted)]">
{(file.size / 1024 / 1024).toFixed(2)} MB
</span>
</li>
))}
</ul>
<div className="mt-auto flex justify-end pt-[12px]">
<button
onClick={() => handleFilesChange(fieldKey, undefined)}
className="cursor-pointer rounded-lg bg-[var(--p-chip)] px-3 py-1.5 font-sans text-[12px] text-[var(--p-muted)] transition-colors hover:bg-[var(--p-border)]"
>
Clear files
</button>
</div>
</div>
) : (
<div
className="flex flex-1 flex-col items-center justify-center rounded-[10px] border-2 border-dashed transition-colors"
style={{
borderColor: isDark
? `color-mix(in oklab, ${tints?.neon || "var(--p-accent)"} 30%, var(--p-border))`
: `color-mix(in oklab, ${tints?.ink || "var(--p-accent)"} 25%, var(--p-border))`,
background: isDark
? `color-mix(in oklab, ${tints?.neon || "var(--p-accent)"} 4%, transparent)`
: `color-mix(in oklab, ${tints?.paper || "var(--p-bg)"} 40%, transparent)`,
}}
>
<svg
width="44"
height="44"
viewBox="0 0 24 24"
fill="none"
stroke={isDark ? (tints?.neon || "currentColor") : (tints?.ink || "currentColor")}
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className="mb-2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<Dropzone
accept={field.accept?.join(",")}
multiple={field.multiple}
maxFiles={field.maxFiles}
maxSizeMb={field.maxSizeMb}
onFilesDrop={(files) => handleFilesChange(fieldKey, files)}
className="w-full flex-1 border-none"
/>
<div className="mb-[24px] font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
processed in your browser
</div>
</div>
)}
</div>
);
};
const renderTextField = (
field: Extract<ToolInputFieldDefinition, { type: "text" | "text-or-files" }>,
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 (
<div
key={`${fieldKey}-${index}`}
className={`flex flex-col gap-[10px] rounded-[14px] border border-[var(--p-border)] p-[16px] ${
isMultiline ? "min-h-[160px]" : ""
}`}
style={{
background: isDark ? "var(--p-bg)" : "var(--p-surface-2)",
}}
>
<div className="flex items-center justify-between font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
<span>{field.label ?? "input"}</span>
<span>{isMultiline ? "paste / drop / type" : "type"}</span>
</div>
{field.description && (
<p className="m-0 font-sans text-[13px] text-[var(--p-muted)]">
{field.description}
</p>
)}
{isMultiline ? (
<textarea
className="w-full flex-1 resize-none border-none bg-transparent font-mono text-[13px] text-[var(--p-text)] outline-none placeholder:text-[var(--p-muted)] placeholder:opacity-50"
placeholder={placeholder}
value={fieldValue.text || ""}
maxLength={field.type === "text" ? field.maxLength : undefined}
onChange={(e) => handleTextChange(fieldKey, e.target.value)}
spellCheck={false}
rows={rows ?? 6}
/>
) : (
<input
type="text"
className="w-full border-none bg-transparent py-1 font-mono text-[13px] text-[var(--p-text)] outline-none placeholder:text-[var(--p-muted)] placeholder:opacity-50"
placeholder={placeholder}
value={fieldValue.text || ""}
maxLength={field.type === "text" ? field.maxLength : undefined}
onChange={(e) => handleTextChange(fieldKey, e.target.value)}
spellCheck={false}
/>
)}
</div>
);
};
return (
<div className="flex flex-col gap-[16px]">
{fields.map((field, index) =>
field.type === "files"
? renderFileField(field, index)
: renderTextField(field, index)
)}
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-[14px] rounded-[14px] border border-[var(--p-border)] bg-[var(--p-surface-2)] p-[16px] h-full min-h-[240px]">
<div className="font-mono text-[10px] text-[var(--p-muted)] tracking-wider uppercase">
options
</div>
<div className="flex flex-col gap-[14px] flex-1 overflow-y-auto pr-1">
{schema.fields.map((field) => {
const currentVal = value[field.key] ?? field.defaultValue;
return (
<div key={field.key} className="flex flex-col gap-1.5">
<span className="font-sans text-[13px] text-[var(--p-text)]">
{field.label}
</span>
{field.type === "text" && (
<Input
value={String(currentVal ?? "")}
placeholder={field.placeholder}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
/>
)}
{field.type === "number" && (
<Input
type="number"
min={field.min}
max={field.max}
step={field.step}
value={Number(currentVal)}
onChange={(e) => handleFieldChange(field.key, Number(e.target.value))}
/>
)}
{field.type === "boolean" && (
<label className="flex items-center gap-2 rounded-lg border border-[var(--p-border)] bg-[var(--p-surface)] px-3 py-2 text-[13px] text-[var(--p-text)]">
<input
type="checkbox"
checked={currentVal === true}
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
className="h-4 w-4 accent-[var(--p-accent)]"
/>
<span>{currentVal === true ? "Enabled" : "Disabled"}</span>
</label>
)}
{field.type === "select" && field.options && (
<Select
value={String(currentVal)}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
options={field.options}
/>
)}
{field.type === "slider" && (
<Slider
min={field.min}
max={field.max}
step={field.step}
value={Number(currentVal)}
onChange={(e) => handleFieldChange(field.key, Number(e.target.value))}
/>
)}
</div>
);
})}
</div>
<div className="mt-auto pt-[12px] border-t border-dashed border-[var(--p-border)] flex items-center justify-between">
<span className="font-mono text-[10px] text-[var(--p-muted)]">
engine
</span>
<span
className="font-mono text-[11px]"
style={{ color: isDark ? tints?.neon : tints?.ink }}
>
{isWasm ? 'wasm' : 'js worker'}
</span>
</div>
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-3 p-4 rounded-[14px] border border-[var(--p-border)] bg-[var(--p-surface-2)]">
<div className="flex items-center justify-between">
<span className="font-sans text-[13px] text-[var(--p-text)] font-medium">
{message || "Processing..."}
</span>
<span className="font-mono text-[11px] text-[var(--p-muted)]">
{Math.round(percentage)}%
</span>
</div>
<div className="h-1.5 w-full bg-[var(--p-border)] rounded-full overflow-hidden">
<div
className="h-full transition-all duration-300 ease-out"
style={{
width: `${percentage}%`,
background: isDark ? (tints?.neon || 'var(--p-accent)') : (tints?.ink || 'var(--p-accent)')
}}
/>
</div>
{onCancel && (
<div className="flex justify-end mt-1">
<button
onClick={onCancel}
className="px-3 py-1.5 rounded-lg bg-[var(--p-chip)] text-[var(--p-muted)] font-sans text-xs hover:text-[var(--p-text)] transition-colors cursor-pointer"
>
Cancel
</button>
</div>
)}
</div>
);
}

View File

@@ -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 (
<div
className="flex flex-col gap-[10px] rounded-[14px] border border-[var(--p-border)] p-[16px] min-h-[240px]"
style={{
background: isDark ? `color-mix(in oklab, ${tints?.neon || 'var(--p-accent)'} 4%, transparent)` : 'var(--p-surface-2)',
}}
>
<div className="flex items-center justify-between font-mono text-[10px] text-[var(--p-muted)] tracking-wider uppercase">
<span>output</span>
{result.value && (
<button
onClick={() => handleCopy(result.value)}
className="px-2 py-1 rounded-md bg-[var(--p-chip)] text-[var(--p-muted)] cursor-pointer hover:bg-[var(--p-border)] transition-colors"
>
{copied ? "Copied!" : "Copy"}
</button>
)}
</div>
<div className="flex-1 overflow-auto font-mono text-[13px] text-[var(--p-text)] whitespace-pre-wrap break-words">
{result.value || ""}
</div>
</div>
);
}
if (result.type === "json") {
const value = result.value;
const isObject = typeof value === "object" && value !== null && !Array.isArray(value);
const valObj = isObject ? (value as Record<string, unknown>) : 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 (
<div className="flex flex-col gap-[16px]">
{primaryVal !== null && (
<div
className="flex flex-col gap-[8px] p-[20px] rounded-[14px] border border-[var(--p-border)] bg-[var(--p-surface)] relative group/primary overflow-hidden"
style={{
boxShadow: 'inset 0 1px 0 var(--p-shadow-inset)',
}}
>
<div className="font-mono text-[10px] text-[var(--p-accent)] tracking-wider uppercase font-semibold">
Primary Result
</div>
<div className="flex items-center justify-between gap-[16px]">
<div className="font-mono text-[20px] font-bold text-[var(--p-text)] break-all select-all">
{primaryVal}
</div>
<button
onClick={() => handleCopy(primaryVal)}
className="px-3 py-1.5 rounded-lg bg-[var(--p-accent)] text-[var(--p-accent-ink)] font-sans text-[12px] font-semibold cursor-pointer hover:opacity-90 transition-opacity whitespace-nowrap"
>
Copy
</button>
</div>
</div>
)}
<div
className="flex flex-col gap-[10px] rounded-[14px] border border-[var(--p-border)] p-[16px] min-h-[160px]"
style={{
background: isDark ? `color-mix(in oklab, ${tints?.neon || 'var(--p-accent)'} 4%, transparent)` : 'var(--p-surface-2)',
}}
>
<div className="flex items-center justify-between font-mono text-[10px] text-[var(--p-muted)] tracking-wider uppercase mb-2">
<span>{primaryVal !== null ? "All Conversions / Details" : "output"}</span>
{!primaryVal && (
<button
onClick={() => handleCopy(JSON.stringify(value, null, 2))}
className="px-2 py-1 rounded-md bg-[var(--p-chip)] text-[var(--p-muted)] cursor-pointer hover:bg-[var(--p-border)] transition-colors"
>
{copied ? "Copied!" : "Copy"}
</button>
)}
</div>
<div className="flex-1 overflow-auto font-mono text-[13px] text-[var(--p-text)] whitespace-pre-wrap break-words">
{isObject ? (
<div className="grid grid-cols-1 gap-[10px]">
{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 (
<div
key={key}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-[8px] p-[12px] rounded-[10px] bg-[var(--p-surface)] border border-[var(--p-border)] group/field hover:border-[var(--p-muted)]/30 transition-all"
>
<div className="flex flex-col gap-[2px] flex-grow min-w-0">
<span className="text-[var(--p-muted)] text-[9px] uppercase tracking-wider font-bold">{key}</span>
{isValObject ? (
<div className="pl-2 border-l border-[var(--p-border)] mt-1">
{renderJsonValue(val)}
</div>
) : (
<span className="text-[var(--p-text)] text-[13px] font-mono break-all">{valStr}</span>
)}
</div>
{!isValObject && (
<button
onClick={() => handleCopy(valStr)}
className="sm:opacity-0 group-hover/field:opacity-100 focus-visible:opacity-100 self-end sm:self-auto px-2 py-1 rounded bg-[var(--p-chip)] text-[var(--p-muted)] text-[11px] cursor-pointer hover:bg-[var(--p-border)] transition-all whitespace-nowrap"
>
Copy
</button>
)}
</div>
);
})}
</div>
) : (
renderJsonValue(value)
)}
</div>
</div>
</div>
);
}
if (result.type === "files" && result.files) {
return (
<div
className="flex flex-col gap-[10px] rounded-[14px] border border-[var(--p-border)] p-[16px]"
style={{
background: isDark ? `color-mix(in oklab, ${tints?.neon || 'var(--p-accent)'} 4%, transparent)` : 'var(--p-surface-2)',
}}
>
<div className="font-mono text-[10px] text-[var(--p-muted)] tracking-wider uppercase mb-2">
output files
</div>
<ul className="flex flex-col gap-2 m-0 p-0 list-none">
{result.files.map((file, i) => (
<li key={i} className="flex items-center justify-between p-3 rounded-xl bg-[var(--p-surface)] border border-[var(--p-border)]">
<div className="flex flex-col gap-1">
<span className="font-sans text-[13px] text-[var(--p-text)] font-medium">
{file.name}
</span>
<span className="font-mono text-[10px] text-[var(--p-muted)]">
{file.sizeAfter ? `${(file.sizeAfter / 1024).toFixed(1)} KB` : 'Ready'}
</span>
</div>
<button
onClick={() => downloadBlob(file.blob, file.name)}
className="px-3 py-1.5 rounded-lg bg-[var(--p-accent)] text-[var(--p-accent-ink)] font-sans text-xs font-semibold cursor-pointer"
>
Download
</button>
</li>
))}
</ul>
</div>
);
}
if (result.type === "table" && result.columns && result.rows) {
return (
<div
className="flex flex-col gap-[10px] rounded-[14px] border border-[var(--p-border)] p-[16px] overflow-hidden"
style={{
background: isDark ? `color-mix(in oklab, ${tints?.neon || 'var(--p-accent)'} 4%, transparent)` : 'var(--p-surface-2)',
}}
>
<div className="flex items-center justify-between font-mono text-[10px] text-[var(--p-muted)] tracking-wider uppercase">
<span>output table</span>
<button
onClick={() => {
const csv = [
result.columns.join(","),
...result.rows.map(row => row.map(cell => `"${String(cell ?? "").replace(/"/g, '""')}"`).join(","))
].join("\n");
handleCopy(csv);
}}
className="px-2 py-1 rounded-md bg-[var(--p-chip)] text-[var(--p-muted)] cursor-pointer hover:bg-[var(--p-border)] transition-colors"
>
Copy CSV
</button>
</div>
<div className="overflow-x-auto w-full max-h-[400px] mt-2">
<table className="w-full border-collapse text-left font-sans text-[13px] text-[var(--p-text)]">
<thead>
<tr className="border-b border-[var(--p-border)]">
{result.columns.map((col, i) => (
<th key={i} className="pb-[8px] pr-[12px] font-semibold text-[var(--p-muted)] uppercase tracking-wider text-[10px] font-mono">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.map((row, rIndex) => (
<tr
key={rIndex}
className="border-b border-[var(--p-border)] last:border-none hover:bg-[var(--p-chip)]/30 transition-colors"
>
{row.map((cell, cIndex) => (
<td key={cIndex} className="py-[10px] pr-[12px] font-mono text-[12px] text-[var(--p-text)]">
{cell === null ? (
<span className="text-[var(--p-muted)] italic">null</span>
) : (
String(cell)
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
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 (
<div className="flex flex-col gap-1">
{value.map((item, i) => (
<div key={i} className="pl-3 border-l-2 border-[var(--p-border)]">
{renderJsonValue(item)}
</div>
))}
</div>
);
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return "{}";
return (
<div className="flex flex-col gap-1.5">
{entries.map(([key, val]) => (
<div key={key} className="flex flex-col gap-0.5">
<span className="text-[var(--p-muted)] text-[11px] uppercase tracking-wider">{key}</span>
<div className="pl-3">
{typeof val === "object" && val !== null ? (
renderJsonValue(val)
) : (
<span className="text-[var(--p-text)]">{String(val ?? "null")}</span>
)}
</div>
</div>
))}
</div>
);
}
return String(value);
}

View File

@@ -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<string, { paper: string; ink: string; neon: string }> = {
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 (
<div className="flex items-center justify-between gap-3 border-b border-[var(--p-border)] bg-[var(--p-surface)] px-[18px] py-[14px] md:px-[24px] md:py-[20px]">
<div className="flex min-w-0 items-center gap-[14px]">
<span
className="inline-flex h-[36px] w-[36px] shrink-0 items-center justify-center rounded-[12px] md:h-[44px] md:w-[44px]"
style={{
background: dark
? `color-mix(in oklab, ${tints.neon} 12%, transparent)`
: tints.paper,
}}
>
<CategoryIcon
category={plugin.manifest.category}
color={dark ? tints.neon : tints.ink}
size={24}
strokeWidth={2}
aria-hidden="true"
/>
</span>
<div className="min-w-0">
<div className="truncate font-sans text-[15px] font-semibold tracking-tight text-[var(--p-text)] md:text-[18px]">
{plugin.manifest.name}
</div>
<div className="truncate font-mono text-[11px] uppercase tracking-wider text-[var(--p-muted)]">
{plugin.manifest.category} / {permissions.join(" / ")}
</div>
</div>
</div>
{onClose && (
<button
onClick={onClose}
className="shrink-0 rounded-lg bg-[var(--p-chip)] px-3 py-2 font-sans text-[13px] text-[var(--p-muted)] cursor-pointer"
>
Close
</button>
)}
</div>
);
}
export function ToolShell({
plugin,
onClose,
dark,
}: {
plugin: UnknownPlimiPlugin;
onClose?: () => void;
dark?: boolean;
}) {
const [input, setInput] = useState<ToolInput>({});
const [options, setOptions] = useState<ToolOptionsValue>({});
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 (
<>
<ToolHeader plugin={plugin} dark={isDark} onClose={onClose} />
<div className="min-h-0 flex-1 overflow-y-auto bg-[var(--p-surface)]">
<Suspense
fallback={
<div className="p-6 font-sans text-sm text-[var(--p-muted)]">
Loading tool...
</div>
}
>
<CustomUi plugin={plugin} dark={isDark} examples={examples} />
</Suspense>
</div>
</>
);
}
return (
<>
<ToolHeader plugin={plugin} dark={isDark} onClose={onClose} />
<div className="grid min-h-0 flex-1 grid-cols-1 gap-[16px] overflow-y-auto bg-[var(--p-surface)] p-[18px] md:grid-cols-[1fr_280px] md:gap-[24px] md:p-[28px]">
<div className="flex flex-col gap-[24px]">
<ToolInputPanel
definition={plugin.manifest.input}
value={input}
onChange={setInput}
dark={isDark}
tints={tints}
/>
{isExecuting && (
<ToolProgress
percentage={progress.percentage ?? 0}
message={progress.message}
onCancel={plugin.capabilities?.cancelable ? cancel : undefined}
dark={isDark}
tints={tints}
/>
)}
{error && (
<div className="rounded-xl border border-red-200 bg-red-100 p-4 font-sans text-sm text-red-800">
<strong>Error:</strong> {error}
</div>
)}
{result && !isExecuting && (
<ToolResultPanel result={result} dark={isDark} tints={tints} />
)}
</div>
<div className="flex flex-col gap-[24px]">
{plugin.optionsSchema && (
<ToolOptionsPanel
schema={plugin.optionsSchema}
value={options}
onChange={setOptions}
dark={isDark}
tints={tints}
toolCategory={plugin.manifest.category}
/>
)}
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t border-[var(--p-border)] bg-[var(--p-surface)] px-[18px] py-[12px] md:px-[24px] md:py-[14px]">
<span className="font-mono text-[11px] text-[var(--p-muted)]">
ready when you are
</span>
<div className="flex shrink-0 items-center gap-2">
{primaryExample && (
<ExampleActionButton
label={primaryExample.label}
disabled={isExecuting}
onClick={handleApplyExample}
/>
)}
<button
onClick={handleRun}
disabled={isExecuting}
className="cursor-pointer rounded-[10px] bg-[var(--p-accent)] px-[18px] py-[10px] font-sans text-[13px] font-semibold tracking-tight text-[var(--p-accent-ink)] transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
>
{isExecuting ? "Running..." : `Run ${plugin.manifest.name.split(" ")[0]} ->`}
</button>
</div>
</div>
</>
);
}

View File

@@ -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<TOptions>(plugin: PlimiPlugin<TOptions>) {
const [result, setResult] = useState<ToolResult | null>(null);
const [isExecuting, setIsExecuting] = useState(false);
const [progress, setProgress] = useState<ToolProgress>({
percentage: 0,
message: "",
});
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(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,
};
}

View File

@@ -0,0 +1,26 @@
import React from "react";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
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 (
<button className={`${baseStyles} ${variantStyles} ${className}`} {...props} />
);
}

View File

@@ -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 (
<div className={`flex flex-col gap-3 p-[18px] md:p-[24px] rounded-[14px] md:rounded-[18px] border border-[var(--p-border)] bg-[var(--p-surface-2)] ${className}`}>
{title && (
<h3 className="font-sans font-semibold text-[15px] md:text-[16px] text-[var(--p-text)] tracking-tight m-0">
{title}
</h3>
)}
<div className="flex-1 min-h-0">
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import React from "react";
interface CategoryIconProps extends React.SVGProps<SVGSVGElement> {
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 (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
<line x1="14" y1="4" x2="10" y2="20" />
</svg>
);
case "image":
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
);
case "pdf":
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M10 9H8" />
<path d="M16 13H8" />
<path d="M16 17H8" />
</svg>
);
case "text":
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<polyline points="4 7 4 4 20 4 20 7" />
<line x1="9" y1="20" x2="15" y2="20" />
<line x1="12" y1="4" x2="12" y2="20" />
</svg>
);
case "crypto":
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
case "privacy":
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
);
default:
// Fallback to the original triangle shape
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M12 2L2 22h20L12 2z" />
</svg>
);
}
}

View File

@@ -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<string | null>(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<HTMLInputElement>) => {
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 (
<div
className={`relative flex flex-col items-center justify-center p-6 border-2 border-dashed rounded-[14px] min-h-[140px] transition-colors cursor-pointer text-center ${
isDragActive
? "border-[var(--p-accent)] bg-opacity-10 bg-[var(--p-accent)]"
: "border-[var(--p-border)] hover:bg-[var(--p-surface)]"
} ${className}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<input
type="file"
accept={accept}
multiple={multiple}
onChange={handleChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<div className="flex flex-col items-center gap-2 pointer-events-none">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--p-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span className="font-sans text-[13px] font-medium text-[var(--p-text)]">
{isDragActive ? "Drop files now" : "Drop files here or click to browse"}
</span>
{error && (
<span className="font-sans text-[12px] text-red-600">
{error}
</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import React from "react";
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
}
export function Input({ label, className = "", ...props }: InputProps) {
return (
<div className={`flex flex-col gap-1.5 ${className}`}>
{label && (
<label className="font-sans text-[13px] text-[var(--p-text)]">
{label}
</label>
)}
<input
className="px-3 py-2 bg-[var(--p-surface)] border border-[var(--p-border)] rounded-lg text-[13px] text-[var(--p-text)] placeholder:text-[var(--p-muted)] font-sans focus:outline-none focus:border-[var(--p-accent)] transition-colors w-full"
{...props}
/>
</div>
);
}
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
}
export function Textarea({ label, className = "", ...props }: TextareaProps) {
return (
<div className={`flex flex-col gap-1.5 ${className}`}>
{label && (
<label className="font-sans text-[13px] text-[var(--p-text)]">
{label}
</label>
)}
<textarea
className="px-3 py-2 bg-[var(--p-surface)] border border-[var(--p-border)] rounded-lg text-[13px] text-[var(--p-text)] placeholder:text-[var(--p-muted)] font-mono focus:outline-none focus:border-[var(--p-accent)] transition-colors w-full"
{...props}
/>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import React from "react";
interface SelectOption {
label: string;
value: string;
}
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
options: SelectOption[];
}
export function Select({ label, options, className = "", ...props }: SelectProps) {
return (
<div className={`flex flex-col gap-1.5 ${className}`}>
{label && (
<label className="font-sans text-[13px] text-[var(--p-text)]">
{label}
</label>
)}
<select
className="px-3 py-2 bg-[var(--p-chip)] border border-[var(--p-border)] rounded-lg text-[13px] text-[var(--p-text)] font-sans focus:outline-none focus:border-[var(--p-accent)] transition-colors w-full cursor-pointer appearance-none"
{...props}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import React from "react";
interface SliderProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
value: number;
}
export function Slider({ label, className = "", ...props }: SliderProps) {
return (
<div className={`flex flex-col gap-1.5 ${className}`}>
{label && (
<div className="flex justify-between items-center">
<label className="font-sans text-[13px] text-[var(--p-text)]">
{label}
</label>
<span className="font-mono text-[11px] text-[var(--p-muted)]">
{props.value}
</span>
</div>
)}
<input
type="range"
className="w-full h-1.5 bg-[var(--p-border)] rounded-full appearance-none cursor-pointer outline-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[var(--p-accent)]"
{...props}
/>
</div>
);
}

View File

@@ -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<string, ToolInputValue>;
}
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,
},
};
}

View File

@@ -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<Array<string | number | boolean | null>>;
};

View File

@@ -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<ToolOptionsValue>[] {
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 }] : [];
}

View File

@@ -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<UnknownPlimiPlugin | undefined> {
return getPluginById(id);
}
export async function loadAllPlugins(): Promise<UnknownPlimiPlugin[]> {
return getAllPlugins();
}

View File

@@ -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;
}

View File

@@ -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<TOptions>(plugin: PlimiPlugin<TOptions>): 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
);
}

View File

@@ -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<TOptions>(
plugin: PlimiPlugin<TOptions>,
input: ToolInput,
options: TOptions,
context: ToolContext
): Promise<ToolResult> {
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<TOptions>(
plugin: PlimiPlugin<TOptions>,
input: ToolInput,
options: TOptions,
context: ToolContext
): Promise<ToolResult> {
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<ToolWorkerResponse>) => {
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<TOptions> = {
type: "run",
id: jobId,
input,
options,
};
worker.postMessage(request);
});
}

View File

@@ -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<string, ToolOptionValue>;
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<TOptions = unknown> {
label?: string;
description?: string;
input: ToolInput;
options?: Partial<TOptions>;
}
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<TOptions = unknown> = (
input: ToolInput,
options: TOptions,
context: ToolContext
) => Promise<ToolResult>;
export interface ToolUiProps<TOptions = unknown> {
plugin: PlimiPlugin<TOptions>;
dark?: boolean;
examples?: ToolExample<TOptions>[];
}
export interface PlimiPlugin<TOptions = unknown> {
manifest: ToolManifest;
optionsSchema?: ToolOptionsSchema;
examples?: ToolExample<TOptions>[];
capabilities?: ToolCapabilities;
permissions?: ToolPermissions;
run?: ToolRunner<TOptions>;
worker?: () => Worker;
customUi?: React.LazyExoticComponent<
React.ComponentType<ToolUiProps<TOptions>>
>;
}
export type UnknownPlimiPlugin = Omit<
PlimiPlugin<never>,
"run" | "customUi"
> & {
run?: ToolRunner<ToolOptionsValue>;
examples?: ToolExample<ToolOptionsValue>[];
customUi?: React.LazyExoticComponent<
React.ComponentType<ToolUiProps<ToolOptionsValue>>
>;
};

View File

@@ -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<ToolInputFieldDefinition, { type: "text" }>, 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<ToolInputFieldDefinition, { type: "files" | "text-or-files" }>,
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<string, unknown>)
: {};
return schema.fields.reduce<ToolOptionsValue>((acc, field) => {
acc[field.key] = normalizeOptionField(field, raw[field.key]);
return acc;
}, {});
}

View File

@@ -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<TOptions = unknown> {
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;
};

127
src/index.css Normal file
View File

@@ -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,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' seed='4'/><feColorMatrix values='0 0 0 0 0.42 0 0 0 0 0.34 0 0 0 0 0.22 0 0 0 0.06 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
}
.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,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' seed='4'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.03 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
}
/* 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);
}

13
src/main.tsx Normal file
View File

@@ -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(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>
);

5
src/pages/HomePage.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { Navigate } from "react-router-dom";
export function HomePage() {
return <Navigate to="/tools" replace />;
}

View File

@@ -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: (
<path d="M12 5v14M5 12h14" />
),
},
{
label: "Run",
title: "Process locally",
copy: "The plugin runs in your browser using JavaScript, workers, Canvas, or PDF libraries.",
icon: (
<>
<path d="M12 3v3M12 18v3M3 12h3M18 12h3" />
<circle cx="12" cy="12" r="4" />
</>
),
},
{
label: "Output",
title: "Copy or download",
copy: "Results appear immediately in the page. Files stay on the device unless you export them.",
icon: (
<>
<path d="M12 4v11" />
<path d="m7 10 5 5 5-5" />
<path d="M5 20h14" />
</>
),
},
];
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 (
<span className="inline-flex h-10 w-10 items-center justify-center rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] text-[var(--p-text)]">
<svg
width="19"
height="19"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{children}
</svg>
</span>
);
}
export function HowItWorksPage() {
return (
<div className="mx-auto flex max-w-[1120px] flex-col gap-10 pb-16 animate-plimi-slide">
<section className="grid grid-cols-1 gap-8 md:grid-cols-[0.95fr_1.05fr] md:items-end">
<div className="flex flex-col gap-4">
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
browser-first architecture
</span>
<h1 className="m-0 max-w-[620px] font-sans text-4xl font-bold leading-[1.02] tracking-tight text-[var(--p-text)] text-balance md:text-[56px]">
Your files stay where they started.
</h1>
<p className="m-0 max-w-[560px] text-base leading-relaxed text-[var(--p-muted)] text-pretty">
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.
</p>
</div>
<div
className="rounded-[20px] border border-[var(--p-border)] bg-[var(--p-surface)] p-4 md:p-5"
style={{
boxShadow:
"0 18px 38px -28px var(--p-shadow-soft), 0 1px 0 0 var(--p-shadow-inset)",
}}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3 md:grid-cols-1 lg:grid-cols-3">
{workflow.map((step, index) => (
<div
key={step.label}
className="relative flex min-h-[170px] flex-col gap-4 rounded-[16px] border border-[var(--p-border)] bg-[var(--p-surface-2)] p-5"
>
<div className="flex items-center justify-between gap-3">
<IconFrame>{step.icon}</IconFrame>
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
{String(index + 1).padStart(2, "0")}
</span>
</div>
<div className="flex flex-col gap-1.5">
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-accent)]">
{step.label}
</span>
<h2 className="m-0 font-sans text-[17px] font-semibold tracking-tight text-[var(--p-text)]">
{step.title}
</h2>
<p className="m-0 text-sm leading-relaxed text-[var(--p-muted)]">{step.copy}</p>
</div>
</div>
))}
</div>
</div>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-4">
{principles.map((item) => (
<article
key={item.title}
className="flex min-h-[150px] flex-col justify-between gap-5 rounded-[16px] border border-[var(--p-border)] bg-[var(--p-surface)] p-5"
>
<h2 className="m-0 font-sans text-[16px] font-semibold tracking-tight text-[var(--p-text)]">
{item.title}
</h2>
<p className="m-0 text-sm leading-relaxed text-[var(--p-muted)]">{item.copy}</p>
</article>
))}
</section>
<section className="grid grid-cols-1 gap-6 rounded-[20px] border border-[var(--p-border)] bg-[var(--p-surface-2)] p-5 md:grid-cols-[0.75fr_1.25fr] md:p-7">
<div className="flex flex-col gap-3">
<span className="font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--p-muted)]">
plugin model
</span>
<h2 className="m-0 font-sans text-2xl font-bold tracking-tight text-[var(--p-text)]">
One shell, many tools.
</h2>
<p className="m-0 text-sm leading-relaxed text-[var(--p-muted)]">
Plimi keeps the application shell simple. Each tool contributes a compact manifest and
a runner, so new utilities can share the same dependable interface.
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{layers.map((layer, index) => (
<div
key={layer.name}
className="flex items-start gap-4 rounded-[14px] border border-[var(--p-border)] bg-[var(--p-surface)] p-4"
>
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[var(--p-chip)] font-mono text-[11px] text-[var(--p-muted)]">
{index + 1}
</span>
<div className="flex flex-col gap-1">
<h3 className="m-0 font-sans text-sm font-semibold text-[var(--p-text)]">
{layer.name}
</h3>
<p className="m-0 text-[13px] leading-relaxed text-[var(--p-muted)]">
{layer.detail}
</p>
</div>
</div>
))}
</div>
</section>
<section className="flex flex-col items-start justify-between gap-4 border-t border-[var(--p-border)] pt-7 md:flex-row md:items-center">
<div className="flex flex-col gap-1">
<h2 className="m-0 font-sans text-xl font-semibold tracking-tight text-[var(--p-text)]">
Ready to run something?
</h2>
<p className="m-0 text-sm text-[var(--p-muted)]">
Pick a utility and the same local workflow applies.
</p>
</div>
<Link
to="/tools"
className="inline-flex items-center justify-center rounded-[10px] bg-[var(--p-accent)] px-5 py-3 font-sans text-sm font-semibold tracking-tight text-[var(--p-accent-ink)] no-underline transition-[filter] hover:brightness-110"
>
Browse tools
</Link>
</section>
</div>
);
}

View File

@@ -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 (
<div className="p-8">
<h2 className="text-xl font-semibold mb-4 text-[var(--p-text)]">Tool not found</h2>
<button onClick={() => navigate("/tools")} className="text-blue-500 hover:underline">
Return to directory
</button>
</div>
);
}
const handleClose = () => {
navigate("/tools");
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-0 md:p-10 animate-plimi-fade"
style={{
background: 'color-mix(in oklab, var(--p-bg) 70%, transparent)',
backdropFilter: 'blur(8px)',
}}
>
<div
onClick={(e) => 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)',
}}
>
<ToolShell plugin={plugin} onClose={handleClose} dark={dark} />
</div>
</div>
);
}

153
src/pages/ToolsPage.tsx Normal file
View File

@@ -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<string, number> = { 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<string, typeof pluginRegistry> = {};
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 (
<div className="flex flex-col gap-7 max-w-[1200px] mx-auto pb-10">
<div className="grid grid-cols-1 md:grid-cols-[1.1fr_1fr] gap-12 items-end">
<div className="flex flex-col gap-3.5">
<span className="font-mono text-[11px] text-[var(--p-muted)] tracking-[0.12em] uppercase">
your digital pencil case
</span>
<h1 className="m-0 font-sans text-4xl md:text-[56px] font-bold tracking-tight leading-[1.02] text-[var(--p-text)] text-balance">
Small tools.<br />
<span className="text-[var(--p-muted)]">Big trust.</span>
</h1>
<p className="m-0 max-w-[480px] text-base leading-relaxed text-[var(--p-muted)] text-pretty">
{pluginRegistry.length} utilities for files, text and code running entirely in your browser. No upload. No account. No server.
</p>
</div>
<PlimiSearch
value={q}
onChange={(nextQuery) => {
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]);
}
}}
/>
</div>
<CategoryChips
active={cat}
onPick={(nextCategory) => {
setCat(nextCategory);
setFocusedIndex(0);
}}
counts={counts}
categories={PLIMI_CATEGORIES}
/>
{grouped ? (
<div className="flex flex-col gap-7 mt-4">
{grouped.map(({ cat: c, tools }) => (
<div key={c.id} className="flex flex-col gap-3.5">
<SectionHeader catLabel={c.label} catId={c.id} n={tools.length} />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{tools.map((t) => {
const flatIdx = filtered.indexOf(t);
return (
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} />
);
})}
</div>
</div>
))}
</div>
) : filtered.length === 0 ? (
<div className="py-20 text-center text-[var(--p-muted)] font-sans">
No tools found matching "{q}".
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mt-4">
{filtered.map((t) => {
const flatIdx = filtered.indexOf(t);
return (
<ToolTile key={t.manifest.id} plugin={t} onClick={handleOpenTool} focused={flatIdx === safeFocusedIndex} dark={dark} />
);
})}
</div>
)}
</div>
);
}

43
src/tools/base64/index.ts Normal file
View File

@@ -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<Base64Options> = {
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,
};

View File

@@ -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.");
});
});

37
src/tools/base64/run.ts Normal file
View File

@@ -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<ToolResult> {
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 });
}
}

View File

@@ -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<ColorConverterOptions> = {
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,
};

View File

@@ -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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).value;
const channels = value.channels as Record<string, unknown>;
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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).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");
});
});

View File

@@ -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<ToolResult> {
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 },
},
};
}

View File

@@ -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<CsvToolsOptions> = {
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,
};

View File

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

199
src/tools/csv-tools/run.ts Normal file
View File

@@ -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<ToolResult> {
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<string, string>[] = [];
for (let i = 1; i < parsedRows.length; i++) {
const row = parsedRows[i];
const obj: Record<string, string> = {};
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<string>();
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<string, unknown>[]).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.");
}
}
}

View File

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

View File

@@ -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.");
});
});

View File

@@ -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<HTMLImageElement> {
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<Blob> {
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<ToolResult> {
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,
};
}

View File

@@ -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<FileChecksumVerifierOptions> = {
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,
};

View File

@@ -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.");
});
});

View File

@@ -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<ToolResult> {
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,
};
}

View File

@@ -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<HashOptions> = {
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,
};

View File

@@ -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<ToolResult> {
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,
};
}

View File

@@ -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<HtmlEntityOptions> = {
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: '<div class="hero">Hello & "World"</div>',
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,
};

View File

@@ -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: '<div class="test">Hello & World</div>' },
{ mode: "encode" },
mockContext
);
expect(result.type).toBe("text");
const value = (result as { type: "text"; value: string }).value;
expect(value).toBe("&lt;div class=&quot;test&quot;&gt;Hello &amp; World&lt;/div&gt;");
});
it("should decode basic HTML entities", async () => {
const result = await runHtmlEntity(
{ text: "&lt;div&gt;Hello &amp; World&lt;/div&gt;" },
{ mode: "decode" },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toBe("<div>Hello & World</div>");
});
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("&euro;");
expect(value).toContain("&copy;");
});
it("should decode special symbol entities", async () => {
const result = await runHtmlEntity(
{ text: "&copy; 2024 &mdash; 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: "&#65;&#66;&#67;" },
{ 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: "&#x41;&#x42;&#x43;" },
{ 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("&#39;");
});
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 = '<p>Hello & "World"</p>';
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);
});
});

View File

@@ -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<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
"©": "&copy;",
"®": "&reg;",
"™": "&trade;",
"—": "&mdash;",
"": "&ndash;",
"«": "&laquo;",
"»": "&raquo;",
"°": "&deg;",
"±": "&plusmn;",
"×": "&times;",
"÷": "&divide;",
"€": "&euro;",
"£": "&pound;",
"¥": "&yen;",
"¢": "&cent;",
"§": "&sect;",
"¶": "&para;",
"•": "&bull;",
"…": "&hellip;",
};
const REVERSE_ENTITY_MAP: Record<string, string> = {};
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(/&#x([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<ToolResult> {
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,
};
}

View File

@@ -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<T extends HTMLElement>() {
const ref = useRef<T | null>(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<ImageEditorOptions>) {
const canvasElRef = useRef<HTMLCanvasElement | null>(null);
const canvasRef = useRef<Canvas | null>(null);
const historyRef = useRef<string[]>([]);
const historyIndexRef = useRef(-1);
const restoringRef = useRef(false);
const sourceFileRef = useRef<File | undefined>(undefined);
const isPanningRef = useRef(false);
const panStartRef = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
const { ref: workspaceRef, size: workspaceSize } = useElementSize<HTMLDivElement>();
const [sourceFile, setSourceFile] = useState<File | undefined>();
const [canvasReady, setCanvasReady] = useState(false);
const [canvasSize, setCanvasSize] = useState({
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
});
const [activeObject, setActiveObject] = useState<FabricObject | undefined>();
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [layerRows, setLayerRows] = useState<LayerRow[]>([]);
const [exportFormat, setExportFormat] =
useState<ImageEditorOptions["format"]>("image/png");
const [quality, setQuality] = useState(92);
const [fontFamily, setFontFamily] = useState(FONT_OPTIONS[0].value);
const [state, setState] = useState<EditorState>({
selectedType: "None",
fill: "#ffffff",
stroke: "#1a1714",
fontSize: 48,
brushWidth: 8,
mode: "select",
zoom: 1,
});
const { run, result, isExecuting, error } = useToolExecution(plugin);
const refreshHistoryControls = useCallback(() => {
setCanUndo(historyIndexRef.current > 0);
setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
}, []);
const refreshLayerRows = useCallback(() => {
const canvas = canvasRef.current;
const rows = (canvas?.getObjects() ?? [])
.filter((object) => object.selectable !== false)
.map((object, index) => ({
object,
id: `${object.type}-${index}`,
label: `${objectType(object)} ${index + 1}`,
}))
.reverse();
setLayerRows(rows);
}, []);
const updateActiveState = useCallback(() => {
const canvas = canvasRef.current;
const object = canvas?.getActiveObject();
setActiveObject(object);
setState((prev) => ({
...prev,
selectedType: objectType(object),
fill: typeof object?.get("fill") === "string" ? String(object.get("fill")) : prev.fill,
stroke: typeof object?.get("stroke") === "string" ? String(object.get("stroke")) : prev.stroke,
fontSize: typeof object?.get("fontSize") === "number" ? Number(object.get("fontSize")) : prev.fontSize,
}));
refreshLayerRows();
}, [refreshLayerRows]);
const pushHistory = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas || restoringRef.current) return;
const snapshot = JSON.stringify(canvas.toJSON());
const current = historyRef.current[historyIndexRef.current];
if (snapshot === current) return;
const nextHistory = historyRef.current.slice(0, historyIndexRef.current + 1);
nextHistory.push(snapshot);
if (nextHistory.length > HISTORY_LIMIT) {
nextHistory.shift();
}
historyRef.current = nextHistory;
historyIndexRef.current = nextHistory.length - 1;
refreshHistoryControls();
refreshLayerRows();
}, [refreshHistoryControls, refreshLayerRows]);
const loadHistory = useCallback(async (index: number) => {
const canvas = canvasRef.current;
const snapshot = historyRef.current[index];
if (!canvas || !snapshot) return;
restoringRef.current = true;
await canvas.loadFromJSON(snapshot);
canvas.renderAll();
restoringRef.current = false;
historyIndexRef.current = index;
updateActiveState();
refreshHistoryControls();
refreshLayerRows();
}, [refreshHistoryControls, refreshLayerRows, updateActiveState]);
const applyDisplayZoom = useCallback((
zoom: number,
size = canvasSize
) => {
const canvas = canvasRef.current;
if (!canvas) return;
const nextZoom = Math.max(0.12, Math.min(3, zoom));
const displayWidth = Math.max(1, Math.round(size.width * nextZoom));
const displayHeight = Math.max(1, Math.round(size.height * nextZoom));
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
canvas.setDimensions({
width: `${displayWidth}px`,
height: `${displayHeight}px`,
}, {
cssOnly: true,
});
canvas.calcOffset();
setState((prev) => ({ ...prev, zoom: nextZoom }));
canvas.requestRenderAll();
}, [canvasSize]);
const fitCanvasToWorkspace = useCallback(() => {
if (workspaceSize.width === 0 || workspaceSize.height === 0) return;
const zoom = Math.max(0.12, Math.min(
1,
(workspaceSize.width - 56) / canvasSize.width,
(workspaceSize.height - 56) / canvasSize.height
));
applyDisplayZoom(zoom);
}, [applyDisplayZoom, canvasSize.height, canvasSize.width, workspaceSize.height, workspaceSize.width]);
const setCanvasZoom = useCallback((zoom: number) => {
applyDisplayZoom(zoom);
}, [applyDisplayZoom]);
useEffect(() => {
if (!canvasElRef.current || canvasRef.current) return;
const canvas = new Canvas(canvasElRef.current, {
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
backgroundColor: "#ffffff",
preserveObjectStacking: true,
selection: true,
});
canvas.freeDrawingBrush = new PencilBrush(canvas);
canvas.freeDrawingBrush.color = "#1a1714";
canvas.freeDrawingBrush.width = 8;
canvasRef.current = canvas;
const handleModified = () => {
updateActiveState();
pushHistory();
};
canvas.on("selection:created", updateActiveState);
canvas.on("selection:updated", updateActiveState);
canvas.on("selection:cleared", updateActiveState);
canvas.on("object:modified", handleModified);
canvas.on("object:added", pushHistory);
canvas.on("object:removed", pushHistory);
canvas.on("path:created", pushHistory);
pushHistory();
setCanvasReady(true);
return () => {
canvas.dispose();
canvasRef.current = null;
};
}, [pushHistory, updateActiveState]);
useEffect(() => {
fitCanvasToWorkspace();
}, [fitCanvasToWorkspace]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas?.freeDrawingBrush) return;
canvas.isDrawingMode = state.mode === "draw";
canvas.selection = state.mode === "select";
canvas.skipTargetFind = state.mode === "pan";
canvas.freeDrawingBrush.color = state.stroke;
canvas.freeDrawingBrush.width = state.brushWidth;
}, [state.brushWidth, state.mode, state.stroke]);
const loadImageFile = useCallback(async (file: File) => {
const canvas = canvasRef.current;
if (!canvas) return;
sourceFileRef.current = file;
setSourceFile(file);
const url = URL.createObjectURL(file);
try {
const image = await FabricImage.fromURL(url);
const maxWidth = 1400;
const maxHeight = 1000;
const scale = Math.min(1, maxWidth / image.width, maxHeight / image.height);
const width = Math.max(320, Math.round(image.width * scale));
const height = Math.max(240, Math.round(image.height * scale));
canvas.clear();
canvas.setDimensions({ width, height });
setCanvasSize({ width, height });
canvas.backgroundColor = "#ffffff";
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
image.set({
left: 0,
top: 0,
originX: "left",
originY: "top",
selectable: false,
evented: false,
scaleX: width / image.width,
scaleY: height / image.height,
});
canvas.add(image);
canvas.sendObjectToBack(image);
canvas.renderAll();
historyRef.current = [];
historyIndexRef.current = -1;
pushHistory();
refreshHistoryControls();
refreshLayerRows();
const zoom = Math.max(0.12, Math.min(
1,
workspaceSize.width > 0 ? (workspaceSize.width - 56) / width : 1,
workspaceSize.height > 0 ? (workspaceSize.height - 56) / height : 1
));
applyDisplayZoom(zoom, { width, height });
} finally {
URL.revokeObjectURL(url);
}
}, [applyDisplayZoom, pushHistory, refreshHistoryControls, refreshLayerRows, workspaceSize.height, workspaceSize.width]);
const applyToActive = useCallback((props: Record<string, unknown>) => {
const canvas = canvasRef.current;
const object = canvas?.getActiveObject();
if (!canvas || !object) return;
object.set(props);
object.setCoords();
canvas.requestRenderAll();
updateActiveState();
pushHistory();
}, [pushHistory, updateActiveState]);
const addText = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const text = new Textbox("Edit text", {
left: 80,
top: 80,
width: 320,
fontSize: state.fontSize,
fontFamily,
fill: state.stroke,
backgroundColor: "rgba(255,255,255,0)",
});
canvas.add(text);
canvas.setActiveObject(text);
canvas.requestRenderAll();
updateActiveState();
refreshLayerRows();
}, [fontFamily, refreshLayerRows, state.fontSize, state.stroke, updateActiveState]);
const addShape = useCallback((kind: ShapeKind) => {
const canvas = canvasRef.current;
if (!canvas) return;
const common = {
left: 110,
top: 110,
fill: kind === "line" ? "" : state.fill,
stroke: state.stroke,
strokeWidth: 4,
};
const object =
kind === "rect"
? new Rect({ ...common, width: 180, height: 120, rx: 4, ry: 4 })
: kind === "circle"
? new Circle({ ...common, radius: 72 })
: kind === "triangle"
? new Triangle({ ...common, width: 160, height: 140 })
: new Line([120, 120, 320, 120], {
stroke: state.stroke,
strokeWidth: 5,
});
canvas.add(object);
canvas.setActiveObject(object);
canvas.requestRenderAll();
updateActiveState();
refreshLayerRows();
}, [refreshLayerRows, state.fill, state.stroke, updateActiveState]);
const deleteActive = useCallback(() => {
const canvas = canvasRef.current;
const objects = canvas?.getActiveObjects() ?? [];
if (!canvas || objects.length === 0) return;
objects.forEach((object) => {
if (object.selectable !== false) canvas.remove(object);
});
canvas.discardActiveObject();
canvas.requestRenderAll();
updateActiveState();
refreshLayerRows();
}, [refreshLayerRows, updateActiveState]);
const duplicateActive = useCallback(async () => {
const canvas = canvasRef.current;
const object = canvas?.getActiveObject();
if (!canvas || !object) return;
const clone = await object.clone();
clone.set({
left: (clone.left ?? 0) + 24,
top: (clone.top ?? 0) + 24,
evented: true,
});
canvas.add(clone);
canvas.setActiveObject(clone);
canvas.requestRenderAll();
updateActiveState();
refreshLayerRows();
}, [refreshLayerRows, updateActiveState]);
const moveLayer = useCallback((direction: "forward" | "backward") => {
const canvas = canvasRef.current;
const object = canvas?.getActiveObject();
if (!canvas || !object) return;
if (direction === "forward") {
canvas.bringObjectForward(object);
} else {
canvas.sendObjectBackwards(object);
}
canvas.requestRenderAll();
pushHistory();
refreshLayerRows();
}, [pushHistory, refreshLayerRows]);
const clearObjects = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.getObjects().forEach((object) => {
if (object.selectable !== false) canvas.remove(object);
});
canvas.discardActiveObject();
canvas.requestRenderAll();
updateActiveState();
refreshLayerRows();
}, [refreshLayerRows, updateActiveState]);
const exportImage = useCallback(async () => {
const canvas = canvasRef.current;
if (!canvas) return;
const dataUrl = canvas.toDataURL({
format: exportFormat.replace("image/", "") as "png" | "jpeg" | "webp",
quality: quality / 100,
multiplier: 1,
});
await run(
{ files: sourceFileRef.current ? [sourceFileRef.current] : undefined },
{ format: exportFormat, quality, dataUrl }
);
}, [exportFormat, quality, run]);
const handleKeyDown = useCallback((event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const isTyping = target?.tagName === "INPUT" || target?.tagName === "TEXTAREA";
if (isTyping) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
event.preventDefault();
const nextCanRedo = historyIndexRef.current < historyRef.current.length - 1;
const nextCanUndo = historyIndexRef.current > 0;
if (event.shiftKey && nextCanRedo) {
void loadHistory(historyIndexRef.current + 1);
} else if (nextCanUndo) {
void loadHistory(historyIndexRef.current - 1);
}
return;
}
if ((event.key === "Delete" || event.key === "Backspace") && activeObject) {
event.preventDefault();
deleteActive();
}
}, [activeObject, deleteActive, loadHistory]);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
const handleWorkspacePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (state.mode !== "pan") return;
const workspace = workspaceRef.current;
if (!workspace) return;
isPanningRef.current = true;
panStartRef.current = {
x: event.clientX,
y: event.clientY,
scrollLeft: workspace.scrollLeft,
scrollTop: workspace.scrollTop,
};
workspace.setPointerCapture(event.pointerId);
}, [state.mode, workspaceRef]);
const handleWorkspacePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!isPanningRef.current) return;
const workspace = workspaceRef.current;
if (!workspace) return;
const start = panStartRef.current;
workspace.scrollLeft = start.scrollLeft - (event.clientX - start.x);
workspace.scrollTop = start.scrollTop - (event.clientY - start.y);
}, [workspaceRef]);
const handleWorkspacePointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (!isPanningRef.current) return;
isPanningRef.current = false;
workspaceRef.current?.releasePointerCapture(event.pointerId);
}, [workspaceRef]);
const handleWorkspaceWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
if (!event.ctrlKey && !event.metaKey) return;
event.preventDefault();
const delta = event.deltaY > 0 ? -0.08 : 0.08;
setCanvasZoom(state.zoom + delta);
}, [setCanvasZoom, state.zoom]);
return (
<div className="flex h-full min-h-[620px] flex-col bg-[var(--p-surface)]">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--p-border)] px-4 py-3">
<Dropzone
accept="image/jpeg,image/png,image/webp"
multiple={false}
maxSizeMb={25}
onFilesDrop={(files) => {
if (files[0]) void loadImageFile(files[0]);
}}
className="min-h-0 w-full p-3 sm:w-[260px]"
/>
<div className="flex flex-wrap gap-2">
<Button
variant={state.mode === "select" ? "primary" : "secondary"}
onClick={() => setState((prev) => ({ ...prev, mode: "select" }))}
>
Select
</Button>
<Button
variant={state.mode === "draw" ? "primary" : "secondary"}
onClick={() => setState((prev) => ({ ...prev, mode: "draw" }))}
>
Draw
</Button>
<Button
variant={state.mode === "pan" ? "primary" : "secondary"}
onClick={() => setState((prev) => ({ ...prev, mode: "pan" }))}
>
Pan
</Button>
<Button variant="secondary" onClick={addText}>Text</Button>
<Button variant="secondary" onClick={() => addShape("rect")}>Rect</Button>
<Button variant="secondary" onClick={() => addShape("circle")}>Circle</Button>
<Button variant="secondary" onClick={() => addShape("triangle")}>Triangle</Button>
<Button variant="secondary" onClick={() => addShape("line")}>Line</Button>
</div>
<div className="ml-auto flex items-center gap-2">
<Button variant="secondary" onClick={() => setCanvasZoom(state.zoom - 0.12)}>
-
</Button>
<button
onClick={fitCanvasToWorkspace}
className="rounded-[10px] border border-[var(--p-border)] bg-[var(--p-chip)] px-3 py-2 font-mono text-[12px] text-[var(--p-text)]"
>
{Math.round(state.zoom * 100)}%
</button>
<Button variant="secondary" onClick={() => setCanvasZoom(state.zoom + 0.12)}>
+
</Button>
<Button variant="secondary" onClick={() => setCanvasZoom(1)}>
100%
</Button>
</div>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_300px]">
<div
ref={workspaceRef}
onPointerDown={handleWorkspacePointerDown}
onPointerMove={handleWorkspacePointerMove}
onPointerUp={handleWorkspacePointerUp}
onPointerCancel={handleWorkspacePointerUp}
onWheel={handleWorkspaceWheel}
className={`min-h-[520px] overflow-auto bg-[var(--p-bg)] p-6 ${
state.mode === "pan" ? "cursor-grab active:cursor-grabbing" : ""
}`}
>
<div className="flex min-h-full min-w-max items-center justify-center">
<div
className="shrink-0 border border-[var(--p-border)] bg-white shadow-[0_24px_60px_-36px_var(--p-shadow-soft)]"
style={{
width: canvasSize.width * state.zoom,
height: canvasSize.height * state.zoom,
}}
>
<canvas ref={canvasElRef} />
</div>
</div>
</div>
<aside className="flex min-h-0 flex-col gap-4 overflow-y-auto border-l border-[var(--p-border)] bg-[var(--p-surface-2)] p-4">
<section className="flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
properties
</div>
<div className="rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-3">
<div className="mb-3 font-sans text-sm font-semibold text-[var(--p-text)]">
{state.selectedType}
</div>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
Fill
<input
type="color"
value={state.fill}
onChange={(event) => {
setState((prev) => ({ ...prev, fill: event.target.value }));
applyToActive({ fill: event.target.value });
}}
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
/>
</label>
<label className="flex flex-col gap-1 font-sans text-[12px] text-[var(--p-muted)]">
Stroke / text
<input
type="color"
value={state.stroke}
onChange={(event) => {
setState((prev) => ({ ...prev, stroke: event.target.value }));
applyToActive({ stroke: event.target.value, fill: activeObject?.type === "textbox" ? event.target.value : activeObject?.get("fill") });
}}
className="h-9 w-full rounded border border-[var(--p-border)] bg-transparent"
/>
</label>
</div>
<div className="mt-3 flex flex-col gap-3">
<Select
label="Font"
value={fontFamily}
options={FONT_OPTIONS}
onChange={(event) => {
setFontFamily(event.target.value);
applyToActive({ fontFamily: event.target.value });
}}
/>
<Slider
label="Text size"
min={12}
max={160}
value={state.fontSize}
onChange={(event) => {
const fontSize = Number(event.target.value);
setState((prev) => ({ ...prev, fontSize }));
applyToActive({ fontSize });
}}
/>
<Slider
label="Brush"
min={1}
max={48}
value={state.brushWidth}
onChange={(event) => {
setState((prev) => ({ ...prev, brushWidth: Number(event.target.value) }));
}}
/>
</div>
</div>
</section>
<section className="flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
arrange
</div>
<div className="grid grid-cols-2 gap-2">
<Button variant="secondary" onClick={duplicateActive} disabled={!activeObject}>Duplicate</Button>
<Button variant="danger" onClick={deleteActive} disabled={!activeObject}>Delete</Button>
<Button variant="secondary" onClick={() => moveLayer("forward")} disabled={!activeObject}>Forward</Button>
<Button variant="secondary" onClick={() => moveLayer("backward")} disabled={!activeObject}>Backward</Button>
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current - 1)} disabled={!canUndo}>Undo</Button>
<Button variant="secondary" onClick={() => loadHistory(historyIndexRef.current + 1)} disabled={!canRedo}>Redo</Button>
</div>
<Button variant="secondary" onClick={clearObjects}>
Clear editable objects
</Button>
</section>
<section className="flex min-h-0 flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
layers
</div>
<div className="max-h-[160px] overflow-y-auto rounded-[12px] border border-[var(--p-border)] bg-[var(--p-surface)] p-2">
{layerRows.length === 0 ? (
<div className="px-2 py-4 text-center text-sm text-[var(--p-muted)]">
No editable layers yet
</div>
) : (
layerRows.map((row) => (
<button
key={row.id}
onClick={() => {
canvasRef.current?.setActiveObject(row.object);
canvasRef.current?.requestRenderAll();
updateActiveState();
}}
className={`mb-1 w-full rounded-lg px-3 py-2 text-left font-sans text-[13px] ${
activeObject === row.object
? "bg-[var(--p-accent)] text-[var(--p-accent-ink)]"
: "bg-[var(--p-chip)] text-[var(--p-text)]"
}`}
>
{row.label}
</button>
))
)}
</div>
</section>
<section className="mt-auto flex flex-col gap-3">
<div className="font-mono text-[10px] uppercase tracking-wider text-[var(--p-muted)]">
export
</div>
<Select
label="Format"
value={exportFormat}
options={FORMAT_OPTIONS}
onChange={(event) => setExportFormat(event.target.value as ImageEditorOptions["format"])}
/>
<Slider
label="Quality"
min={10}
max={100}
value={quality}
onChange={(event) => setQuality(Number(event.target.value))}
/>
<Button onClick={exportImage} disabled={!canvasReady || isExecuting}>
{isExecuting ? "Exporting..." : "Export image"}
</Button>
{sourceFile && (
<div className="truncate font-mono text-[10px] text-[var(--p-muted)]">
source: {sourceFile.name}
</div>
)}
{error && <div className="text-sm text-red-700">{error}</div>}
{result && <ToolResultPanel result={result} />}
</section>
</aside>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { lazy } from "react";
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runImageEditor } from "./run";
export interface ImageEditorOptions {
format: "image/png" | "image/jpeg" | "image/webp";
quality: number;
dataUrl?: string;
}
const ImageEditorUi = lazy(() => import("./ImageEditorUi"));
export const imageEditorPlugin: PlimiPlugin<ImageEditorOptions> = {
manifest: {
id: "image-editor",
name: "Image Editor",
description:
"Edit images with text, drawing, shapes, colors, layers, and export locally.",
category: "image",
version: "1.0.0",
tags: ["image", "editor", "text", "draw", "shapes", "fabric"],
input: {
type: "files",
accept: ["image/jpeg", "image/png", "image/webp"],
multiple: false,
maxSizeMb: 25,
},
output: { type: "files" },
offlineReady: true,
},
capabilities: {
cancelable: false,
worker: false,
customUi: true,
preview: true,
},
permissions: {
network: "none",
fileSystem: "read-write",
clipboard: "none",
},
run: runImageEditor,
customUi: ImageEditorUi,
};

View File

@@ -0,0 +1,60 @@
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 { ImageEditorOptions } from "./index";
function dataUrlToBlob(dataUrl: string): Blob {
const [header, payload] = dataUrl.split(",");
const mimeMatch = header.match(/^data:(.*?);base64$/);
if (!mimeMatch || !payload) {
throw new Error("Invalid image export data.");
}
const binary = atob(payload);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new Blob([bytes], { type: mimeMatch[1] });
}
function extensionForMime(mimeType: ImageEditorOptions["format"]): string {
if (mimeType === "image/jpeg") return "jpg";
if (mimeType === "image/webp") return "webp";
return "png";
}
export async function runImageEditor(
input: ToolInput,
options: ImageEditorOptions,
context: ToolContext
): Promise<ToolResult> {
if (!options.dataUrl) {
throw new Error("No edited image is ready to export.");
}
context.reportProgress({ percentage: 60, message: "Preparing export..." });
const blob = dataUrlToBlob(options.dataUrl);
const sourceName = input.files?.[0]?.name ?? "image";
const baseName = sourceName.includes(".")
? sourceName.slice(0, sourceName.lastIndexOf("."))
: sourceName;
const name = `${baseName}_edited.${extensionForMime(options.format)}`;
context.reportProgress({ percentage: 100, message: "Done" });
return {
type: "files",
files: [
{
name,
mimeType: options.format,
blob,
sizeBefore: input.files?.[0]?.size,
sizeAfter: blob.size,
},
],
};
}

View File

@@ -0,0 +1,224 @@
import { useState, useCallback, useEffect } from "react";
import type { ToolUiProps } from "../../core/plugins/plugin-types";
import type { ImageOptimizerOptions } from "./index";
import { Button } from "../../components/ui/Button";
import { Card } from "../../components/ui/Card";
import { Dropzone } from "../../components/ui/Dropzone";
import { Slider } from "../../components/ui/Slider";
import { Select } from "../../components/ui/Select";
import { useToolExecution } from "../../components/tool/useToolExecution";
const IMAGE_FORMATS = ["image/jpeg", "image/webp", "image/png"] as const;
type ImageFormat = (typeof IMAGE_FORMATS)[number];
function isImageFormat(value: string): value is ImageFormat {
return IMAGE_FORMATS.includes(value as ImageFormat);
}
export default function ImageOptimizerUi({
plugin,
}: ToolUiProps<ImageOptimizerOptions>) {
const [files, setFiles] = useState<File[]>([]);
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
const [quality, setQuality] = useState(80);
const [format, setFormat] = useState<
"image/jpeg" | "image/webp" | "image/png"
>("image/webp");
const { run, result, isExecuting, error, reset } = useToolExecution(plugin);
useEffect(() => {
return () => {
previewUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [previewUrls]);
const handleFilesDrop = useCallback((nextFiles: File[]) => {
setFiles(nextFiles);
setPreviewUrls(nextFiles.map((file) => URL.createObjectURL(file)));
reset();
}, [reset]);
const handleRun = useCallback(async () => {
if (files.length === 0) return;
await run({ files }, { quality, format });
}, [files, format, quality, run]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
if (!isExecuting && files.length > 0) {
handleRun();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleRun, isExecuting, files]);
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);
}, []);
return (
<div className="p-[18px] md:p-[28px] max-w-5xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<Card title="Upload Images">
<Dropzone
accept="image/jpeg,image/png,image/webp"
multiple
maxSizeMb={20}
onFilesDrop={handleFilesDrop}
/>
{previewUrls.length > 0 && (
<div className="mt-6">
<h4 className="text-sm font-medium text-gray-700 mb-3">
Previews:
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{previewUrls.map((url, i) => (
<div
key={i}
className="relative aspect-square rounded-lg overflow-hidden border border-gray-200"
>
<img
src={url}
alt="Preview"
className="object-cover w-full h-full"
/>
</div>
))}
</div>
</div>
)}
</Card>
{error && (
<Card className="border-red-200">
<div className="text-sm text-red-700">{error}</div>
</Card>
)}
{result && result.type === "files" && (
<Card
title="Optimized Results"
className="border-green-200 shadow-md"
>
{result.files.length > 1 && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 pb-4 border-b border-gray-100">
<span className="text-sm text-gray-500 font-medium">
Successfully optimized {result.files.length} images
</span>
<Button
variant="primary"
onClick={() => {
result.files.forEach((file) => {
downloadBlob(file.blob, file.name);
});
}}
className="w-full sm:w-auto shrink-0"
>
Download All ({result.files.length})
</Button>
</div>
)}
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg">
{result.files.map((file, i) => {
const savedBytes =
(file.sizeBefore || 0) - (file.sizeAfter || 0);
const savedPercentage = file.sizeBefore
? (savedBytes / file.sizeBefore) * 100
: 0;
return (
<li
key={i}
className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-gray-50 transition-colors"
>
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium text-gray-900 truncate" title={file.name}>
{file.name}
</span>
<div className="text-sm text-gray-500 mt-1 flex flex-wrap items-center gap-2">
<span className="line-through">
{((file.sizeBefore || 0) / 1024).toFixed(1)} KB
</span>
<span className="text-green-600 font-medium">
{((file.sizeAfter || 0) / 1024).toFixed(1)} KB
</span>
<span className="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded-full">
-{savedPercentage.toFixed(0)}%
</span>
</div>
</div>
<Button
variant="secondary"
onClick={() => downloadBlob(file.blob, file.name)}
className="w-full sm:w-auto shrink-0 text-center"
>
Download
</Button>
</li>
);
})}
</ul>
</Card>
)}
</div>
<div className="space-y-6">
<Card title="Settings">
<div className="space-y-6">
<Select
label="Output Format"
value={format}
onChange={(e) => {
if (isImageFormat(e.target.value)) {
setFormat(e.target.value);
}
}}
options={[
{ label: "WebP (Recommended)", value: "image/webp" },
{ label: "JPEG", value: "image/jpeg" },
{ label: "PNG", value: "image/png" },
]}
/>
<Slider
label="Quality"
value={quality}
onChange={(e) => setQuality(Number(e.target.value))}
min={1}
max={100}
/>
<div className="pt-4 border-t border-gray-100">
<Button
onClick={handleRun}
disabled={isExecuting || files.length === 0}
className="w-full"
>
{isExecuting ? "Optimizing..." : "Optimize Images"}
</Button>
</div>
</div>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { lazy } from "react";
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runImageOptimizer } from "./run";
export interface ImageOptimizerOptions {
quality: number;
format: "image/jpeg" | "image/webp" | "image/png";
}
// Lazy load the custom UI to keep the main bundle small
const ImageOptimizerUi = lazy(() => import("./ImageOptimizerUi"));
export const imageOptimizerPlugin: PlimiPlugin<ImageOptimizerOptions> = {
manifest: {
id: "image-optimizer",
name: "Image Optimizer",
description:
"Compress and convert images entirely in your browser using the Canvas API.",
category: "image",
version: "1.0.0",
tags: ["image", "compress", "resize", "webp", "jpeg"],
input: {
type: "files",
accept: ["image/jpeg", "image/png", "image/webp"],
multiple: true,
maxSizeMb: 20,
},
output: { type: "files" },
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "slider",
key: "quality",
label: "Quality",
defaultValue: 80,
min: 1,
max: 100,
step: 1,
},
{
type: "select",
key: "format",
label: "Output Format",
defaultValue: "image/webp",
options: [
{ label: "WebP", value: "image/webp" },
{ label: "JPEG", value: "image/jpeg" },
{ label: "PNG", value: "image/png" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false,
customUi: true,
},
run: runImageOptimizer,
// Provide the custom UI
customUi: ImageOptimizerUi,
};

View File

@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from "vitest";
import { runImageOptimizer } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("Image Optimizer 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(
runImageOptimizer(
{ files: [] },
{ quality: 80, format: "image/webp" },
mockContext
)
).rejects.toThrow("No files provided for optimization.");
});
it("should throw error if file is not an image", async () => {
const textFile = new File(["hello"], "hello.txt", { type: "text/plain" });
await expect(
runImageOptimizer(
{ files: [textFile] },
{ quality: 80, format: "image/webp" },
mockContext
)
).rejects.toThrow("File hello.txt is not an image.");
});
// Note: Testing actual Canvas API compression requires a full browser environment
// or a mock canvas library in Vitest. For V1 MVP, we test the core validation logic.
});

View File

@@ -0,0 +1,108 @@
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 { ImageOptimizerOptions } from "./index";
export async function runImageOptimizer(
input: ToolInput,
options: ImageOptimizerOptions,
context: ToolContext
): Promise<ToolResult> {
const files = input.files;
if (!files || files.length === 0) {
throw new Error("No files provided for optimization.");
}
const results = [];
let processed = 0;
for (const file of files) {
// Check if it's an image
if (!file.type.startsWith("image/")) {
throw new Error(`File ${file.name} is not an image.`);
}
context.reportProgress({
percentage: (processed / files.length) * 100,
message: `Optimizing ${file.name}...`,
});
const optimizedBlob = await optimizeImage(file, options);
let ext = "jpg";
if (options.format === "image/webp") ext = "webp";
if (options.format === "image/png") ext = "png";
const originalNameWithoutExt =
file.name.substring(0, file.name.lastIndexOf(".")) || file.name;
const newName = `${originalNameWithoutExt}_optimized.${ext}`;
results.push({
name: newName,
mimeType: options.format,
blob: optimizedBlob,
sizeBefore: file.size,
sizeAfter: optimizedBlob.size,
});
processed++;
}
context.reportProgress({ percentage: 100, message: "Done" });
return {
type: "files",
files: results,
};
}
function optimizeImage(
file: File,
options: ImageOptimizerOptions
): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = document.createElement("canvas");
const width = img.width;
const height = img.height;
// Optional: resize logic could go here based on max width/height options
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
return reject(new Error("Could not get 2D context"));
}
ctx.drawImage(img, 0, 0, width, height);
const quality = options.quality / 100;
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error("Failed to create blob from canvas"));
}
},
options.format,
quality
);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Failed to load image for optimization"));
};
img.src = url;
});
}

View File

@@ -0,0 +1,304 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { ToolUiProps } from "../../core/plugins/plugin-types";
import type { ImageRedactorOptions, RedactRect } from "./index";
import { Button } from "../../components/ui/Button";
import { Card } from "../../components/ui/Card";
import { Dropzone } from "../../components/ui/Dropzone";
import { useToolExecution } from "../../components/tool/useToolExecution";
export default function ImageRedactorUi({
plugin,
}: ToolUiProps<ImageRedactorOptions>) {
const [files, setFiles] = useState<File[]>([]);
const [previewUrl, setPreviewUrl] = useState<string>("");
const [rects, setRects] = useState<RedactRect[]>([]);
// Drawing state
const [isDrawing, setIsDrawing] = useState(false);
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
const [currentPos, setCurrentPos] = useState({ x: 0, y: 0 });
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
const { run, result, isExecuting, error, reset } = useToolExecution(plugin);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!previewUrl) return;
return () => {
URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
const handleFilesDrop = useCallback((nextFiles: File[]) => {
setFiles(nextFiles);
setPreviewUrl(nextFiles.length > 0 ? URL.createObjectURL(nextFiles[0]) : "");
setRects([]);
reset();
}, [reset]);
const handleClearFiles = useCallback(() => {
setFiles([]);
setPreviewUrl("");
setRects([]);
reset();
}, [reset]);
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
setImageSize({ width: img.naturalWidth, height: img.naturalHeight });
};
const getPercentageCoords = (e: React.PointerEvent<HTMLDivElement>) => {
if (!containerRef.current) return { x: 0, y: 0 };
const rect = containerRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
// Clamp to 0-100 range
return {
x: Math.max(0, Math.min(100, x)),
y: Math.max(0, Math.min(100, y)),
};
};
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (result) return; // Disable drawing if result is shown
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const coords = getPercentageCoords(e);
setStartPos(coords);
setCurrentPos(coords);
setIsDrawing(true);
};
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isDrawing) return;
const coords = getPercentageCoords(e);
setCurrentPos(coords);
};
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isDrawing) return;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
setIsDrawing(false);
const coords = getPercentageCoords(e);
const x = Math.min(startPos.x, coords.x);
const y = Math.min(startPos.y, coords.y);
const w = Math.abs(coords.x - startPos.x);
const h = Math.abs(coords.y - startPos.y);
// Save rectangle if it has some minimum size to avoid tiny single click boxes
if (w > 0.4 && h > 0.4) {
setRects((prev) => [...prev, { x, y, w, h }]);
}
};
const handleUndo = () => {
setRects((prev) => prev.slice(0, -1));
};
const handleClear = () => {
setRects([]);
};
const handleRun = useCallback(async () => {
if (files.length === 0) return;
await run({ files }, { rects, color: "#000000" });
}, [files, rects, run]);
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);
}, []);
// Hotkeys: Ctrl+Enter to redact
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
if (!isExecuting && files.length > 0 && !result) {
handleRun();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [files.length, handleRun, isExecuting, result]);
// Temporary rectangle styles (while drawing)
const tempRectStyle = isDrawing
? {
left: `${Math.min(startPos.x, currentPos.x)}%`,
top: `${Math.min(startPos.y, currentPos.y)}%`,
width: `${Math.abs(currentPos.x - startPos.x)}%`,
height: `${Math.abs(currentPos.y - startPos.y)}%`,
}
: null;
return (
<div className="p-[18px] md:p-[28px] max-w-5xl mx-auto">
{files.length === 0 ? (
<Card title="Upload Image to Redact">
<Dropzone
accept="image/jpeg,image/png,image/webp"
multiple={false}
onFilesDrop={handleFilesDrop}
/>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
<Card title="Redaction Canvas">
<div className="text-xs text-gray-400 mb-3">
Pointer drawing active. Click and drag to place black redaction blocks.
</div>
{/* Bounding box wrapper */}
<div
ref={containerRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
className="relative select-none overflow-hidden cursor-crosshair border border-[var(--p-border)] rounded-lg bg-[var(--p-bg)] max-w-full flex items-center justify-center"
style={{ touchAction: "none" }}
>
<img
src={previewUrl}
alt="Redaction source"
onLoad={handleImageLoad}
onDragStart={(e) => e.preventDefault()}
className="max-w-full max-h-[60vh] object-contain pointer-events-none"
/>
{/* Draw saved rectangles */}
{rects.map((r, i) => (
<div
key={i}
className="absolute bg-black pointer-events-none"
style={{
left: `${r.x}%`,
top: `${r.y}%`,
width: `${r.w}%`,
height: `${r.h}%`,
boxShadow: "0 0 0 1px rgba(255, 255, 255, 0.15)",
}}
/>
))}
{/* Draw active temporary rectangle */}
{tempRectStyle && (
<div
className="absolute bg-black/60 border-2 border-red-500 border-dashed pointer-events-none"
style={tempRectStyle}
/>
)}
</div>
</Card>
{error && (
<Card className="border-red-200">
<div className="text-sm text-red-700">{error}</div>
</Card>
)}
{result && result.type === "files" && (
<Card
title="Redacted Result"
className="border-green-200 shadow-md"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 border border-green-200 bg-green-50/50 rounded-xl">
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium text-green-950 truncate" title={result.files[0].name}>
{result.files[0].name}
</span>
<span className="text-xs text-green-700 mt-1">
Metadata flattened & redacted successfully.
</span>
</div>
<Button
variant="primary"
onClick={() => downloadBlob(result.files[0].blob, result.files[0].name)}
className="w-full sm:w-auto shrink-0"
>
Download Redacted Image
</Button>
</div>
</Card>
)}
</div>
<div className="space-y-6">
<Card title="Controls">
<div className="space-y-6">
<div>
<div className="text-sm font-semibold text-gray-800">
File details
</div>
<div className="text-xs text-gray-500 mt-1 truncate">
Name: {files[0].name}
</div>
{imageSize.width > 0 && (
<div className="text-xs text-gray-500">
Dimensions: {imageSize.width} &times; {imageSize.height} px
</div>
)}
<div className="text-xs text-gray-500">
Active redactions: {rects.length}
</div>
</div>
<div className="flex flex-col gap-2">
<Button
variant="secondary"
onClick={handleUndo}
disabled={rects.length === 0 || isExecuting}
className="w-full justify-center"
>
Undo Last Block
</Button>
<Button
variant="secondary"
onClick={handleClear}
disabled={rects.length === 0 || isExecuting}
className="w-full justify-center text-red-600 hover:text-red-700 hover:bg-red-50"
>
Clear All Blocks
</Button>
</div>
<div className="pt-4 border-t border-gray-100 flex flex-col gap-2">
<Button
variant="primary"
onClick={handleRun}
disabled={isExecuting || rects.length === 0 || result !== null}
className="w-full justify-center"
>
{isExecuting ? "Processing..." : "Apply Redactions"}
</Button>
<Button
variant="secondary"
onClick={handleClearFiles}
disabled={isExecuting}
className="w-full justify-center"
>
Upload Another Image
</Button>
</div>
</div>
</Card>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { lazy } from "react";
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runImageRedactor } from "./run";
export interface RedactRect {
x: number; // Absolute X coordinate in image natural pixels
y: number; // Absolute Y coordinate in image natural pixels
w: number; // Absolute width in image natural pixels
h: number; // Absolute height in image natural pixels
}
export interface ImageRedactorOptions {
rects: RedactRect[];
color: string;
}
const ImageRedactorUi = lazy(() => import("./ImageRedactorUi"));
export const imageRedactorPlugin: PlimiPlugin<ImageRedactorOptions> = {
manifest: {
id: "image-redactor",
name: "Image Redactor",
description: "Safely paint black boxes over sensitive data like addresses or credit cards entirely in your browser.",
category: "privacy",
version: "1.0.0",
tags: ["privacy", "redact", "hide", "censor", "blur", "image"],
input: {
type: "files",
accept: ["image/jpeg", "image/png", "image/webp"],
multiple: false,
},
output: { type: "files" },
offlineReady: true,
},
capabilities: {
cancelable: false,
worker: false,
customUi: true,
},
run: runImageRedactor,
customUi: ImageRedactorUi,
};

View File

@@ -0,0 +1,25 @@
import { describe, it, expect, vi } from "vitest";
import { runImageRedactor } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("Image Redactor 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(
runImageRedactor(
{ files: [] },
{ rects: [], color: "#000000" },
mockContext
)
).rejects.toThrow("No image file uploaded.");
});
});

View File

@@ -0,0 +1,106 @@
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 { ImageRedactorOptions } from "./index";
function loadImage(url: string): Promise<HTMLImageElement> {
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<Blob> {
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 runImageRedactor(
input: ToolInput,
options: ImageRedactorOptions,
context: ToolContext
): Promise<ToolResult> {
const files = input.files;
if (!files || !Array.isArray(files) || files.length === 0) {
throw new Error("No image file uploaded.");
}
const file = files[0];
const url = URL.createObjectURL(file);
try {
context.reportProgress({ percentage: 20, message: "Loading image file..." });
const img = await loadImage(url);
context.reportProgress({ percentage: 50, message: "Drawing base canvas..." });
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.");
}
// Draw original image
ctx.drawImage(img, 0, 0);
// Apply redactions
context.reportProgress({ percentage: 70, message: "Applying redaction blocks..." });
ctx.fillStyle = options.color || "#000000";
const rects = options.rects || [];
for (const rect of rects) {
const rx = (rect.x / 100) * img.naturalWidth;
const ry = (rect.y / 100) * img.naturalHeight;
const rw = (rect.w / 100) * img.naturalWidth;
const rh = (rect.h / 100) * img.naturalHeight;
ctx.fillRect(rx, ry, rw, rh);
}
context.reportProgress({ percentage: 90, message: "Exporting redacted image..." });
const mimeType = file.type || "image/png";
const blob = await canvasToBlob(canvas, mimeType);
// Generate output file name
const dotIdx = file.name.lastIndexOf(".");
const name = dotIdx !== -1
? `${file.name.substring(0, dotIdx)}_redacted${file.name.substring(dotIdx)}`
: `${file.name}_redacted.png`;
context.reportProgress({ percentage: 100, message: "Completed!" });
return {
type: "files",
files: [
{
name: name,
mimeType: mimeType,
blob: blob,
sizeAfter: blob.size,
},
],
};
} catch (error) {
console.error("Image Redaction Error:", error);
throw error instanceof Error ? error : new Error("Unknown error occurred during image redaction");
} finally {
URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,45 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runJsonFormatter } from "./run";
export interface JsonOptions {
indent: "2 spaces" | "4 spaces" | "tabs" | "minified";
}
export const jsonFormatterPlugin: PlimiPlugin<JsonOptions> = {
manifest: {
id: "dev-json",
name: "JSON Formatter",
description: "Format, minify, and validate JSON strings.",
category: "developer",
version: "1.0.0",
tags: ["json", "format", "minify", "validate"],
input: { type: "text" },
output: { type: "text" },
example: '{"name":"Plimi","version":1,"tools":["base64","hash"],"offline":true}',
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "indent",
label: "Indent",
defaultValue: "2 spaces",
options: [
{ label: "2 Spaces", value: "2 spaces" },
{ label: "4 Spaces", value: "4 spaces" },
{ label: "Tabs", value: "tabs" },
{ label: "Minified", value: "minified" },
],
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runJsonFormatter,
};

View File

@@ -0,0 +1,35 @@
import type { ToolInput } from "../../core/io/input-types";
import type { ToolResult } from "../../core/io/output-types";
import type { JsonOptions } from "./index";
export async function runJsonFormatter(
input: ToolInput,
options: JsonOptions
): Promise<ToolResult> {
const text = input.text || "";
if (!text) {
throw new Error("No text provided.");
}
let indentSpace: string | number = 2;
if (options.indent === "4 spaces") {
indentSpace = 4;
} else if (options.indent === "tabs") {
indentSpace = "\t";
} else if (options.indent === "minified") {
indentSpace = 0;
}
try {
const parsed = JSON.parse(text);
const formatted = JSON.stringify(parsed, null, indentSpace);
return {
type: "text",
value: formatted,
};
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`Invalid JSON: ${message}`, { cause });
}
}

View File

@@ -0,0 +1,30 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runJwtDecoder } from "./run";
export const jwtDecoderPlugin: PlimiPlugin = {
manifest: {
id: "jwt-decoder",
name: "JWT Decoder",
description: "Decode and inspect JSON Web Tokens (JWT) locally in your browser.",
category: "developer",
version: "1.0.0",
tags: ["jwt", "token", "decoder", "auth", "json"],
input: {
type: "text",
label: "JWT Token",
placeholder: "Paste your JWT token here (encoded header.payload.signature)...",
multiline: true,
rows: 6,
},
output: { type: "json" },
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE4MTYyMzkwMjJ9.signature-placeholder",
offlineReady: true,
},
capabilities: {
cancelable: false,
worker: false,
},
run: runJwtDecoder,
};

View File

@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from "vitest";
import { runJwtDecoder } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("JWT Decoder Plugin", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
// {"alg":"HS256","typ":"JWT"} -> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
// {"sub":"123","name":"John Doe","iat":1516239022} -> eyJzdWIiOiIxMjMiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9
const validNoExp = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.sig";
// {"sub":"123","exp":32503680000} (Year 3000) -> eyJzdWIiOiIxMjMiLCJleHAiOjMyNTAzNjgwMDAwfQ
const futureExp = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjMyNTAzNjgwMDAwfQ.sig";
// {"sub":"123","exp":946684800} (Year 2000) -> eyJzdWIiOiIxMjMiLCJleHAiOjk0NjY4NDgwMH0
const expiredExp = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjk0NjY4NDgwMH0.sig";
it("should decode a valid JWT without exp claim", async () => {
const result = await runJwtDecoder({ text: validNoExp }, {}, mockContext);
expect(result.type).toBe("json");
if (result.type === "json") {
const data = result.value as any;
expect(data.status).toBe("valid");
expect(data.primary).toContain("Valid (No Expiration claim)");
expect(data.header.alg).toBe("HS256");
expect(data.payload.sub).toBe("123");
expect(data.issuedAt).toBe("2018-01-18T01:30:22.000Z");
expect(data.expiresAt).toBeNull();
}
});
it("should decode a valid JWT with future exp claim", async () => {
const result = await runJwtDecoder({ text: futureExp }, {}, mockContext);
expect(result.type).toBe("json");
if (result.type === "json") {
const data = result.value as any;
expect(data.status).toBe("valid");
expect(data.primary).toContain("Valid (Expires:");
expect(data.expiresAt).toBe("3000-01-01T00:00:00.000Z");
}
});
it("should decode an expired JWT with past exp claim", async () => {
const result = await runJwtDecoder({ text: expiredExp }, {}, mockContext);
expect(result.type).toBe("json");
if (result.type === "json") {
const data = result.value as any;
expect(data.status).toBe("expired");
expect(data.primary).toContain("Expired (at");
expect(data.expiresAt).toBe("2000-01-01T00:00:00.000Z");
}
});
it("should throw error for empty token", async () => {
await expect(
runJwtDecoder({ text: "" }, {}, mockContext)
).rejects.toThrow("Please enter a JWT token to decode.");
});
it("should throw error for malformed token format", async () => {
await expect(
runJwtDecoder({ text: "one.two" }, {}, mockContext)
).rejects.toThrow("A JWT must consist of three parts");
});
it("should throw error for invalid JSON payload", async () => {
// Header ok, payload invalid base64/json: "invalid" -> aW52YWxpZA==
const invalidJson = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aW52YWxpZA.sig";
await expect(
runJwtDecoder({ text: invalidJson }, {}, mockContext)
).rejects.toThrow("Failed to decode JWT Payload: invalid Base64URL encoding or malformed JSON content.");
});
});

View File

@@ -0,0 +1,92 @@
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 base64urlDecode(str: string): string {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const paddingNeeded = (4 - (base64.length % 4)) % 4;
const padded = base64 + "=".repeat(paddingNeeded);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
}
export async function runJwtDecoder(
input: ToolInput,
_options: unknown,
context: ToolContext
): Promise<ToolResult> {
const token = (input.text ?? "").trim();
if (!token) {
throw new Error("Please enter a JWT token to decode.");
}
context.reportProgress({ percentage: 20, message: "Parsing token parts..." });
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Invalid JWT token format. A JWT must consist of three parts (header, payload, signature) separated by dots.");
}
const [headerB64, payloadB64, signatureHex] = parts;
context.reportProgress({ percentage: 50, message: "Decoding JSON content..." });
let header: Record<string, unknown>;
let payload: Record<string, unknown>;
try {
const decodedHeader = base64urlDecode(headerB64);
header = JSON.parse(decodedHeader);
} catch (err) {
throw new Error("Failed to decode JWT Header: invalid Base64URL encoding or malformed JSON content.");
}
try {
const decodedPayload = base64urlDecode(payloadB64);
payload = JSON.parse(decodedPayload);
} catch (err) {
throw new Error("Failed to decode JWT Payload: invalid Base64URL encoding or malformed JSON content.");
}
context.reportProgress({ percentage: 85, message: "Checking claims..." });
let expiresAt: string | null = null;
let issuedAt: string | null = null;
let status: "valid" | "expired" = "valid";
let primary = "Valid (No Expiration claim)";
if (typeof payload.exp === "number") {
const expTime = payload.exp * 1000;
expiresAt = new Date(expTime).toISOString();
if (Date.now() > expTime) {
status = "expired";
primary = `Expired (at ${expiresAt})`;
} else {
primary = `Valid (Expires: ${expiresAt})`;
}
}
if (typeof payload.iat === "number") {
issuedAt = new Date(payload.iat * 1000).toISOString();
}
context.reportProgress({ percentage: 100, message: "Done" });
return {
type: "json",
value: {
primary,
status,
expiresAt,
issuedAt,
header,
payload,
signature: signatureHex || "(empty)",
},
};
}

View File

@@ -0,0 +1,54 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runLoremIpsum } from "./run";
export interface LoremIpsumOptions {
type: "paragraphs" | "sentences" | "words";
count: number;
}
export const loremIpsumPlugin: PlimiPlugin<LoremIpsumOptions> = {
manifest: {
id: "txt-lorem",
name: "Lorem Ipsum Generator",
description: "Generate placeholder text in paragraphs, sentences, or words.",
category: "text",
version: "1.0.0",
tags: ["lorem", "ipsum", "placeholder", "text"],
input: { type: "none" },
output: { type: "text" },
example: "Click 'Try example' to generate placeholder text.",
offlineReady: true,
},
optionsSchema: {
fields: [
{
type: "select",
key: "type",
label: "Unit",
defaultValue: "paragraphs",
options: [
{ label: "Paragraphs", value: "paragraphs" },
{ label: "Sentences", value: "sentences" },
{ label: "Words", value: "words" },
],
},
{
type: "slider",
key: "count",
label: "Count",
defaultValue: 3,
min: 1,
max: 50,
step: 1,
},
],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runLoremIpsum,
};

View File

@@ -0,0 +1,82 @@
import { describe, it, expect, vi } from "vitest";
import { runLoremIpsum } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
import type { LoremIpsumOptions } from "./index";
describe("Lorem Ipsum Generator", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
it("should generate the requested number of words", async () => {
const result = await runLoremIpsum(
{},
{ type: "words", count: 5 },
mockContext
);
expect(result.type).toBe("text");
const words = (result as { type: "text"; value: string }).value.split(" ");
expect(words).toHaveLength(5);
words.forEach((w) => expect(w.length).toBeGreaterThan(0));
});
it("should generate the requested number of sentences", async () => {
const result = await runLoremIpsum(
{},
{ type: "sentences", count: 3 },
mockContext
);
expect(result.type).toBe("text");
const value = (result as { type: "text"; value: string }).value;
const sentences = value.split(". ").filter((s) => s.trim().length > 0);
expect(sentences).toHaveLength(3);
});
it("should generate the requested number of paragraphs", async () => {
const result = await runLoremIpsum(
{},
{ type: "paragraphs", count: 4 },
mockContext
);
expect(result.type).toBe("text");
const value = (result as { type: "text"; value: string }).value;
const paragraphs = value.split("\n\n").filter((p) => p.trim().length > 0);
expect(paragraphs).toHaveLength(4);
});
it("should start sentences with a capital letter", async () => {
const result = await runLoremIpsum(
{},
{ type: "sentences", count: 5 },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
const sentences = value.split(". ").filter((s) => s.trim().length > 0);
sentences.forEach((s) => {
const trimmed = s.trim();
expect(trimmed[0]).toBe(trimmed[0].toUpperCase());
});
});
it("should end sentences with a period", async () => {
const result = await runLoremIpsum(
{},
{ type: "sentences", count: 3 },
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value.endsWith(".")).toBe(true);
});
it("should default to paragraphs when type is unrecognized", async () => {
const result = await runLoremIpsum(
{},
{ type: "unknown", count: 2 } as unknown as LoremIpsumOptions,
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,89 @@
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 { LoremIpsumOptions } from "./index";
const LOREM_WORDS = [
"lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit",
"sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et", "dolore",
"magna", "aliqua", "enim", "ad", "minim", "veniam", "quis", "nostrud",
"exercitation", "ullamco", "laboris", "nisi", "aliquip", "ex", "ea", "commodo",
"consequat", "duis", "aute", "irure", "in", "reprehenderit", "voluptate",
"velit", "esse", "cillum", "fugiat", "nulla", "pariatur", "excepteur", "sint",
"occaecat", "cupidatat", "non", "proident", "sunt", "culpa", "qui", "officia",
"deserunt", "mollit", "anim", "id", "est", "laborum", "perspiciatis", "unde",
"omnis", "iste", "natus", "error", "voluptatem", "accusantium", "doloremque",
"laudantium", "totam", "rem", "aperiam", "eaque", "ipsa", "quae", "ab", "illo",
"inventore", "veritatis", "quasi", "architecto", "beatae", "vitae", "dicta",
"explicabo", "nemo", "ipsam", "quia", "voluptas", "aspernatur", "aut", "odit",
"fugit", "consequuntur", "magni", "dolores", "eos", "ratione", "sequi", "nesciunt",
];
function randomWord(): string {
return LOREM_WORDS[Math.floor(Math.random() * LOREM_WORDS.length)];
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function generateSentence(wordCount: number): string {
const count = wordCount || (Math.floor(Math.random() * 10) + 6);
const words: string[] = [];
for (let i = 0; i < count; i++) {
words.push(randomWord());
}
words[0] = capitalize(words[0]);
return words.join(" ") + ".";
}
function generateParagraph(sentenceCount: number): string {
const count = sentenceCount || (Math.floor(Math.random() * 4) + 4);
const sentences: string[] = [];
for (let i = 0; i < count; i++) {
sentences.push(generateSentence(0));
}
return sentences.join(" ");
}
export async function runLoremIpsum(
_input: ToolInput,
options: LoremIpsumOptions,
context?: ToolContext
): Promise<ToolResult> {
void context;
const count = options.count || 3;
const result = (() => {
switch (options.type) {
case "words": {
const words: string[] = [];
for (let i = 0; i < count; i++) {
words.push(randomWord());
}
return words.join(" ");
}
case "sentences": {
const sentences: string[] = [];
for (let i = 0; i < count; i++) {
sentences.push(generateSentence(0));
}
return sentences.join(" ");
}
case "paragraphs":
default: {
const paragraphs: string[] = [];
for (let i = 0; i < count; i++) {
paragraphs.push(generateParagraph(0));
}
return paragraphs.join("\n\n");
}
}
})();
return {
type: "text",
value: result,
};
}

View File

@@ -0,0 +1,32 @@
import type { PlimiPlugin } from "../../core/plugins/plugin-types";
import { runMarkdownToHtml } from "./run";
export interface MarkdownToHtmlOptions {
headingStyle?: "atx" | "setext";
}
export const markdownToHtmlPlugin: PlimiPlugin<MarkdownToHtmlOptions> = {
manifest: {
id: "txt-markdown",
name: "Markdown to HTML",
description: "Convert Markdown text to raw HTML without sending anything to a server.",
category: "text",
version: "1.0.0",
tags: ["markdown", "html", "convert", "md"],
input: { type: "text", placeholder: "Type or paste Markdown here..." },
output: { type: "text" },
example: "# Hello World\n\nThis is **bold** and *italic*.\n\n- Item 1\n- Item 2\n\n[Visit Plimi](https://plimi.app)",
offlineReady: true,
},
optionsSchema: {
fields: [],
},
capabilities: {
cancelable: false,
worker: false,
},
run: runMarkdownToHtml,
};

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, vi } from "vitest";
import { runMarkdownToHtml } from "./run";
import type { ToolContext } from "../../core/plugins/plugin-types";
describe("Markdown to HTML Converter", () => {
const mockContext: ToolContext = {
signal: new AbortController().signal,
reportProgress: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
};
it("should convert headings h1, h2, h3", async () => {
const result = await runMarkdownToHtml(
{ text: "# Hello\n## World\n### Sub" },
{},
mockContext
);
expect(result.type).toBe("text");
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("<h1>Hello</h1>");
expect(value).toContain("<h2>World</h2>");
expect(value).toContain("<h3>Sub</h3>");
});
it("should convert bold and italic text", async () => {
const result = await runMarkdownToHtml(
{ text: "This is **bold** and *italic* and ***both***" },
{},
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("<strong>bold</strong>");
expect(value).toContain("<em>italic</em>");
expect(value).toContain("<strong><em>both</em></strong>");
});
it("should convert inline code", async () => {
const result = await runMarkdownToHtml(
{ text: "Use `console.log` to debug" },
{},
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("<code>console.log</code>");
});
it("should convert links", async () => {
const result = await runMarkdownToHtml(
{ text: "Visit [Google](https://google.com)" },
{},
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain('<a href="https://google.com">Google</a>');
});
it("should convert images", async () => {
const result = await runMarkdownToHtml(
{ text: "![Alt text](image.png)" },
{},
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain('<img src="image.png" alt="Alt text">');
});
it("should convert horizontal rules", async () => {
const result = await runMarkdownToHtml(
{ text: "Above\n---\nBelow" },
{},
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("<hr>");
});
it("should convert unordered lists", async () => {
const result = await runMarkdownToHtml(
{ text: "- item 1\n- item 2\n- item 3" },
{},
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("<ul>");
expect(value).toContain("<li>item 1</li>");
expect(value).toContain("<li>item 2</li>");
expect(value).toContain("<li>item 3</li>");
expect(value).toContain("</ul>");
});
it("should convert strikethrough", async () => {
const result = await runMarkdownToHtml(
{ text: "This is ~~deleted~~ text" },
{},
mockContext
);
const value = (result as { type: "text"; value: string }).value;
expect(value).toContain("<del>deleted</del>");
});
it("should throw on empty input", async () => {
await expect(
runMarkdownToHtml({ text: "" }, {}, mockContext)
).rejects.toThrow("No Markdown text provided");
});
});

View File

@@ -0,0 +1,65 @@
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 { MarkdownToHtmlOptions } from "./index";
function convertMarkdown(md: string): string {
let html = md;
html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
html = html.replace(/\*\*\*(.+?)\*\*\*/g, "<strong><em>$1</em></strong>");
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
html = html.replace(/~~(.+?)~~/g, "<del>$1</del>");
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
html = html.replace(/^>\s(.+)$/gm, "<blockquote>$1</blockquote>");
html = html.replace(/^---$/gm, "<hr>");
html = html.replace(/^\s*[-*+]\s(.+)$/gm, "<li>$1</li>");
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => `<ul>\n${match}</ul>\n`);
html = html.replace(/^\s*\d+\.\s(.+)$/gm, "<li>$1</li>");
html = html.replace(/\n{2,}/g, "\n</p>\n<p>\n");
html = `<p>${html}</p>`;
html = html.replace(/<p>\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(/<p>\s*<\/p>/g, "");
return html.trim();
}
export async function runMarkdownToHtml(
input: ToolInput,
_options: MarkdownToHtmlOptions,
context: ToolContext
): Promise<ToolResult> {
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",
};
}

View File

@@ -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<NumberBaseOptions> = {
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,
};

View File

@@ -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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).value;
expect(value.decimal).toBe("0");
expect(value.binary).toBe("0b0");
});
});

View File

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

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