Contributing
PR workflow, testing, style
Contributing to frogConvert
For developers extending or fixing frogConvert. Audience is humans; AI agents should read ../AGENTS.md instead (same rules, distilled).
This document covers process: how to structure PRs, test, and match the project's style.
For authoring a new format handler, see HANDLERS.md. For system design, see ARCHITECTURE.md. For MCP and REST, see INTEGRATIONS.md.
Reporting bugs and giving feedback
Found a bug, want a new format added, or have feedback on the converter or PDF editor? Email francois.prevot@frog.co. Include:
- The input file format (and output format, for conversion bugs).
- The browser and OS you're on.
- A short description of what you expected vs. what happened.
- If possible, a sample file that reproduces the issue (do not send anything sensitive, frogConvert is fully client-side, but email isn't).
Same address for security reports. See ../SECURITY.md.
1. Directory structure
The codebase is a vanilla TypeScript Vite project. Full responsibilities are in ARCHITECTURE.md ยง Code Structure at a Glance. Day-to-day, these are the directories you will touch most:
- src/handlers/ - one file per conversion tool (FFmpeg, ImageMagick, Pandoc, etc.). New format support lives here.
- src/core/ -
FormatHandlerinterface, base classes,CommonFormatsregistry,TraversionGraph, quality planners, and src/core/compression/ (the UI-free compression engine). - src/components/ - UI (vanilla TS + DOM), including
store/store.tsfor state,PdfWorkspace/for the PDF editor andCompressWorkspace/for the Compress surface. - src/tools/ - PDF editor primitives (
pdfMerge.ts,pdfOrganize.ts,pdfExtract.ts,pdfWatermark.ts,pdfThumbnails.ts). - src/mcp/ and src/api/ - MCP server and REST API. Must stay in sync per ../AGENTS.md.
- src/workers/ -
conversion.worker.tsandroute-search.worker.ts. Most heavy work runs here, not on the main thread.
Three parallel subsystems. Before adding code, know which one you are touching:
- Conversion pipeline routes through TraversionGraph and FormatHandlers. Any format-to-format transformation lives here. This is the piece that originates from the Convert to it! fork; see the Credits section of ../README.md.
- PDF Workspace (editor mode) is a separate, frogConvert-original subsystem in ../src/components/PdfWorkspace/ plus tool files in ../src/tools/. Not part of the fork. The handler authoring guide in HANDLERS.md does not apply here. See ARCHITECTURE.md ยง PDF Workspace.
- Compression engine (../src/core/compression/ + ../src/components/CompressWorkspace/) makes a file smaller without changing its format. frogConvert-original. It borrows FormatHandlers as engines but does not use the conversion graph - dispatch happens in
resolveCompressor.ts. The engine half is deliberately UI-free: it takes aruncallback instead of importing the worker client, sosrc/core/never importssrc/components/. Keep it that way. See COMPRESS.md.
2. UI and state management principles
frogConvert deliberately does not use React or Vue. Vanilla TS plus direct DOM for performance and small bundle size.
State reactivity
State lives in src/components/store/store.ts as "Value Wrapper" objects:
export const currentFiles: { value: File[] } = { value: [] };
UI components subscribe to or update .value manually.
UI references
Avoid document.querySelector inside components. Use the centralized ui object in store.ts which caches all primary DOM references.
Popups and modals
Popup.tsexposesshowPopup(content, persistent?),hidePopup(),showAlertPopup(title, html),createPopupButton(text, class, onClick), and specialised helpers (showSizeWarningPopup,showFileTypeMismatchPopup). Open/close is delegated toModalManager.utils/ModalManager.tsowns modal lifecycle: stacks open modals, toggles theopenclass, setsaria-hidden, handles keyboard escape (non-persistent modals), traps focus, and callsupdateScrollLock(). Managed modals are#format-modal,#files-modal,#popup.- Visibility contract. Modals are shown/hidden via the
openCSS class, neverstyle.display. A modal is open iff it hasclassList.contains("open"). - Spinners. Active conversions use the gooey spinner (
loader-gooey); short blocking operations like cancellation useloader-spinner. - Stopping and partial downloads.
isCancelledand related state machine live insrc/conversion/cancellation.ts. If a batch is stopped,showPartialDownloadPopup()offers a download of finished files. User-facing copy says stop / stopping / stopped everywhere - the button, the interstitial, the finished state and the per-file row labels. The internal identifiers still saycancel(isCancelled,cancelButton,reason: "cancelled"); only the strings changed. Don't reintroduce "Cancel" in visible copy - it reads as dismiss this dialog, which is the opposite of abandoning work in flight. - Scroll locking.
updateScrollLock()instore.tschecks all five open-surface conditions - the format modal, the files modal, the top-bar menu, the popup, and the PDF workspace tray and toggles.scroll-lockon<html>. Called automatically byModalManager.
3. Format mode system (Core / Plus / All)
The format picker exposes three tiers that filter which output formats are visible. Configured in src/components/store/store.ts:
- Core - common everyday formats. A format must be in the
CORE_FORMATSwhitelist and its category must not be inCORE_HIDDEN_CATEGORIES(hidden:data,font,code,other). - Plus - adds data, font, and extra media formats. Uses
PLUS_FORMATS(superset ofCORE_FORMATS) andPLUS_HIDDEN_CATEGORIES(hidden:code,other). - All - every registered format; no filtering.
If your new handler's formats do not appear in Core or Plus, add the format's short identifier (e.g. "png", "csv") to the relevant Set in store.ts. The selected mode persists in localStorage.
4. Cache system
frogConvert uses a pre-computed format cache (public/cache.json) to skip calling every handler's init() at startup. Without it, first page load is slow because each WASM handler must load to reveal its supported formats.
How it works
- Build time.
bun run cache:buildlaunches Puppeteer, loads the built site, waits for handlers to initialize, then callswindow.printSupportedFormatCache()to serialize the handlerโformats mapping. - Runtime. The app loads
cache.jsonand builds theTraversionGraphimmediately, noinit()calls. - On demand. When a conversion is actually requested, only the handlers in the chosen path call
init().
When to regenerate
- After adding, removing, or renaming a handler.
- After changing a handler's
supportedFormats.
Use bun run cache:refresh, not cache:build. The two write to different
places and only one of them lasts:
| Script | Writes to | Survives? |
|---|---|---|
cache:refresh |
public/cache.json |
Yes - this is the tracked file that ships |
cache:build |
dist/cache.json |
No - dist/ is gitignored, and the next build overwrites it with the copy from public/ |
cache:build exists for desktop:build, which packages dist/ directly and
never reads public/. Reaching for it to refresh the shipped cache is a no-op
that looks like it worked, which is how the committed cache silently fell three
handlers behind: through the whole v3 cycle it carried no Ghostscript,
PdfCanvasCompress or imageToPdf entries at all.
Both need a production build first, since they drive the built site:
bun run build && bun run cache:refresh
In dev, the cache is optional; the app falls back to initializing all handlers at startup with a loading screen.
5. Testing
Commands
bun run testruns unit and integration tests (Vitest + jsdom). Do not use barebun test; that invokes Bun's native runner which lacks jsdom.bun run test:watchruns tests in watch mode.- E2E tests live in
test/e2e/(Puppeteer) and verify that workers mount and the UI flow works. bun run test:shellruns the stale-shell recovery E2E, which is opt-in - see The stale-shell suite below.bun run testleavesdist/holding a desktop build.test/e2e/electron-app.test.tssetsIS_DESKTOP=trueand builds intodist/, because that is where Electron's main process loads the app from. A desktop build has no service worker, nosw.jsand no inlined boot handler, all gated off deliberately - so servingdist/after a test run and finding the PWA missing means the build is stale, not broken. Re-runbun run buildfirst.
The corpus suites (opt-in, and the ones that find real bugs)
Six suites in test/e2e/ run real files through the real thing. Four drive the built app in a real browser - corpus-compress, corpus-convert, corpus-pdf, corpus-combined - and two drive the agent surfaces over their real transports: corpus-api (HTTP against a spawned src/api/index.ts) and corpus-mcp (stdio against src/mcp/index.ts). They exist because every serious defect in v3 lived in a seam between mocked units and was invisible to a green unit run - an encrypted PDF emptied and reported as an 83% saving, a truncated PDF returned as a blank page called a 99% win, a .webm not recognised as input at all.
They need ~49 MB of other people's files, so they are opt-in and skip loudly (test/helpers/corpus.ts prints a manifest of exactly what did not run and why - the inverse of optionalDeps.ts, which throws, because CI genuinely does have those dependencies and genuinely does not have this corpus):
bun run scripts/fetch-corpus.ts # ~31 files from public repos
bun run scripts/make-adversarial.ts # 12 generated edge cases
bun run build # they drive dist/, not the dev server
bun run test:corpus # sets FROG_CORPUS=1 for you
Deliberately not part of the default CI run: it needs a production build, a browser, and ~49 MB of downloads. bun run test skips all six suites, and says so. (The two agent suites need no build - they spawn the servers directly - but they share the same corpus gate.)
Shared plumbing lives in two helpers, split by what they drive. Add to the right one rather than starting a third copy.
test/helpers/corpusBrowser.ts - static server, browser, downloads, and re-opening PDF output with pdf-lib and pdfjs. Two things it encodes that cost a debugging round each: .mjs must be in the server's MIME map (Ghostscript ships gs.mjs, and a module script with the wrong type is refused, which looks exactly like a compression failure), and every suite waits for the handler registry rather than a fixed delay.
test/helpers/corpusAgents.ts - spawning the API and MCP servers, and the byte-level assertions both agent suites share. It encodes three constraints of its own:
PORT=0, always. The API defaults to 3000 and vitest runs test files in parallel workers, so a fixed port fails as an unexplained connection refusal inside a child process rather than as the port conflict it is. The assigned port is read back off the line the server prints, because that is the only place it exists.- SIGTERM, never SIGKILL.
browserBridge.tsinstalls a SIGTERM handler whose exit path kills the Chromium it warmed up. SIGKILL leaks a browser per run. - Compare by size, page count and extracted text - not by bytes. Ghostscript stamps an XMP
ModifyDate, so the same file compressed twice a second apart already differs. A first version of the REST-vs-MCP parity test asserted byte equality and reported the app broken for it.
The two agent suites also check the surfaces against each other: both are thin wrappers over compressForAgents, and each was previously only ever compared against itself, so one drifting to different options would have gone unnoticed.
The stale-shell suite (opt-in)
test/e2e/stale-shell-recovery.test.ts builds the app twice - a second deploy
derived from the first, with different asset hashes - serves both, and drives
Chromium across them to confirm a returning user on a stale shell recovers
instead of landing on a dead UI. It is the empirical counterpart to the
regression fixed in 3.0.0.
bun run test:shell # sets FROG_E2E_SHELL=1 for you
Opt-in for the same reason the corpus suites are, plus one of its own: it runs a full production build and a browser (~62s) inside a worker parallel with every other test file, and on a two-core CI runner that contention was enough to push the MCP integration suite past the SDK's 60-second request timeout.
What is not gated is the invariant itself. Every script named by a precached
HTML file must itself be precached, and that is asserted at build time by the
manifestTransforms hook in vite.config.js - so bun run build fails in CI
whether or not this suite runs. What test:shell adds is the browser-level
confirmation.
It also shares the optionalDeps.ts gate, so it skips where xlsx or the
image-to-txt submodule are missing: it runs a real production build, and that
build cannot resolve them. As of 2026-08-29 both halves are satisfied on a
restricted network - the submodule moved off git.sr.ht onto the author's
GitHub mirror, and xlsx moved off cdn.sheetjs.com onto the @e965/xlsx
republish on npm - so bun run test:shell now runs anywhere bun install
does. It had never run outside CI before that, which is how a service worker
that could not install reached a release. The skip message names whichever
dependency is actually absent.
Writing a handler test
Handler tests are colocated with the handler under src/handlers/ (e.g. src/handlers/myHandler.test.ts). test/ is reserved for e2e, fixtures, and shared mocks. Minimal:
import { expect, test } from 'vitest';
import CommonFormats from '../core/CommonFormats/CommonFormats.ts';
import myHandler from './myHandler.ts';
const encoder = new TextEncoder();
test('myHandler converts X to Y', async () => {
const handler = new myHandler();
await handler.init();
const inputFormat = CommonFormats.PNG.supported('png', true, true, true);
const outputFormat = CommonFormats.JPEG.supported('jpeg', true, true);
const [output] = await handler.doConvert(
[{ name: 'test.png', bytes: encoder.encode('...') }],
inputFormat,
outputFormat,
);
expect(output.name).toBe('test.jpeg');
});
Test infrastructure
test/setup.ts- Vitest preload. Mocksnavigator.deviceMemoryand providesMockWorkerthat routes messages through the route-search worker handler (jsdom has no real Web Worker support).test/MockedHandler.ts- stubFormatHandlerfor graph/pathfinding tests.test/resources/- fixture files.
6. PR workflow
- Fork and branch. One topic per branch. Branch names are descriptive (
add-webp-handler,fix-safari-pdf-fallback). - Commit style. Imperative, specific. Don't bundle unrelated changes.
- Run locally.
bun run testmust be green. Runbun run buildonce before opening the PR. - Docs. If you change a handler, update
public/cache.jsonviabun run build && bun run cache:refresh(see Cache system for whycache:buildis the wrong one). If you change user-visible behaviour, touch the relevant doc (CONVERTER.md, PDF_EDITOR.md, INTEGRATIONS.md) and add a CHANGELOG.md bullet. - Declare what you import. A package that happens to be installed as somebody else's transitive dependency will import fine on your machine and keep working until that somebody bumps a version and drops it. If you
importit, it belongs inpackage.json, and bothpackage.jsonandbun.lockgo in the commit - CI runsbun i --frozen-lockfileand fails if they disagree. - Dead-file check.
bun x knip --include files,unlistedis enforced in CI: a file nothing imports fails the build. Run the fullbun x kniptoo - the other categories are advisory but real. If it flags a file that is used, the reference is probably invisible to it (a path inside a string, say), and the fix is to declare it inknip.jsoncas an entry rather than to ignore the finding. Read the warning at the top of that file before removing any ignore.
7. Agent workflow
The full rules for AI pair-programming agents (Claude Code, Cursor, Aider, Cline, etc.) live in ../AGENTS.md. That file is the single source of truth for agent behavior; this document covers the human contributor side only. The rules there apply equally to human contributors.
See also
- ARCHITECTURE.md - internal design and subsystem boundaries.
- HANDLERS.md - authoring a new format handler.
- ../AGENTS.md - rules for AI agents and contributors.
- INTEGRATIONS.md - MCP and REST API reference.