Marky icon

A beautiful Markdown editor,
built AI-native from day one.

Marky is a desktop Markdown editor with first-class AI assistance, pixel-perfect PDF export, and a multi-tab side-by-side workspace. This page documents the system design and architecture behind it — a deep look at how an Electron app with three processes, four AI providers, and a unified markdown pipeline fits together.

Electron · React 19 · TypeScript · CodeMirror 6 · unified · Anthropic · OpenAI · Google · Ollama
Overview

What problem does Marky solve?

Existing markdown editors fall into two camps. Web-based tools like StackEdit are gorgeous but live in a browser tab and can't produce pixel-perfect PDFs. Native tools like Typora are powerful but predate the AI inflection point. Marky aims for the intersection: a polished native desktop app where AI assistance is a first-class feature, the rendered preview is the source of truth for export, and the UX feels modern without being noisy.

Multi-tab, side-by-side

Each open file gets its own editor, preview, view-mode, and scroll position. Dirty indicator, drag-reorder, middle-click close, command palette.

AI on demand

Ghost-text continuations on Ctrl+J, Gmail-style selection refine toolbar with streaming Accept / Reject / Try-again. Four providers.

Pixel-perfect PDF

Export uses the same Chromium that renders the preview — no second markdown engine to drift. What you see is what your PDF is.

Privacy-first keys

API keys never reach the renderer. Encrypted at rest with the OS keychain (DPAPI / Keychain / libsecret) via Electron's safeStorage.

In action

See it work

Short captures of the features that don't translate to a screenshot. Drop your own GIFs into docs/assets/ with the filenames below to replace the placeholders.

Formatting toolbar in action

Formatting toolbar

Heading dropdown, bold/italic toggle, table grid-picker, math, mermaid.

AI ghost-text completion

AI ghost-text completion

Press Ctrl+J → suggestion appears as gray text → Tab to accept.

Selection refine toolbar

Selection refine toolbar

Select text → choose Rewrite / Shorter / Grammar / Tone → streaming replace.

PDF export

Pixel-perfect PDF export

Ctrl+E → hidden Chromium window renders preview → printToPDF → save.

High-level design

Three processes, one app.

Marky follows Electron's standard tri-process model with strict security defaults. The renderer never touches Node, the main process never touches React, and a tiny typed bridge in the middle carries every cross-boundary call.

