feat: initial commit — ASCIInator v0.1

Vue 3 + Vite + Tailwind frontend, Fastify API server, execa shell bridge.
All components built, server validated, 41 unit tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dev 2026-04-28 19:47:57 +00:00
commit c1cf06ed67
45 changed files with 9836 additions and 0 deletions

8
.editorconfig Normal file
View File

@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
test-results/
playwright-report/

10
.oxlintrc.json Normal file
View File

@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "unicorn", "oxc", "vue", "vitest"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}

8
.prettierrc.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"printWidth": 100,
"vueIndentScriptAndStyle": false
}

11
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"ms-playwright.playwright",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}

342
CLAUDE.md Normal file
View File

@ -0,0 +1,342 @@
# ASCIInator — CLAUDE.md
> Project spec for Claude Code. Read this fully before touching any file.
> Stack: Vue 3 + Vite + Tailwind (no component lib) + Fastify + execa
> Purpose: Headless GUI front-end for system ASCII conversion binaries.
---
## 1. Project Overview
ASCIInator converts images to ASCII art by orchestrating system binaries
(chafa, jp2a, ascii-image-converter, img2txt) via ImageMagick preprocessing.
It is a **pure front-end UI** that talks to a **local Fastify API server**
which shells out to the binaries via `execa`.
Output format: **plain text**
Input methods: file upload, clipboard paste, drag-and-drop
Theme: dark default, respects `prefers-color-scheme`
CSS: Tailwind utility classes only — no component libraries, no Vuetify, no PrimeVue
---
## 2. Repository Structure
```
asciinator/
├── CLAUDE.md ← you are here
├── package.json ← root (workspaces)
├── vite.config.js
├── tailwind.config.js
├── index.html
├── src/
│ ├── main.js
│ ├── App.vue
│ ├── assets/
│ │ └── main.css ← Tailwind base + custom properties
│ └── components/
│ ├── ImageInput.vue ← Component 1: input (upload / paste / drag-drop)
│ ├── ToolSelector.vue ← Component 2: ASCII tool picker + per-tool options
│ ├── ShellBridge.vue ← Component 3: translates GUI state → API call → shell
│ ├── OutputDisplay.vue ← Component 4: renders plain-text ASCII result
│ ├── ErrorLog.vue ← Component 5: watches and surfaces stderr / API errors
│ └── options/ ← Per-tool flag panels (extracted from ToolSelector)
│ ├── ChafaOptions.vue
│ ├── Jp2aOptions.vue
│ ├── AsciiOptions.vue
│ └── ImgTxtOptions.vue
└── server/
├── index.js ← Fastify server entry
├── routes/
│ └── convert.js ← POST /convert route
└── lib/
├── imagemagick.js ← ImageMagick pre/post processing via execa
└── converters.js ← per-tool execa invocations
```
---
## 3. Component Architecture
### 3.1 `ImageInput.vue`
- Accepts: file picker, drag-and-drop onto drop zone, clipboard paste (`Ctrl+V`)
- Emits: `image-ready` with a `{ dataUrl, mimeType, filename }` payload
- Sends raw file to server as `multipart/form-data`
- Shows image preview thumbnail before conversion
- Validates: image/* MIME types only; max 20 MB
### 3.2 `ToolSelector.vue`
- Provides a segmented control to choose between:
- `chafa` (default)
- `jp2a`
- `ascii-image-converter`
- `img2txt`
- Each tool reveals its own option panel when selected
- **chafa options** (full flag exposure):
- `--width` / `--height` (integer inputs — combined server-side into `--size=WxH`)
- `--colors` (none | 2 | 8 | 16/8 | 16 | 240 | 256 | full)
- `--symbols` (text input, e.g. `block+border+extra`)
- `--dither` (none | ordered | diffusion | noise)
- `--threshold` (0.01.0 slider)
- `--font-ratio` (float input)
- **jp2a options**: `--width`, `--height`, `--chars`, `--background`
- **ascii-image-converter options**: `--width`, `--height`, `--color`, `--braille`, `--threshold`
- **img2txt options**: `--width`, `--height`, `--format` (ansi/utf8/html), `--dither`, `--gamma`
- Emits: `tool-config` with `{ tool: string, flags: Record<string, string|number|boolean> }`
### 3.3 `ShellBridge.vue`
- Renderless component (`<template><slot /></template>`)
- Props: `image` (`{ dataUrl, mimeType, filename }`), `toolConfig` (`{ tool, flags }`)
- Exposes: `convert()` — called via template ref from App.vue on button click
- Converts `dataUrl` back to Blob via `fetch()`, sends as `multipart/form-data`
- POSTs to `/api/convert` (proxied by Vite dev server to `http://localhost:3001/convert`)
- Emits: `conversion-result` (stdout string) | `conversion-error` (stderr string)
- Handles: 30s timeout via `AbortController`, network errors, non-2xx responses
### 3.4 `OutputDisplay.vue`
- Receives plain-text ASCII output string as prop
- Renders in a `<pre>` block with monospace font (JetBrains Mono or Fira Code via CDN)
- Controls: copy-to-clipboard button, download as `.txt`, font-size slider
- Preserves whitespace exactly — never reformat or trim output
### 3.5 `ErrorLog.vue`
- Receives array of `{ timestamp, source, message }` error objects as prop
- Collapsible panel, pinned to bottom of viewport
- Color-coded: yellow = warning, red = error
- Shows last 50 entries, auto-scrolls to latest
- Clear button
---
## 4. Fastify Server (`server/`)
### Entry: `server/index.js`
```js
// Fastify + @fastify/multipart
// Listens on localhost:3001
// Single route: POST /convert
// CORS: origin: true (reflects requester — permissive for local dev)
// Logger: warn level only (startup logged via console.log)
```
### Route: `POST /convert`
1. Parse multipart body — extract image file + JSON fields (`tool`, `flags`)
2. Write image to temp file (`/tmp/asciinator-<uuid>.<ext>`)
3. Pass through ImageMagick preprocessing (see §4.1)
4. Invoke selected tool via `converters.js`
5. Stream stdout back as `text/plain`
6. Clean up temp files in `finally`
7. On non-zero exit: return 422 with stderr body
### 4.1 ImageMagick Pipeline (`lib/imagemagick.js`)
**Preprocessing** (always runs before ASCII tool):
- Convert to PNG by default, JPEG for jp2a (`magick input.x output.{png,jpg}`)
- Strip metadata (`-strip`)
- Auto-orient (`-auto-orient`)
- Resize to max 2000px on longest side if larger (`-resize 2000x2000>`)
- `jp2a` receives JPEG — it links against libjpeg and rejects PNG input
**Post-processing** (optional, future hook — stub it now):
- Reserved for potential brightness/contrast adjustment on output
### 4.2 Converters (`lib/converters.js`)
Each converter is a function:
```js
async function runChafa(imagePath, flags) → { stdout, stderr }
async function runJp2a(imagePath, flags) → { stdout, stderr }
async function runAsciiImageConverter(imagePath, flags) → { stdout, stderr }
async function runImgToTxt(imagePath, flags) → { stdout, stderr }
```
- Use `execa` for all invocations
- Map the `flags` object to CLI args (e.g. `{ width: 80 }``['--width', '80']`)
- Validate flag values before constructing args — reject unknown flags
---
## 5. State Management
Use Vue 3 `reactive()` in `App.vue` — no Pinia for this scale.
```js
const state = reactive({
image: null, // { dataUrl, mimeType, filename }
toolConfig: { tool: 'chafa', flags: {} },
result: '', // plain text ASCII output
errors: [], // [{ timestamp, source, message }]
isConverting: false,
})
```
Pass state slices as props, bubble events up. No global store.
---
## 6. Styling Conventions
- **Tailwind only** — no inline styles, no external CSS except `src/assets/main.css`
- Always dark — `class="dark"` hardcoded on `<html>`. Light mode support planned (needs CSS variable overrides)
- CSS custom properties in `main.css` for brand tokens:
```css
:root {
--ascii-green: #39ff14; /* neon accent */
--ascii-bg: #0d0d0d;
--ascii-surface: #1a1a1a;
--ascii-border: #2a2a2a;
--ascii-text: #e0e0e0;
--ascii-error: #ff4444;
--ascii-warn: #ffaa00;
}
```
- Monospace font for all output: `font-family: 'JetBrains Mono', 'Fira Code', monospace`
- UI font: `font-family: 'IBM Plex Sans', sans-serif`
- Load both via Google Fonts / Bunny Fonts in `index.html`
**`@layer components` utility classes** (defined in `main.css` — use these in templates):
| Class | Purpose |
|-------|---------|
| `.ascii-input` | Dark-themed text input or select — full width, bordered, focus ring |
| `.ascii-label` | Column-flex label wrapper with muted text — wraps a `.ascii-input` |
| `.ascii-btn` | Ghost button — transparent bg, border, hover turns neon green |
| `.ascii-btn-primary` | Solid neon-green button — used for the main Convert action |
---
## 7. Neovim IDE v2 Setup
### 7.1 LSP & Tooling (expected config)
| Tool | Role |
|------|------|
| `volar` | Vue 3 LSP (use Takeover mode — disable `tsserver` for `.vue` files) |
| `eslint-lsp` | Linting via `eslint_d` or native LSP client |
| `prettier` | Formatting — `.prettierrc` at root |
| `emmet-ls` | HTML/template expansion in `.vue` files |
| `cssls` | Tailwind class intellisense (pair with `tailwindcss-language-server`) |
### 7.2 `.prettierrc`
```json
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"printWidth": 100,
"vueIndentScriptAndStyle": false
}
```
### 7.3 `.eslintrc.cjs`
```js
module.exports = {
extends: ['plugin:vue/vue3-recommended', 'prettier'],
rules: {
'vue/multi-word-component-names': 'off'
}
}
```
### 7.4 Neovim v2 Test Notes
This project is a test bed for **Neovim IDE v2.1.x**. Log observations here:
- [ ] Volar Takeover mode stability with Vite HMR running
- [ ] Tailwind intellisense triggering inside `class=""` in `.vue` templates
- [ ] `emmet-ls` expansion in `<template>` blocks
- [ ] ESLint auto-fix on save (`BufWritePre` autocmd)
- [ ] Prettier format on save — confirm it doesn't fight ESLint
- [ ] `gd` (go-to-definition) across `.vue` SFC boundaries
- [ ] Telescope file search respecting `.gitignore` (node_modules, dist)
- [ ] DAP (debugger) attach to Vite dev server — note any issues
- [ ] Terminal split for running Fastify server alongside Vite
**Add test results below as you work:**
```
DATE | COMPONENT | OBSERVATION | PASS/FAIL
```
---
## 8. Dev Workflow
```bash
# Install
npm install
# Start Fastify server (terminal 1)
node server/index.js
# Start Vite dev server (terminal 2)
npm run dev
# Both should be running simultaneously during development
# Vue: http://localhost:5173
# Fastify API: http://localhost:3001
```
### npm scripts (`package.json`)
```json
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"server": "node server/index.js",
"server:dev": "node --watch server/index.js",
"test:unit": "vitest",
"test:e2e": "playwright test",
"lint": "run-s lint:oxlint lint:eslint",
"format": "prettier --write --experimental-cli src/"
}
}
```
---
## 9. Key Constraints for Claude Code
- **Never** install a Vue component library (no Vuetify, Headless UI, PrimeVue, etc.)
- **Never** add TypeScript — this is a JS project
- **Never** use `Options API` — Composition API with `<script setup>` only
- **Never** add Pinia — use `reactive()` in App.vue
- **Never** use `localStorage` for anything
- **Always** use `execa` for shell invocations — never `exec()` or raw `spawn()`
- **Always** sanitize/validate flag values before building CLI args
- **Always** clean up `/tmp/asciinator-*` files after each conversion
- **Always** use `<script setup>` SFC format
- **Always** keep components under ~150 lines — extract composables if growing
- **Never** remove Vitest or Playwright — they were scaffolded by Vite and are part of the test bed
- **Unit tests** live in `src/__tests__/` (App) and `src/components/__tests__/` (components) — Vitest, not Jest
- **e2e tests** live in `e2e/` — Playwright
---
## 10. Dependencies
### All in root `package.json` (single package, no workspaces)
```json
{
"dependencies": {
"vue": "^3.5",
"fastify": "^5.x",
"@fastify/multipart": "^10.x",
"@fastify/cors": "^11.x",
"execa": "^9.x"
},
"devDependencies": {
"vite": "^8.x",
"@vitejs/plugin-vue": "^6.x",
"tailwindcss": "^3.x",
"autoprefixer": "^10.x",
"postcss": "^8.x",
"vitest": "^4.x",
"@playwright/test": "^1.x",
"@vue/test-utils": "^2.x",
"eslint": "^10.x",
"eslint-plugin-vue": "^10.x",
"oxlint": "^1.x",
"prettier": "^3.x"
}
}
```
---
*Last updated: 2026-04-28 — ASCIInator v0.1 (all components built)*

330
README.md Normal file
View File

@ -0,0 +1,330 @@
# ASCIInator
```
█████╗ ███████╗ ██████╗██╗██╗███╗ ██╗ █████╗ ████████╗ ██████╗ ██████╗
██╔══██╗██╔════╝██╔════╝██║██║████╗ ██║██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗
███████║███████╗██║ ██║██║██╔██╗ ██║███████║ ██║ ██║ ██║██████╔╝
██╔══██║╚════██║██║ ██║██║██║╚██╗██║██╔══██║ ██║ ██║ ██║██╔══██╗
██║ ██║███████║╚██████╗██║██║██║ ╚████║██║ ██║ ██║ ╚██████╔╝██║ ██║
╚═╝ ╚═╝╚══════╝ ╚═════╝╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
```
> Turn any image into ASCII art. Headless Vue 3 front-end for your system's ASCII conversion binaries.
---
## What It Does
ASCIInator is a local-first GUI wrapper around the ASCII conversion tools already on your system. Drop an image in, pick your tool, tune the flags, get plain-text ASCII out. No cloud, no SaaS, no telemetry — just your binaries, a thin Fastify bridge, and a Vue 3 interface.
Built as a test bed for **Neovim IDE v2.1.x**.
---
## Supported Converters
| Tool | Notes |
|------|-------|
| **chafa** | Default. Best quality, full flag exposure |
| **jp2a** | JPEG-focused, classic output |
| **ascii-image-converter** | Braille support, good for fine detail |
| **img2txt** | libcaca-based, multiple output formats |
All tools must be installed on your system. ASCIInator does not install them for you.
---
## Stack
| Layer | Tech |
|-------|------|
| Frontend | Vue 3 + Vite |
| Styling | Tailwind CSS (no component lib) |
| API server | Fastify |
| Shell invocation | execa |
| Image processing | ImageMagick v7 (`magick`) |
---
## Architecture
```
┌─────────────────────────────────────────┐
│ Vue 3 Frontend │
│ │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ ImageInput │ │ ToolSelector │ │
│ │ │ │ │ │
│ │ • Upload │ │ • chafa │ │
│ │ • Paste │ │ • jp2a │ │
│ │ • Drag-drop │ │ • ascii-i-c │ │
│ └──────┬──────┘ │ • img2txt │ │
│ │ └────────┬────────┘ │
│ └────────┬──────────┘ │
│ ▼ │
│ ┌───────────────┐ │
│ │ ShellBridge │ (renderless) │
│ └───────┬───────┘ │
│ │ POST /convert │
└──────────────────┼──────────────────────┘
┌──────────────────┼──────────────────────┐
│ Fastify :3001 │ │
│ ▼ │
│ ImageMagick preprocessing │
│ ↓ │
│ execa → binary │
│ ↓ │
│ stdout → response │
└─────────────────────────────────────────┘
┌──────────────────┼──────────────────────┐
│ Vue 3 Frontend │
│ │
│ ┌────────────────────┐ ┌───────────┐ │
│ │ OutputDisplay │ │ ErrorLog │ │
│ │ │ │ │ │
│ │ <pre> ASCII art │ │ stderr │ │
│ │ copy / download │ │ warnings │ │
│ └────────────────────┘ └───────────┘ │
└─────────────────────────────────────────┘
```
---
## Prerequisites
**System binaries** (install via your package manager):
```bash
# macOS
brew install chafa jp2a libcaca imagemagick
brew install ascii-image-converter
# Debian/Ubuntu
apt install chafa jp2a caca-utils imagemagick
# ascii-image-converter: see https://github.com/TheZoraiz/ascii-image-converter
# Arch
pacman -S chafa jp2a libcaca imagemagick
yay -S ascii-image-converter # AUR
```
**Node** — v20.19+ or v22.12+ required
**Vite** — installed via npm
---
## Installation
```bash
git clone <repo-url>
cd asciinator
npm install
```
---
## IDE Setup
**Neovim** (primary — this is a Neovim v2.1.x test bed):
- Volar LSP in Takeover mode (disable tsserver for `.vue` files)
- `tailwindcss-language-server` for class intellisense
- `eslint-lsp` + `prettier` for format on save
- See `CLAUDE.md §7` for the full Neovim config checklist
**Browser DevTools:**
- [Vue DevTools for Chrome/Brave](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Vue DevTools for Firefox](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- Enable Custom Object Formatters in DevTools for readable Vue reactive state
---
## Running
You need two terminals (or one split — you're using Neovim, you know the drill).
```bash
# Terminal 1 — Fastify API server
node server/index.js
# Terminal 2 — Vite dev server
npm run dev
```
| Service | URL |
|---------|-----|
| Vue app | http://localhost:5173 |
| Fastify API | http://localhost:3001 |
---
## Project Structure
```
asciinator/
├── CLAUDE.md ← Full spec for Claude Code
├── README.md ← You are here
├── index.html
├── vite.config.js
├── tailwind.config.js
├── src/
│ ├── main.js
│ ├── App.vue
│ ├── assets/
│ │ └── main.css
│ └── components/
│ ├── ImageInput.vue ← Upload / paste / drag-drop
│ ├── ToolSelector.vue ← Tool picker + flag controls
│ ├── ShellBridge.vue ← GUI state → API call (renderless)
│ ├── OutputDisplay.vue ← Plain-text ASCII result
│ ├── ErrorLog.vue ← stderr / error surface
│ └── options/
│ ├── ChafaOptions.vue
│ ├── Jp2aOptions.vue
│ ├── AsciiOptions.vue
│ └── ImgTxtOptions.vue
└── server/
├── index.js
├── routes/
│ └── convert.js ← POST /convert handler
└── lib/
├── imagemagick.js ← Pre/post processing
└── converters.js ← Per-tool execa invocations
```
---
## npm Scripts
```bash
npm run dev # Vite dev server (HMR)
npm run build # Production build
npm run preview # Preview production build
npm run server # Fastify API server
npm run server:dev # Fastify with --watch (auto-restart)
npm run lint # oxlint + ESLint (with autofix)
npm run format # Prettier
npm run test:unit # Vitest unit tests
npm run test:e2e # Playwright end-to-end tests
```
For e2e tests, build first if running on CI:
```bash
npm run build
npm run test:e2e
npm run test:e2e -- --project=chromium # Chromium only
npm run test:e2e -- --debug # Debug mode
```
---
## API
### `POST /convert`
**Request** — `multipart/form-data`
| Field | Type | Description |
|-------|------|-------------|
| `image` | file | Image file to convert |
| `tool` | string | `chafa` \| `jp2a` \| `ascii-image-converter` \| `img2txt` |
| `flags` | JSON string | Tool-specific flags object |
**Response** — `text/plain` — raw ASCII output on success, stderr on 422.
**curl example:**
```bash
curl -s -X POST http://localhost:3001/convert \
-F "image=@/path/to/photo.jpg" \
-F "tool=chafa" \
-F 'flags={"width":"80","colors":"256"}'
```
---
## Flag Reference
All flags are optional. Omitting a flag lets the binary use its own default.
### chafa
| Flag | Type | Values | Description |
|------|------|--------|-------------|
| `width` | integer | e.g. `80` | Output width in characters (combined into `--size=WxH`) |
| `height` | integer | e.g. `40` | Output height in characters (combined into `--size=WxH`) |
| `colors` | enum | `none` `2` `8` `16/8` `16` `240` `256` `full` | Color depth |
| `symbols` | string | e.g. `block+border+extra` | Symbol sets to use |
| `dither` | enum | `none` `ordered` `diffusion` `noise` | Dithering algorithm |
| `threshold` | float | `0.0``1.0` | Alpha threshold |
| `font-ratio` | float | e.g. `0.5` | Terminal font width/height ratio |
### jp2a
| Flag | Type | Values | Description |
|------|------|--------|-------------|
| `width` | integer | e.g. `80` | Output width in characters |
| `height` | integer | e.g. `40` | Output height in characters |
| `background` | enum | `light` `dark` | Assumed terminal background |
| `chars` | string | e.g. `.:-=+*#%@` | Character ramp to use |
### ascii-image-converter
| Flag | Type | Values | Description |
|------|------|--------|-------------|
| `width` | integer | e.g. `80` | Output width in characters |
| `height` | integer | e.g. `40` | Output height in characters |
| `color` | boolean | `true` | Emit ANSI color codes |
| `braille` | boolean | `true` | Use braille dot patterns |
| `threshold` | integer | `0``255` | Brightness threshold |
### img2txt
| Flag | Type | Values | Description |
|------|------|--------|-------------|
| `width` | integer | e.g. `80` | Output width in characters |
| `height` | integer | e.g. `40` | Output height in characters |
| `format` | enum | `ansi` `utf8` `html` | Output format |
| `dither` | string | e.g. `fstein` | Dithering mode |
| `gamma` | float | e.g. `1.0` | Gamma correction |
---
## Troubleshooting
**`command not found: chafa` (or jp2a, img2txt, ascii-image-converter)**
The binary isn't on PATH. Install it via your package manager — see Prerequisites above.
**`convert: command not found`**
ImageMagick isn't installed. It's required for all conversions regardless of tool.
**CORS error in the browser console**
The Fastify server isn't running, or it's on a different port. Start it with `npm run server` and confirm it logs `Server listening at http://127.0.0.1:3001`.
**422 from the server**
The binary returned a non-zero exit code. The full stderr is in the response body and shown in the ErrorLog panel.
**Blank or truncated output**
Some tools default to terminal width detection which may return 0 in a non-TTY context. Set an explicit `--width` flag.
---
## Design
- Always dark — light mode not wired (planned)
- No component library — raw Tailwind only
- Monospace output: JetBrains Mono
- UI font: IBM Plex Sans
- Accent: `#39ff14` (neon green — because ASCII)
---
## Neovim v2 Test Bed
This project exists partly to stress-test the **Neovim IDE v2.1.x** setup across a real Vue 3 + JS project. See `CLAUDE.md §7` for the full test checklist covering Volar, Tailwind LSP, ESLint, DAP, and more.
---
## License
MIT