flowchart TB
  subgraph Main["Main Process - Node.js"]
    direction TB
    Window["BrowserWindow
frameless"] FileIO["File I/O
open · save · watch"] PDFGen["PDF Export
printToPDF"] AIRouter["AI Router
4 providers"] Updater["electron-updater"] Vault["safeStorage
OS keychain"] end subgraph Preload["Preload bridge"] Bridge["window.marky
typed contextBridge surface"] end subgraph Renderer["Renderer - Chromium sandbox"] direction TB React["React 19 UI"] CM["CodeMirror 6
editor + extensions"] Pipeline["unified pipeline
remark · rehype
Shiki · KaTeX · Mermaid"] Stores["Zustand stores"] end React --> Bridge CM --> Bridge Stores --> Bridge Bridge --> FileIO Bridge --> AIRouter Bridge --> PDFGen Bridge --> Updater Bridge --> Vault AIRouter -.->|HTTPS stream| LLM["LLM Providers
Anthropic · OpenAI · Google · Ollama"] FileIO -.->|fs| Disk[(Local disk)] Vault -.->|encrypt| Keychain[(OS Keychain)] Updater -.->|check| GH[(GitHub Releases)] PDFGen -.->|spawn| Hidden["Hidden BrowserWindow
renders preview HTML"] style Main fill:#1a1c26,stroke:#818cf8,color:#ebebef style Preload fill:#1a1c26,stroke:#fcd34d,color:#ebebef style Renderer fill:#1a1c26,stroke:#86efac,color:#ebebef
Process boundaries and external dependencies.

The three-process contract

Main process owns everything that requires Node: native dialogs, the filesystem, OS keychain access, the auto-updater, and outbound LLM API calls. It is the only place where API keys are decrypted, the only place where files are read or written, and the only place where the PDF export's hidden Chromium window is spawned.

Renderer process is a sandboxed Chromium tab running the React UI, CodeMirror, and the markdown rendering pipeline. contextIsolation is on, nodeIntegration is off, and sandbox is enabled — there is no require available in renderer code. Everything that needs the OS goes through the bridge.

Preload is the type-checked surface area between the two. A single file (src/preload/index.ts) uses contextBridge to expose a window.marky object with one method per IPC channel. The channels and TypeScript interfaces live in src/shared/ipc-contract.ts, so both sides import the same source of truth.

Why this matters

The boundary is what makes the privacy-first key story possible. When the user pastes an Anthropic API key into the Settings modal, the renderer sends the plaintext to ipcRenderer.invoke('ai:key:set', 'anthropic', key). Main encrypts it with safeStorage and stores it as base64 in ai-settings.json. When an AI call fires later, main decrypts in memory, calls the provider, streams chunks back via webContents.send('ai:stream:event', id, { type: 'chunk', text }) — and the renderer never sees the key.

Architecture deep-dive

How each subsystem actually works.

Five core flows, each with a sequence diagram. Together these cover ~80% of the code's purpose. Treat each as a tour: start with the diagram, then read the prose, then jump into the linked files in the repo.

AI streaming cross-process

All AI calls are bidirectional streams that cross the process boundary, with cancellation. The renderer fires a request; main aborts a previous in-flight stream with the same id, opens a new provider connection, and pumps chunks back as IPC events. Each provider (Anthropic, OpenAI, Google, Ollama) implements the same async-iterator interface, so the router stays uniform.

sequenceDiagram
  actor User
  participant R as Renderer
  participant P as Preload
  participant M as Main
  participant Prov as Provider SDK
  participant API as LLM API

  User->>R: select text + click "Rewrite"
  R->>R: build AIChatRequest
  R->>P: stream(id, request, onEvent)
  P->>M: invoke ai:stream:start
  M->>M: new AbortController
  M->>Prov: provider.stream(req, signal)
  Prov->>API: HTTP POST stream
  loop streaming
    API-->>Prov: chunk
    Prov-->>M: yield text
    M-->>P: send ai:stream:event chunk
    P-->>R: callback(event)
    R->>R: dispatch CM transaction
  end
  API-->>Prov: done
  Prov-->>M: generator return
  M-->>R: ai:stream:event done
  User->>R: Accept or Reject
  R->>R: keep or restore original
              
AI selection-refine: renderer → main → provider, streaming chunks back.

Pixel-perfect PDF export core differentiator

The fundamental design choice: use the same engine for preview and export. On Ctrl+E the renderer rebuilds the HTML through the same unified pipeline, hydrates Mermaid blocks to inline SVG, inlines preview CSS plus KaTeX CSS, and hands the document to main. Main spawns a hidden, sandboxed BrowserWindow, loads the HTML via a data URL, calls webContents.printToPDF, and writes the buffer to disk.

sequenceDiagram
  actor User
  participant R as Renderer
  participant M as Main
  participant H as Hidden Window

  User->>R: Ctrl+E
  R->>R: renderMarkdown(tab.content)
  R->>R: resolveMermaid to inline SVG
  R->>R: wrap with print CSS and KaTeX CSS
  R->>M: exportPdf(html, defaultName, dark)
  M->>User: showSaveDialog
  User-->>M: filePath
  M->>H: new BrowserWindow show false
  H->>H: loadURL data text html
  Note over H: 150 ms for fonts and layout
  M->>H: webContents.printToPDF
  H-->>M: PDF Buffer
  M->>M: fs.writeFile filePath buffer
  M-->>R: success path
  H->>H: destroy
              
Same Chromium → same rendering. No second markdown engine to drift.

Auto-update production-only

electron-updater checks the GitHub Releases latest.yml manifest five seconds after launch, downloads the new installer in the background (using a blockmap delta when possible), and surfaces a toast asking the user to restart. The renderer can also trigger a manual check from Settings → About → "Check for updates."

sequenceDiagram
  participant App
  participant U as electron-updater
  participant GH as GitHub Releases
  participant R as Renderer
  actor User

  App->>App: setTimeout 5s after launch
  App->>U: autoUpdater.checkForUpdates
  U->>GH: GET latest.yml
  GH-->>U: version, url, sha512
  alt new version available
    U-->>App: emit update-available
    App->>R: send update:event available
    U->>GH: GET installer plus blockmap
    GH-->>U: bytes
    U-->>App: emit update-downloaded
    App->>R: send update:event downloaded
    R->>User: toast Restart now
    User->>R: click Restart
    R->>App: send update:install
    App->>U: autoUpdater.quitAndInstall
  else up to date
    U-->>App: emit update-not-available
    App->>R: send update:event not-available
  end
              
Manual check ("Check for updates" button) shares the same event pipeline.

File associations & "Open With" cross-platform

When the user installs Marky, electron-builder writes platform-specific bindings for .md, .markdown, .mdx. macOS adds the entries to Info.plist; Windows NSIS writes registry keys; Linux ships a .desktop file with MIME associations. Double-clicking a file then routes through one of three OS-specific paths, all funneling to the same renderer flow.

flowchart LR
  Finder["Finder
double-click .md"] --> OpenFile["open-file event
macOS only"] Explorer["Explorer
double-click .md"] --> Argv["process.argv
Win and Linux"] Running["Already running
second instance"] --> Second["second-instance event
with argv"] OpenFile --> Queue["queueFileFromOs
reads and buffers"] Argv --> Queue Second --> Queue Queue -->|window ready| Send["webContents.send
file:open-from-os"] Queue -->|window not ready| Pending[("pendingFromOs[]")] Pending -.->|renderer pulls| Get["files.getPending"] Send --> Open["openFile + recent.add"] Get --> Open style Finder fill:#1a1c26,stroke:#818cf8,color:#ebebef style Explorer fill:#1a1c26,stroke:#818cf8,color:#ebebef style Running fill:#1a1c26,stroke:#818cf8,color:#ebebef style Open fill:#1a1c26,stroke:#818cf8,color:#ebebef style OpenFile fill:#0e1018,stroke:#888a99,color:#b4b6c2 style Argv fill:#0e1018,stroke:#888a99,color:#b4b6c2 style Second fill:#0e1018,stroke:#888a99,color:#b4b6c2 style Queue fill:#0e1018,stroke:#888a99,color:#b4b6c2 style Send fill:#0e1018,stroke:#888a99,color:#b4b6c2 style Get fill:#0e1018,stroke:#888a99,color:#b4b6c2
Three OS paths → one queue → one openFile + recent-files flow.

Editor & preview pipeline renderer-only

CodeMirror 6 hosts the editor with a stack of custom extensions: image paste, ghost-text (AI), selection tracker (for the refine toolbar), scroll-sync, and the base markdown grammar. The preview pane uses unified with a remark → rehype chain: remark-gfm, remark-math → KaTeX, custom Mermaid block extractor, then Shiki for code highlighting with dual themes (CSS variables, so dark mode switches without re-parsing). Mermaid is dynamically imported the first time a diagram appears.

flowchart LR
  Type["User types"] --> Doc[("CodeMirror doc")]
  Doc -->|updateListener debounced 120ms| Tabs[("Zustand: tab.content")]
  Tabs --> Pipeline

  subgraph Pipeline["Preview pipeline"]
    direction LR
    A[remark-parse] --> B[remark-gfm]
    B --> C[remark-math]
    C --> D[remark-rehype]
    D --> E[rehype-mermaid-extract]
    E --> F[rehype-katex]
    F --> G["rehype-shiki
dual theme"] G --> H[rehype-stringify] end Pipeline --> HTML["HTML string"] HTML --> DOM["dangerouslySetInnerHTML
+ hydrateMermaidBlocks"] DOM --> View["Live preview"] style Type fill:#1a1c26,stroke:#818cf8,color:#ebebef style Doc fill:#1a1c26,stroke:#818cf8,color:#ebebef style Tabs fill:#1a1c26,stroke:#818cf8,color:#ebebef style View fill:#1a1c26,stroke:#818cf8,color:#ebebef style HTML fill:#0e1018,stroke:#86efac,color:#ebebef style DOM fill:#0e1018,stroke:#86efac,color:#ebebef style Pipeline fill:#0e1018,stroke:#fcd34d,color:#ebebef
Editor → debounced unified pipeline → DOM. Mermaid hydrates lazily.
Tech stack — the decisions

Why these choices, not the alternatives.

The list of libraries is easy to find in package.json. What's harder to recover is why each was picked over the obvious alternative. Here's the reasoning.

Decision Chose Why over the alternative
Runtime Electron Picked over Tauri specifically for webContents.printToPDF. Tauri uses the system webview (WebView2, WKWebView, WebKitGTK), which means PDF output drifts between platforms — especially on Linux. Electron bundles Chromium, so the PDF matches the preview pixel-for-pixel on all three OSes. The cost is ~100 MB binary; the benefit is the headline feature works.
Editor CodeMirror 6 Picked over Monaco. Monaco is built for code, has a heavy worker model, and its markdown experience is a sideshow. CodeMirror 6 has a first-class extension API, ~10× smaller bundle, and a strong markdown grammar. Ghost-text and selection-tracking are clean to implement as CM 6 extensions.
Markdown engine unified · remark · rehype Picked over markdown-it. unified's AST-pass model is the only reasonable way to inject the Mermaid-extraction pass cleanly between remark-rehype and rehype-shiki. Plugins for GFM, math, KaTeX, Shiki all compose without forks.
State Zustand Picked over Redux Toolkit. The state tree is ~7 small stores (tabs, settings, recent, toasts, ai). RTK's slices/reducers/middlewares would be 5× the code for the same result. Zustand also exposes a stable getState() outside React, which I use heavily from CodeMirror extensions where there's no hook context.
AI key storage Electron safeStorage Picked over keytar. keytar is a native module that needs recompilation per Electron version; safeStorage is built into Electron, calls the same OS APIs (DPAPI on Windows, Keychain on macOS, libsecret on Linux), and ships zero extra binaries.
Build / dev electron-vite Picked over electron-forge. electron-vite gives one config for main + preload + renderer with HMR for the renderer, ESM main, and no custom webpack setup. Plays nicely with Tailwind v4 plugin.
CSS Tailwind v4 + tokens CSS variables (--surface-bg, --surface-text, etc.) drive theme switching without a re-render; Tailwind utilities handle layout. Theme toggle flips one class on <html> and the whole UI recolours via cascade.
AI providers Anthropic · OpenAI · Google · Ollama Provider abstraction is a single async generator interface. Each provider file is ~30 lines (Ollama uses native fetch; the others use their official SDKs). Adding a new provider takes one file and one entry in the router.
Auto-update electron-updater + GitHub Releases Releases are cut by pushing a git tag; the GitHub Actions workflow builds three platforms in parallel, publishes to a draft release, and a final job promotes it. electron-updater reads latest.yml from the release. No separate update server.
Project layout

Where to find what.

The folder structure mirrors the three-process model. Anything inside src/main runs in Node; anything in src/renderer runs in Chromium; src/shared has the typed IPC contract and pure types both sides import.

src/
├── main/                           // Electron main — privileged, Node.js
│   ├── index.ts                   // app lifecycle, single-instance lock, CSP, IPC wiring
│   ├── window.ts                  // frameless BrowserWindow, traffic-light position, dev DevTools
│   ├── ipc/
│   │   ├── window.ts              // minimize/maximize/close, app:version
│   │   ├── files.ts               // open/save/watch dialogs, image-save, pendingFromOs queue
│   │   ├── pdf.ts                 // hidden BrowserWindow + printToPDF
│   │   ├── ai.ts                  // stream router + AbortController per request
│   │   └── updates.ts             // electron-updater wrapper, dev no-op
│   └── ai/
│       ├── settings.ts            // safeStorage encrypt/decrypt, JSON persistence
│       ├── anthropic.ts           // @anthropic-ai/sdk wrapper
│       ├── openai.ts              // openai SDK wrapper
│       ├── google.ts              // @google/genai wrapper
│       └── ollama.ts              // native fetch, /api/chat SSE-like NDJSON
│
├── preload/                        // contextBridge — typed surface
│   ├── index.ts                   // exposes window.marky{ files, ai, updates, ... }
│   └── index.d.ts
│
├── shared/                         // imported by both main and renderer
│   ├── ipc-contract.ts            // IPC channel names + MarkyApi interface
│   ├── types.ts                   // FileReadResult, ExportPdfOptions, ...
│   └── ai.ts                      // AIProvider, AISettings, AIStreamEvent
│
└── renderer/                       // React + CodeMirror, sandboxed
    ├── index.html
    └── src/
        ├── App.tsx                // composes hooks + layout shell
        ├── main.tsx
        ├── components/
        │   ├── TitleBar.tsx       // custom frameless chrome + double-click-to-max
        │   ├── TabBar.tsx         // drag-reorder, middle-click close
        │   ├── EditorPane.tsx     // CM6 host + formatting toolbar + selection toolbar
        │   ├── EditorToolbar.tsx  // shared Dropdown + Heading/Math/Table menus
        │   ├── PreviewPane.tsx    // unified pipeline + Mermaid hydrate
        │   ├── SplitPane.tsx      // resizable divider
        │   ├── SelectionToolbar.tsx // Gmail-style refine actions, streaming replace
        │   ├── CommandPalette.tsx // Ctrl+K fuzzy search
        │   ├── SettingsModal.tsx  // AI keys, About, theme
        │   ├── Toaster.tsx
        │   ├── StatusBar.tsx
        │   └── EmptyState.tsx
        ├── editor/                 // CodeMirror 6 extensions
        │   ├── theme.ts
        │   ├── imagePaste.ts
        │   ├── ghostText.ts       // inline AI completion ViewPlugin
        │   ├── selectionTracker.ts
        │   ├── scrollSync.ts      // editor ↔ preview sync channel
        │   ├── markdownCommands.ts // toggleWrap, setHeadingLevel, insert table, etc.
        │   └── activeView.ts      // global reference to focused editor view
        ├── preview/
        │   ├── pipeline.ts        // unified processor + rehype-mermaid-extract
        │   ├── mermaid.ts         // dynamic import + hydrateMermaidBlocks
        │   └── styles.css         // preview typography + Shiki dual-theme vars
        ├── store/                  // Zustand
        │   ├── tabs.ts
        │   ├── settings.ts
        │   ├── recent.ts
        │   ├── toasts.ts
        │   └── ai.ts
        ├── ai/
        │   ├── client.ts          // IPC stream wrapper
        │   └── prompts.ts         // system prompts + refine action templates
        ├── hooks/
        │   ├── useFileCommands.ts
        │   ├── useViewShortcuts.ts
        │   ├── useFileWatching.ts
        │   ├── useFileAssociations.ts
        │   └── useUpdates.ts
        └── lib/
            ├── cn.ts              // clsx + tailwind-merge
            ├── id.ts
            └── exportPdf.ts       // renderer-side PDF orchestration