23
e2e/vue.spec.js Normal file
View File

@ -0,0 +1,23 @@
import { test, expect } from '@playwright/test'
test('page loads with correct title', async ({ page }) => {
await page.goto('/')
await expect(page.locator('h1')).toHaveText('ASCIInator')
})
test('drop zone is visible', async ({ page }) => {
await page.goto('/')
await expect(page.getByText('Drop, paste (Ctrl+V), or click to upload')).toBeVisible()
})
test('four tool buttons are rendered', async ({ page }) => {
await page.goto('/')
for (const label of ['chafa', 'jp2a', 'ascii-image-converter', 'img2txt']) {
await expect(page.getByRole('button', { name: label })).toBeVisible()
}
})
test('convert button is disabled until an image is loaded', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('button', { name: 'Convert' })).toBeDisabled()
})

42
eslint.config.js Normal file
View File

@ -0,0 +1,42 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import pluginPlaywright from 'eslint-plugin-playwright'
import pluginVitest from '@vitest/eslint-plugin'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
export default defineConfig([
{
name: 'app/files-to-lint',
files: ['**/*.{vue,js,mjs,jsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
{
languageOptions: {
globals: {
...globals.browser,
},
},
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
{
...pluginPlaywright.configs['flat/recommended'],
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
])

18
index.html Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ASCIInator</title>
<link rel="preconnect" href="https://fonts.bunny.net" />
<link
href="https://fonts.bunny.net/css?family=ibm-plex-sans:400,500,600|jetbrains-mono:400,500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
jsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

7521
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "asciinator",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"server": "node server/index.js",
"server:dev": "node --watch server/index.js",
"lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/multipart": "^10.0.0",
"execa": "^9.6.1",
"fastify": "^5.8.5",
"vue": "^3.5.32"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.59.1",
"@vitejs/plugin-vue": "^6.0.6",
"@vitest/eslint-plugin": "^1.6.16",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.5.0",
"eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.60.0",
"eslint-plugin-playwright": "^2.10.1",
"eslint-plugin-vue": "~10.8.0",
"globals": "^17.5.0",
"jsdom": "^29.0.2",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.60.0",
"postcss": "^8.5.12",
"prettier": "3.8.3",
"tailwindcss": "^3.4.19",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1",
"vitest": "^4.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}

110
playwright.config.js Normal file
View File

@ -0,0 +1,110 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Only on CI systems run the tests headless */
headless: !!process.env.CI,
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
webServer: {
/**
* Use the dev server by default for faster feedback loop.
* Use the preview server on CI for more realistic testing.
* Playwright will re-use the local server if there is already a dev-server running.
*/
command: process.env.CI ? 'npm run preview' : 'npm run dev',
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
})

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { buildArgs } from '../lib/converters.js'
describe('buildArgs', () => {
it('returns empty array for empty flags', () => {
expect(buildArgs('chafa', {})).toEqual([])
})
it('maps value flags to --key value pairs', () => {
expect(buildArgs('chafa', { size: '80x40' })).toEqual(['--size', '80x40'])
})
it('includes bool flag only when true', () => {
expect(buildArgs('ascii-image-converter', { color: true })).toContain('--color')
expect(buildArgs('ascii-image-converter', { color: false })).not.toContain('--color')
})
it('rejects unknown flags', () => {
expect(() => buildArgs('chafa', { evil: 'value' })).toThrow('Unknown flag')
})
it('rejects invalid enum values', () => {
expect(() => buildArgs('chafa', { colors: 'invalid' })).toThrow('Invalid value')
})
it('accepts valid enum values', () => {
expect(() => buildArgs('chafa', { colors: '256' })).not.toThrow()
expect(() => buildArgs('chafa', { colors: '16/8' })).not.toThrow()
expect(() => buildArgs('chafa', { dither: 'noise' })).not.toThrow()
expect(() => buildArgs('jp2a', { background: 'dark' })).not.toThrow()
expect(() => buildArgs('img2txt', { format: 'ansi' })).not.toThrow()
})
it('throws on unknown tool', () => {
expect(() => buildArgs('malicious-tool', {})).toThrow('Unknown tool')
})
it('stringifies numeric values', () => {
const args = buildArgs('chafa', { size: 80 })
expect(args).toEqual(['--size', '80'])
})
it('handles font-ratio hyphenated key', () => {
const args = buildArgs('chafa', { 'font-ratio': '0.5' })
expect(args).toEqual(['--font-ratio', '0.5'])
})
})

View File

@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import path from 'node:path'
const SAFE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.avif'])
function sanitizeExt(filename) {
const rawExt = path.extname(filename || '').toLowerCase()
return SAFE_EXTS.has(rawExt) ? rawExt : '.jpg'
}
describe('extension sanitization', () => {
it('allows known image extensions', () => {
expect(sanitizeExt('photo.png')).toBe('.png')
expect(sanitizeExt('photo.jpg')).toBe('.jpg')
expect(sanitizeExt('photo.WEBP')).toBe('.webp')
})
it('falls back to .jpg for dangerous extensions', () => {
expect(sanitizeExt('exploit.svg')).toBe('.jpg')
expect(sanitizeExt('exploit.mvg')).toBe('.jpg')
expect(sanitizeExt('exploit.msl')).toBe('.jpg')
expect(sanitizeExt('exploit.sh')).toBe('.jpg')
expect(sanitizeExt('exploit.eps')).toBe('.jpg')
})
it('falls back to .jpg for missing or empty filename', () => {
expect(sanitizeExt('')).toBe('.jpg')
expect(sanitizeExt(null)).toBe('.jpg')
})
it('ignores path components — only extension is used', () => {
expect(sanitizeExt('../../etc/passwd.png')).toBe('.png')
expect(sanitizeExt('../../etc/passwd')).toBe('.jpg')
})
})

22
server/index.js Normal file
View File

@ -0,0 +1,22 @@
import Fastify from 'fastify'
import multipart from '@fastify/multipart'
import cors from '@fastify/cors'
import { convertRoute } from './routes/convert.js'
const fastify = Fastify({ logger: { level: 'warn' } })
await fastify.register(cors, { origin: true })
await fastify.register(multipart, {
limits: { fileSize: 20 * 1024 * 1024 },
})
await fastify.register(convertRoute)
try {
await fastify.listen({ port: 3001, host: '127.0.0.1' })
console.log('ASCIInator API → http://127.0.0.1:3001')
} catch (err) {
fastify.log.error(err)
process.exit(1)
}

79
server/lib/converters.js Normal file
View File

@ -0,0 +1,79 @@
import { execa } from 'execa'
const TIMEOUT = 30_000
const SCHEMAS = {
chafa: {
size: { type: 'value' },
colors: { type: 'value', valid: ['none', '2', '8', '16/8', '16', '240', '256', 'full'] },
symbols: { type: 'value' },
dither: { type: 'value', valid: ['none', 'ordered', 'diffusion', 'noise'] },
threshold: { type: 'value' },
'font-ratio': { type: 'value' },
},
jp2a: {
width: { type: 'value' },
height: { type: 'value' },
chars: { type: 'value' },
background: { type: 'value', valid: ['light', 'dark'] },
},
'ascii-image-converter': {
width: { type: 'value' },
height: { type: 'value' },
color: { type: 'bool' },
braille: { type: 'bool' },
threshold: { type: 'value' },
},
img2txt: {
width: { type: 'value' },
height: { type: 'value' },
format: { type: 'value', valid: ['ansi', 'utf8', 'html'] },
dither: { type: 'value' },
gamma: { type: 'value' },
},
}
export function buildArgs(tool, flags) {
const schema = SCHEMAS[tool]
if (!schema) throw new Error(`Unknown tool: ${tool}`)
const args = []
for (const [key, value] of Object.entries(flags)) {
const def = schema[key]
if (!def) throw new Error(`Unknown flag for ${tool}: --${key}`)
if (def.valid && !def.valid.includes(String(value))) {
throw new Error(`Invalid value for --${key}: ${value}. Expected one of: ${def.valid.join(', ')}`)
}
if (def.type === 'bool') {
if (value) args.push(`--${key}`)
} else {
args.push(`--${key}`, String(value))
}
}
return args
}
export async function runChafa(imagePath, flags) {
const { width, height, ...rest } = flags
const processed = { ...rest }
if (width && height) processed.size = `${width}x${height}`
else if (width) processed.size = `${width}x`
else if (height) processed.size = `x${height}`
const args = buildArgs('chafa', processed)
return execa('chafa', [...args, imagePath], { timeout: TIMEOUT })
}
export async function runJp2a(imagePath, flags) {
const args = buildArgs('jp2a', flags)
return execa('jp2a', [...args, imagePath], { timeout: TIMEOUT })
}
export async function runAsciiImageConverter(imagePath, flags) {
const args = buildArgs('ascii-image-converter', flags)
return execa('ascii-image-converter', [...args, imagePath], { timeout: TIMEOUT })
}
export async function runImgToTxt(imagePath, flags) {
const args = buildArgs('img2txt', flags)
return execa('img2txt', [...args, imagePath], { timeout: TIMEOUT })
}

17
server/lib/imagemagick.js Normal file
View File

@ -0,0 +1,17 @@
import { execa } from 'execa'
import { randomUUID } from 'node:crypto'
export async function preprocess(inputPath, format = 'png') {
const outputPath = `/tmp/asciinator-${randomUUID()}-processed.${format}`
await execa('magick', [
inputPath,
'-strip',
'-auto-orient',
'-resize', '2000x2000>',
outputPath,
])
return outputPath
}
// Reserved for future brightness/contrast post-processing
export async function postprocess(_imagePath) {}

66
server/routes/convert.js Normal file
View File

@ -0,0 +1,66 @@
import { randomUUID } from 'node:crypto'
import { writeFile, unlink } from 'node:fs/promises'
import path from 'node:path'
import { preprocess } from '../lib/imagemagick.js'
import {
runChafa,
runJp2a,
runAsciiImageConverter,
runImgToTxt,
} from '../lib/converters.js'
const RUNNERS = {
chafa: runChafa,
jp2a: runJp2a,
'ascii-image-converter': runAsciiImageConverter,
img2txt: runImgToTxt,
}
export async function convertRoute(fastify) {
fastify.post('/convert', async (request, reply) => {
let tempInput = null
let tempProcessed = null
try {
const parts = request.parts()
let tool = null
let flags = {}
for await (const part of parts) {
if (part.type === 'file' && part.fieldname === 'image') {
const SAFE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.avif'])
const rawExt = path.extname(part.filename || '').toLowerCase()
const ext = SAFE_EXTS.has(rawExt) ? rawExt : '.jpg'
tempInput = `/tmp/asciinator-${randomUUID()}${ext}`
const buf = await part.toBuffer()
await writeFile(tempInput, buf)
} else if (part.type === 'field') {
if (part.fieldname === 'tool') tool = part.value
if (part.fieldname === 'flags') {
try { flags = JSON.parse(part.value) } catch { flags = {} }
}
}
}
if (!tempInput) return reply.code(400).type('text/plain').send('No image provided')
if (!tool) return reply.code(400).type('text/plain').send('No tool specified')
const runner = RUNNERS[tool]
if (!runner) return reply.code(400).type('text/plain').send(`Unknown tool: ${tool}`)
const format = tool === 'jp2a' ? 'jpg' : 'png'
tempProcessed = await preprocess(tempInput, format)
const result = await runner(tempProcessed, flags)
return reply.type('text/plain').send(result.stdout)
} catch (err) {
if (err.exitCode !== undefined) {
return reply.code(422).type('text/plain').send(err.stderr || err.message)
}
return reply.code(400).type('text/plain').send(err.message)
} finally {
if (tempInput) await unlink(tempInput).catch(() => {})
if (tempProcessed) await unlink(tempProcessed).catch(() => {})
}
})
}

99
src/App.vue Normal file
View File

@ -0,0 +1,99 @@
<script setup>
import { reactive, ref } from 'vue'
import ImageInput from './components/ImageInput.vue'
import ToolSelector from './components/ToolSelector.vue'
import ShellBridge from './components/ShellBridge.vue'
import OutputDisplay from './components/OutputDisplay.vue'
import ErrorLog from './components/ErrorLog.vue'
const state = reactive({
image: null,
toolConfig: { tool: 'chafa', flags: {} },
result: '',
errors: [],
isConverting: false,
})
const bridge = ref(null)
function addError(message, source = 'error') {
state.errors.push({
timestamp: new Date().toLocaleTimeString(),
source,
message,
})
}
async function runConvert() {
if (!state.image) {
addError('No image selected.', 'warn')
return
}
state.isConverting = true
state.result = ''
try {
await bridge.value.convert()
} finally {
state.isConverting = false
}
}
function onResult(text) {
state.result = text
}
function onError(message) {
addError(message)
}
</script>
<template>
<div class="min-h-screen pb-20" style="background-color: var(--ascii-bg)">
<header class="px-6 py-4 border-b" style="border-color: var(--ascii-border)">
<h1
class="text-lg font-semibold tracking-widest font-mono"
style="color: var(--ascii-green)"
>
ASCIInator
</h1>
</header>
<main class="max-w-5xl mx-auto px-6 py-8 flex flex-col gap-8">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<section>
<h2 class="text-xs font-medium uppercase tracking-wider mb-3"
style="color: rgba(224,224,224,.35)">Image</h2>
<ImageInput @image-ready="state.image = $event" />
</section>
<section>
<h2 class="text-xs font-medium uppercase tracking-wider mb-3"
style="color: rgba(224,224,224,.35)">Tool &amp; Options</h2>
<ToolSelector @tool-config="state.toolConfig = $event" />
</section>
</div>
<div class="flex justify-center">
<button
class="ascii-btn-primary"
:disabled="state.isConverting || !state.image"
@click="runConvert"
>
{{ state.isConverting ? 'Converting...' : 'Convert' }}
</button>
</div>
<ShellBridge
ref="bridge"
:image="state.image"
:tool-config="state.toolConfig"
@conversion-result="onResult"
@conversion-error="onError"
/>
<OutputDisplay :result="state.result" />
</main>
<ErrorLog :errors="state.errors" @clear="state.errors = []" />
</div>
</template>

10
src/__tests__/App.spec.js Normal file
View File

@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import App from '../App.vue'
describe('App', () => {
it('mounts', () => {
const wrapper = shallowMount(App)
expect(wrapper.exists()).toBe(true)
})
})

79
src/assets/main.css Normal file
View File

@ -0,0 +1,79 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--ascii-green: #39ff14;
--ascii-bg: #0d0d0d;
--ascii-surface: #1a1a1a;
--ascii-border: #2a2a2a;
--ascii-text: #e0e0e0;
--ascii-error: #ff4444;
--ascii-warn: #ffaa00;
}
body {
background-color: var(--ascii-bg);
color: var(--ascii-text);
font-family: 'IBM Plex Sans', sans-serif;
}
@layer components {
.ascii-input {
width: 100%;
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
background-color: var(--ascii-surface);
border: 1px solid var(--ascii-border);
color: var(--ascii-text);
transition: border-color 0.15s;
}
.ascii-input:focus {
outline: none;
border-color: rgba(57, 255, 20, 0.35);
}
.ascii-label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
color: rgba(224, 224, 224, 0.55);
}
.ascii-btn {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 0.25rem;
border: 1px solid var(--ascii-border);
color: var(--ascii-text);
background: transparent;
transition: border-color 0.15s, color 0.15s;
cursor: pointer;
}
.ascii-btn:hover {
border-color: var(--ascii-green);
color: var(--ascii-green);
}
.ascii-btn-primary {
padding: 0.5rem 2rem;
font-size: 0.875rem;
font-weight: 600;
border-radius: 0.375rem;
border: none;
background-color: var(--ascii-green);
color: #0d0d0d;
cursor: pointer;
transition: opacity 0.15s;
}
.ascii-btn-primary:hover:not(:disabled) {
opacity: 0.85;
}
.ascii-btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}

View File

@ -0,0 +1,64 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps({
errors: { type: Array, default: () => [] },
})
defineEmits(['clear'])
const collapsed = ref(false)
const logEl = ref(null)
const recent = computed(() => props.errors.slice(-50))
watch(
() => props.errors.length,
async () => {
if (!collapsed.value) {
await nextTick()
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
}
},
)
</script>
<template>
<div
v-if="errors.length > 0"
class="fixed bottom-0 left-0 right-0 z-50"
style="background-color: var(--ascii-surface); border-top: 1px solid var(--ascii-border)"
>
<div class="flex items-center gap-2 px-4 py-2">
<span class="text-xs font-medium" style="color: var(--ascii-text)">
Errors ({{ errors.length }})
</span>
<button
class="text-xs ml-2"
style="color: rgba(224,224,224,.4)"
@click="collapsed = !collapsed"
>
{{ collapsed ? 'Show' : 'Hide' }}
</button>
<button
class="text-xs ml-auto"
style="color: rgba(224,224,224,.4)"
@click="$emit('clear')"
>
Clear
</button>
</div>
<div v-if="!collapsed" ref="logEl" class="max-h-36 overflow-y-auto px-4 pb-3 space-y-1">
<div
v-for="(err, i) in recent"
:key="i"
class="text-xs font-mono leading-snug"
:style="{ color: err.source === 'warn' ? 'var(--ascii-warn)' : 'var(--ascii-error)' }"
>
<span style="color: rgba(224,224,224,.3)">{{ err.timestamp }}</span>
<span class="mx-1" style="color: rgba(224,224,224,.2)">[{{ err.source }}]</span>
{{ err.message }}
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,89 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const emit = defineEmits(['image-ready'])
const preview = ref(null)
const filename = ref('')
const dragOver = ref(false)
const error = ref('')
function processFile(file) {
error.value = ''
if (!file || !file.type.startsWith('image/')) {
error.value = 'Only image files are supported.'
return
}
if (file.size > 20 * 1024 * 1024) {
error.value = 'File exceeds the 20 MB limit.'
return
}
const reader = new FileReader()
reader.onload = (e) => {
preview.value = e.target.result
filename.value = file.name
emit('image-ready', { dataUrl: e.target.result, mimeType: file.type, filename: file.name })
}
reader.readAsDataURL(file)
}
function onFileInput(e) {
const file = e.target.files[0]
if (file) processFile(file)
e.target.value = ''
}
function onDrop(e) {
dragOver.value = false
processFile(e.dataTransfer.files[0])
}
function onGlobalPaste(e) {
const item = Array.from(e.clipboardData?.items ?? []).find((i) => i.type.startsWith('image/'))
if (item) processFile(item.getAsFile())
}
onMounted(() => window.addEventListener('paste', onGlobalPaste))
onUnmounted(() => window.removeEventListener('paste', onGlobalPaste))
</script>
<template>
<div class="flex flex-col gap-2">
<label
class="relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 cursor-pointer transition-colors"
:style="{
borderColor: dragOver ? 'var(--ascii-green)' : 'var(--ascii-border)',
backgroundColor: dragOver ? 'rgba(57,255,20,.04)' : 'transparent',
}"
@dragover.prevent="dragOver = true"
@dragleave="dragOver = false"
@drop.prevent="onDrop"
>
<input type="file" accept="image/*" class="sr-only" @change="onFileInput" />
<template v-if="preview">
<img
:src="preview"
:alt="filename"
class="max-h-48 max-w-full rounded object-contain"
/>
<span class="text-xs" style="color: rgba(224,224,224,.4)">{{ filename }}</span>
<span class="text-xs" style="color: rgba(57,255,20,.6)">Click or drop to replace</span>
</template>
<template v-else>
<svg
xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.5"
style="color: rgba(224,224,224,.25)"
>
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<span class="text-sm" style="color: rgba(224,224,224,.45)">
Drop, paste (Ctrl+V), or click to upload
</span>
<span class="text-xs" style="color: rgba(224,224,224,.25)">image/* · max 20 MB</span>
</template>
</label>
<p v-if="error" class="text-xs" style="color: var(--ascii-error)">{{ error }}</p>
</div>
</template>

View File

@ -0,0 +1,56 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({
result: { type: String, default: '' },
})
const fontSize = ref(12)
const copied = ref(false)
async function copyToClipboard() {
await navigator.clipboard.writeText(props.result)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
function downloadTxt() {
const blob = new Blob([props.result], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'ascii-output.txt'
a.click()
URL.revokeObjectURL(url)
}
</script>
<template>
<div v-if="result" class="mt-6">
<div class="flex items-center gap-2 mb-2">
<button class="ascii-btn" @click="copyToClipboard">
{{ copied ? 'Copied!' : 'Copy' }}
</button>
<button class="ascii-btn" @click="downloadTxt">Download .txt</button>
<label class="flex items-center gap-2 text-xs ml-auto" style="color: rgba(224,224,224,.5)">
{{ fontSize }}px
<input
v-model.number="fontSize"
type="range" min="8" max="24" step="1"
class="w-24"
style="accent-color: var(--ascii-green)"
/>
</label>
</div>
<pre
class="rounded p-4 overflow-auto leading-tight whitespace-pre"
:style="{
fontSize: fontSize + 'px',
fontFamily: '\'JetBrains Mono\', \'Fira Code\', monospace',
backgroundColor: 'var(--ascii-surface)',
border: '1px solid var(--ascii-border)',
color: 'var(--ascii-text)',
}"
>{{ result }}</pre>
</div>
</template>

View File

@ -0,0 +1,44 @@
<script setup>
const props = defineProps({
image: { type: Object, default: null },
toolConfig: { type: Object, default: null },
})
const emit = defineEmits(['conversion-result', 'conversion-error'])
async function convert() {
if (!props.image || !props.toolConfig) return
const blob = await fetch(props.image.dataUrl).then((r) => r.blob())
const fd = new FormData()
fd.append('image', blob, props.image.filename)
fd.append('tool', props.toolConfig.tool)
fd.append('flags', JSON.stringify(props.toolConfig.flags))
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30_000)
try {
const res = await fetch('/api/convert', {
method: 'POST',
body: fd,
signal: controller.signal,
})
const text = await res.text()
if (!res.ok) {
emit('conversion-error', text)
} else {
emit('conversion-result', text)
}
} catch (err) {
emit('conversion-error', err.name === 'AbortError' ? 'Conversion timed out (30s)' : err.message)
} finally {
clearTimeout(timeoutId)
}
}
defineExpose({ convert })
</script>
<template><slot /></template>

View File

@ -0,0 +1,73 @@
<script setup>
import { ref, reactive, watch } from 'vue'
import ChafaOptions from './options/ChafaOptions.vue'
import Jp2aOptions from './options/Jp2aOptions.vue'
import AsciiOptions from './options/AsciiOptions.vue'
import ImgTxtOptions from './options/ImgTxtOptions.vue'
const emit = defineEmits(['tool-config'])
const TOOLS = [
{ id: 'chafa', label: 'chafa' },
{ id: 'jp2a', label: 'jp2a' },
{ id: 'ascii-image-converter', label: 'ascii-image-converter' },
{ id: 'img2txt', label: 'img2txt' },
]
const OPTIONS_MAP = {
chafa: ChafaOptions,
jp2a: Jp2aOptions,
'ascii-image-converter': AsciiOptions,
img2txt: ImgTxtOptions,
}
const selectedTool = ref('chafa')
const toolFlags = reactive({
chafa: { width: '', height: '', colors: '', symbols: '', dither: '', threshold: '', 'font-ratio': '' },
jp2a: { width: '', height: '', chars: '', background: '' },
'ascii-image-converter': { width: '', height: '', color: false, braille: false, threshold: '' },
img2txt: { width: '', height: '', format: '', dither: '', gamma: '' },
})
function onFlagsUpdate(newFlags) {
Object.assign(toolFlags[selectedTool.value], newFlags)
}
watch(
[selectedTool, toolFlags],
() => {
const flags = Object.fromEntries(
Object.entries(toolFlags[selectedTool.value]).filter(([, v]) => v !== '' && v !== false),
)
emit('tool-config', { tool: selectedTool.value, flags })
},
{ deep: true, immediate: true },
)
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap gap-1">
<button
v-for="t in TOOLS"
:key="t.id"
class="px-3 py-1 text-xs font-mono rounded border transition-colors"
:style="{
borderColor: selectedTool === t.id ? 'var(--ascii-green)' : 'var(--ascii-border)',
color: selectedTool === t.id ? 'var(--ascii-green)' : 'rgba(224,224,224,.5)',
backgroundColor: selectedTool === t.id ? 'rgba(57,255,20,.06)' : 'transparent',
}"
@click="selectedTool = t.id"
>
{{ t.label }}
</button>
</div>
<component
:is="OPTIONS_MAP[selectedTool]"
:flags="toolFlags[selectedTool]"
@update:flags="onFlagsUpdate"
/>
</div>
</template>

View File

@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ChafaOptions from '../options/ChafaOptions.vue'
const EMPTY_FLAGS = {
width: '', height: '', colors: '', symbols: '',
dither: '', threshold: '', 'font-ratio': '',
}
describe('ChafaOptions', () => {
it('renders without error', () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
expect(wrapper.exists()).toBe(true)
})
it('emits update:flags with updated value on input change', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
const widthInput = wrapper.find('input[type="number"]')
await widthInput.setValue('120')
const emitted = wrapper.emitted('update:flags')
expect(emitted).toBeTruthy()
expect(emitted[0][0].width).toBe('120')
})
it('preserves other flag values when one changes', async () => {
const flags = { ...EMPTY_FLAGS, colors: '256' }
const wrapper = mount(ChafaOptions, { props: { flags } })
const widthInput = wrapper.find('input[type="number"]')
await widthInput.setValue('80')
const emitted = wrapper.emitted('update:flags')
expect(emitted[0][0].colors).toBe('256')
})
it('emits update:flags with selected value on select change', async () => {
const wrapper = mount(ChafaOptions, { props: { flags: EMPTY_FLAGS } })
const select = wrapper.find('select')
await select.setValue('256')
const emitted = wrapper.emitted('update:flags')
expect(emitted[0][0].colors).toBe('256')
})
})

View File

@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ErrorLog from '../ErrorLog.vue'
function makeErrors(n, source = 'error') {
return Array.from({ length: n }, (_, i) => ({
timestamp: '12:00:00',
source,
message: `Error ${i}`,
}))
}
describe('ErrorLog', () => {
it('renders nothing when errors array is empty', () => {
const wrapper = mount(ErrorLog)
expect(wrapper.find('.fixed').exists()).toBe(false)
})
it('shows error count in header', () => {
const wrapper = mount(ErrorLog, { props: { errors: makeErrors(3) } })
expect(wrapper.text()).toContain('Errors (3)')
})
it('emits clear when clear button is clicked', async () => {
const wrapper = mount(ErrorLog, { props: { errors: makeErrors(1) } })
const clearBtn = wrapper.findAll('button').find((b) => b.text() === 'Clear')
await clearBtn.trigger('click')
expect(wrapper.emitted('clear')).toBeTruthy()
})
it('caps display at 50 entries', () => {
const wrapper = mount(ErrorLog, { props: { errors: makeErrors(60) } })
const entries = wrapper.findAll('.font-mono.leading-snug')
expect(entries.length).toBe(50)
})
it('toggles log visibility when hide/show is clicked', async () => {
const wrapper = mount(ErrorLog, { props: { errors: makeErrors(2) } })
const toggle = wrapper.findAll('button').find((b) => b.text() === 'Hide')
await toggle.trigger('click')
expect(wrapper.text()).toContain('Show')
expect(wrapper.findAll('.font-mono.leading-snug').length).toBe(0)
})
})

View File

@ -0,0 +1,70 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import ImageInput from '../ImageInput.vue'
function makeFile(name, type, size = 100) {
return new File(['x'.repeat(size)], name, { type })
}
function setFiles(input, files) {
Object.defineProperty(input.element, 'files', { value: files, configurable: true })
}
describe('ImageInput', () => {
afterEach(() => vi.unstubAllGlobals())
it('renders the drop zone and file input', () => {
const wrapper = mount(ImageInput)
expect(wrapper.find('label').exists()).toBe(true)
expect(wrapper.find('input[type="file"]').exists()).toBe(true)
})
it('shows error for non-image file', async () => {
const wrapper = mount(ImageInput)
const input = wrapper.find('input[type="file"]')
setFiles(input, [makeFile('doc.pdf', 'application/pdf')])
await input.trigger('change')
expect(wrapper.text()).toContain('Only image files are supported.')
})
it('shows error for file exceeding 20 MB', async () => {
const wrapper = mount(ImageInput)
const input = wrapper.find('input[type="file"]')
setFiles(input, [makeFile('big.png', 'image/png', 21 * 1024 * 1024)])
await input.trigger('change')
expect(wrapper.text()).toContain('20 MB')
})
it('emits image-ready with correct shape on valid image', async () => {
class MockFileReader {
readAsDataURL() {
this.onload({ target: { result: 'data:image/png;base64,abc' } })
}
}
vi.stubGlobal('FileReader', MockFileReader)
const wrapper = mount(ImageInput)
const input = wrapper.find('input[type="file"]')
setFiles(input, [makeFile('photo.png', 'image/png')])
await input.trigger('change')
const emitted = wrapper.emitted('image-ready')
expect(emitted).toBeTruthy()
expect(emitted[0][0]).toMatchObject({
dataUrl: 'data:image/png;base64,abc',
mimeType: 'image/png',
filename: 'photo.png',
})
})
it('attaches paste listener on mount and removes it on unmount', () => {
const add = vi.spyOn(window, 'addEventListener')
const remove = vi.spyOn(window, 'removeEventListener')
const wrapper = mount(ImageInput)
expect(add).toHaveBeenCalledWith('paste', expect.any(Function))
wrapper.unmount()
expect(remove).toHaveBeenCalledWith('paste', expect.any(Function))
})
})

View File

@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import OutputDisplay from '../OutputDisplay.vue'
describe('OutputDisplay', () => {
it('renders nothing when result is empty', () => {
const wrapper = mount(OutputDisplay)
expect(wrapper.find('pre').exists()).toBe(false)
})
it('renders pre block with result text', () => {
const wrapper = mount(OutputDisplay, { props: { result: 'hello ascii' } })
expect(wrapper.find('pre').text()).toBe('hello ascii')
})
it('shows copy and download buttons when result is provided', () => {
const wrapper = mount(OutputDisplay, { props: { result: 'art' } })
const buttons = wrapper.findAll('button')
expect(buttons.some((b) => b.text().includes('Copy'))).toBe(true)
expect(buttons.some((b) => b.text().includes('Download'))).toBe(true)
})
describe('copy to clipboard', () => {
beforeEach(() => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: vi.fn().mockResolvedValue(undefined) },
configurable: true,
writable: true,
})
})
afterEach(() => vi.restoreAllMocks())
it('calls clipboard.writeText with the result', async () => {
const wrapper = mount(OutputDisplay, { props: { result: 'copy me' } })
const copyBtn = wrapper.findAll('button').find((b) => b.text().includes('Copy'))
await copyBtn.trigger('click')
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('copy me')
})
})
})

View File

@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import ShellBridge from '../ShellBridge.vue'
const IMAGE = {
dataUrl: 'data:image/png;base64,abc',
mimeType: 'image/png',
filename: 'test.png',
}
const TOOL_CONFIG = { tool: 'chafa', flags: { width: '80' } }
function mockFetch(blobResponse, convertResponse) {
return vi.fn()
.mockResolvedValueOnce({ blob: vi.fn().mockResolvedValue(new Blob([''])) })
.mockResolvedValueOnce(convertResponse)
}
describe('ShellBridge', () => {
beforeEach(() => vi.stubGlobal('fetch', vi.fn()))
afterEach(() => vi.unstubAllGlobals())
it('does nothing if image prop is null', async () => {
const wrapper = mount(ShellBridge, { props: { image: null, toolConfig: TOOL_CONFIG } })
await wrapper.vm.convert()
expect(fetch).not.toHaveBeenCalled()
})
it('emits conversion-result with stdout on 200', async () => {
vi.stubGlobal(
'fetch',
mockFetch(null, { ok: true, text: vi.fn().mockResolvedValue('ascii art') }),
)
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
await wrapper.vm.convert()
expect(wrapper.emitted('conversion-result')?.[0][0]).toBe('ascii art')
})
it('emits conversion-error with body on non-2xx', async () => {
vi.stubGlobal(
'fetch',
mockFetch(null, { ok: false, text: vi.fn().mockResolvedValue('tool failed') }),
)
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
await wrapper.vm.convert()
expect(wrapper.emitted('conversion-error')?.[0][0]).toBe('tool failed')
})
it('emits conversion-error on network failure', async () => {
vi.stubGlobal(
'fetch',
vi.fn()
.mockResolvedValueOnce({ blob: vi.fn().mockResolvedValue(new Blob([''])) })
.mockRejectedValueOnce(new Error('Network error')),
)
const wrapper = mount(ShellBridge, { props: { image: IMAGE, toolConfig: TOOL_CONFIG } })
await wrapper.vm.convert()
expect(wrapper.emitted('conversion-error')?.[0][0]).toBe('Network error')
})
})

View File

@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ToolSelector from '../ToolSelector.vue'
describe('ToolSelector', () => {
it('renders four tool buttons', () => {
const wrapper = mount(ToolSelector)
expect(wrapper.findAll('button').length).toBe(4)
})
it('emits tool-config immediately with chafa as default', () => {
const wrapper = mount(ToolSelector)
const emitted = wrapper.emitted('tool-config')
expect(emitted).toBeTruthy()
expect(emitted[0][0].tool).toBe('chafa')
})
it('strips empty and false flag values before emitting', () => {
const wrapper = mount(ToolSelector)
const flags = wrapper.emitted('tool-config')[0][0].flags
const hasEmpty = Object.values(flags).some((v) => v === '' || v === false)
expect(hasEmpty).toBe(false)
})
it('switches active tool and emits updated tool-config', async () => {
const wrapper = mount(ToolSelector)
const buttons = wrapper.findAll('button')
await buttons[1].trigger('click') // jp2a
const emitted = wrapper.emitted('tool-config')
const latest = emitted[emitted.length - 1][0]
expect(latest.tool).toBe('jp2a')
})
it('preserves flags for a tool when switching away and back', async () => {
const wrapper = mount(ToolSelector)
const [chafaBtn, jp2aBtn] = wrapper.findAll('button')
// Set width on chafa via ChafaOptions input
const widthInput = wrapper.find('input[type="number"]')
await widthInput.setValue('120')
// Switch to jp2a and back
await jp2aBtn.trigger('click')
await chafaBtn.trigger('click')
const emitted = wrapper.emitted('tool-config')
const latest = emitted[emitted.length - 1][0]
expect(latest.flags.width).toBe('120')
})
})

View File

@ -0,0 +1,40 @@
<script setup>
const props = defineProps({ flags: { type: Object, required: true } })
const emit = defineEmits(['update:flags'])
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
</script>
<template>
<div class="grid grid-cols-2 gap-3">
<label class="ascii-label">
Width
<input class="ascii-input" type="number" min="1" :value="flags.width"
@input="set('width', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label">
Height
<input class="ascii-input" type="number" min="1" :value="flags.height"
@input="set('height', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label">
Threshold (0255)
<input class="ascii-input" type="number" min="0" max="255" :value="flags.threshold"
@input="set('threshold', $event.target.value)" placeholder="128" />
</label>
<div class="ascii-label justify-center gap-3 flex-row items-center col-span-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" :checked="flags.color"
@change="set('color', $event.target.checked)"
style="accent-color: var(--ascii-green)" />
Color
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" :checked="flags.braille"
@change="set('braille', $event.target.checked)"
style="accent-color: var(--ascii-green)" />
Braille
</label>
</div>
</div>
</template>

View File

@ -0,0 +1,52 @@
<script setup>
const props = defineProps({ flags: { type: Object, required: true } })
const emit = defineEmits(['update:flags'])
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
</script>
<template>
<div class="grid grid-cols-2 gap-3">
<label class="ascii-label">
Width
<input class="ascii-input" type="number" min="1" :value="flags.width"
@input="set('width', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label">
Height
<input class="ascii-input" type="number" min="1" :value="flags.height"
@input="set('height', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label">
Colors
<select class="ascii-input" :value="flags.colors" @change="set('colors', $event.target.value)">
<option value="">default</option>
<option>none</option><option>2</option><option>8</option>
<option value="16/8">16/8</option><option>16</option>
<option>240</option><option>256</option><option>full</option>
</select>
</label>
<label class="ascii-label">
Dither
<select class="ascii-input" :value="flags.dither" @change="set('dither', $event.target.value)">
<option value="">default</option>
<option>none</option><option>ordered</option><option>diffusion</option><option>noise</option>
</select>
</label>
<label class="ascii-label">
Symbols
<input class="ascii-input" type="text" :value="flags.symbols"
@input="set('symbols', $event.target.value)" placeholder="e.g. block+border" />
</label>
<label class="ascii-label">
Threshold (01)
<input class="ascii-input" type="number" min="0" max="1" step="0.05" :value="flags.threshold"
@input="set('threshold', $event.target.value)" placeholder="0.5" />
</label>
<label class="ascii-label col-span-2">
Font ratio
<input class="ascii-input" type="number" min="0.1" step="0.05" :value="flags['font-ratio']"
@input="set('font-ratio', $event.target.value)" placeholder="auto" />
</label>
</div>
</template>

View File

@ -0,0 +1,39 @@
<script setup>
const props = defineProps({ flags: { type: Object, required: true } })
const emit = defineEmits(['update:flags'])
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
</script>
<template>
<div class="grid grid-cols-2 gap-3">
<label class="ascii-label">
Width
<input class="ascii-input" type="number" min="1" :value="flags.width"
@input="set('width', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label">
Height
<input class="ascii-input" type="number" min="1" :value="flags.height"
@input="set('height', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label">
Format
<select class="ascii-input" :value="flags.format"
@change="set('format', $event.target.value)">
<option value="">default</option>
<option>ansi</option><option>utf8</option><option>html</option>
</select>
</label>
<label class="ascii-label">
Dither
<input class="ascii-input" type="text" :value="flags.dither"
@input="set('dither', $event.target.value)" placeholder="default" />
</label>
<label class="ascii-label col-span-2">
Gamma
<input class="ascii-input" type="number" min="0.1" step="0.1" :value="flags.gamma"
@input="set('gamma', $event.target.value)" placeholder="1.0" />
</label>
</div>
</template>

View File

@ -0,0 +1,34 @@
<script setup>
const props = defineProps({ flags: { type: Object, required: true } })
const emit = defineEmits(['update:flags'])
const set = (key, val) => emit('update:flags', { ...props.flags, [key]: val })
</script>
<template>
<div class="grid grid-cols-2 gap-3">
<label class="ascii-label">
Width
<input class="ascii-input" type="number" min="1" :value="flags.width"
@input="set('width', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label">
Height
<input class="ascii-input" type="number" min="1" :value="flags.height"
@input="set('height', $event.target.value)" placeholder="auto" />
</label>
<label class="ascii-label">
Background
<select class="ascii-input" :value="flags.background"
@change="set('background', $event.target.value)">
<option value="">default</option>
<option>light</option><option>dark</option>
</select>
</label>
<label class="ascii-label">
Chars
<input class="ascii-input" type="text" :value="flags.chars"
@input="set('chars', $event.target.value)" placeholder="e.g. .:-=+*#%@" />
</label>
</div>
</template>

5
src/main.js Normal file
View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './assets/main.css'
import App from './App.vue'
createApp(App).mount('#app')

9
tailwind.config.js Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{vue,js}'],
theme: {
extend: {},
},
plugins: [],
}

26
vite.config.js Normal file
View File

@ -0,0 +1,26 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})

14
vitest.config.js Normal file
View File

@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)