--- title: "How Losing My Typora License Led to Building My Own Markdown Editor" author: "Alain Airom (Ayrom)" published: "2026-09-03T11:07:39.183Z" updated: "2026-09-03T11:07:39.183Z" url: "https://zyvop.com/how-losing-my-typora-license-led-to-building-my-own-markdown-editor-lku6i" canonical: "https://dev.to/aairom/how-losing-my-typora-license-led-to-building-my-own-markdown-editor-242j" tags: ["opensource","bob","Rust","markdown"] --- # How Losing My Typora License Led to Building My Own Markdown Editor ![Cover image]() How Losing My Typora License Led to Building My Own Markdown Editor ![ ]() ## Introduction For years, Typora has been my go-to Markdown editor — it’s affordable, reliable, and powerful. But yesterday, out of nowhere, it asked for my license key and threw an error claiming my usage limit was exhausted. While waiting on support to reply, I browsed free and open-source alternatives, but nothing quite clicked. Frustrated and impatient, I decided to take matters into my own hands last night and tasked **Bob** with building one from scratch. What started as a quick experiment quickly turned into a fully functional **Electron app**. Taking it a step further, I had Bob rewrite the entire application in **Rust** and package distributable builds for both versions. Today, both apps are completely open-source and ready to use — feel free to grab them! * * * Building the Desktop Foundation (Electron & React) ![ ]() When building a high-performance desktop editor, selecting an extensible, cross-platform architecture is critical. The first edition of the Markdown editor was built using **Electron**, **React** 18, and **TypeScript**. This architecture decouples the main process handling system events and file storage from the isolated renderer process responsible for live preview rendering, syntax highlighting, and Mermaid diagram execution. ### Architectural Layout & Communication To ensure safety and performance, the application enforces **Context Isolation** and disables **Node Integration** in the Chromium renderer process. The renderer interacts with native desktop APIs strictly via an IPC (Inter-Process Communication) context bridge. ```sql ┌──────────────────────────────────────────────┐ │ Main Process (Node.js) │ │ - Window Lifecycle Management │ │ - System Menu Bar Construction │ │ - Native Dialogs & File Read/Write (fs) │ └──────────────────────┬───────────────────────┘ │ IPC Channels │ (file:open, file:save, etc.) ┌──────────────────────▼───────────────────────┐ │ Renderer Process (Chromium) │ │ - React UI & Document State │ │ - Real-time Markdown Parsing (markdown-it) │ │ - Local Mermaid & Highlight.js Rendering │ └──────────────────────────────────────────────┘ ``` Enter fullscreen mode Exit fullscreen mode ## Core Module Implementation ### Native Desktop Bridge & File Operations The main process configures the application window, constructs the system menu, and exposes secure IPC handlers to safely execute disk operations: ```typescript // src/main/index.tsimport { app, BrowserWindow, dialog, ipcMain } from 'electron';import path from 'path';import fs from 'fs'; let mainWindow: BrowserWindow | null = null; function createWindow() {mainWindow = new BrowserWindow({ width: 1400, height: 900, webPreferences: { preload: path.join(__dirname, '../renderer/index.js'), contextIsolation: true, nodeIntegration: false, },}); const startUrl = process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : `file://${path.join(__dirname, '../renderer/index.html')}`; mainWindow.loadURL(startUrl);} // IPC Handlers for Native File DialogsipcMain.handle('file:open', async () => {const result = await dialog.showOpenDialog(mainWindow!, { properties: ['openFile'], filters: [ { name: 'Markdown Files', extensions: ['md'] }, { name: 'All Files', extensions: ['*'] }, ],}); if (!result.canceled && result.filePaths.length > 0) { const content = fs.readFileSync(result.filePaths[0], 'utf-8'); return { path: result.filePaths[0], content, name: path.basename(result.filePaths[0]), };}return null;}); ipcMain.handle('file:save', async (_event, content: string, filePath?: string) => {let targetPath = filePath; if (!targetPath) { const result = await dialog.showSaveDialog(mainWindow!, { defaultPath: 'document.md', filters: [{ name: 'Markdown Files', extensions: ['md'] }], }); if (result.canceled) return null; targetPath = result.filePath;} if (targetPath) { fs.writeFileSync(targetPath, content, 'utf-8'); return { path: targetPath, name: path.basename(targetPath) };}return null;}); ``` Enter fullscreen mode Exit fullscreen mode ### Pure Formatting Engine To keep state changes predictable and completely testable outside of the UI rendering cycle, document manipulations (wrapping selections, prefixing lines, and inserting dynamic blocks) are decoupled into a pure state transformer: ```typescript // src/lib/applyAction.tsexport interface TextareaState {value: string;selectionStart: number;selectionEnd: number;} export function applyAction(textarea: TextareaState,action: MenuAction,onChange: (v: string) => void): { newValue: string; newStart: number; newEnd: number } {const { selectionStart: start, selectionEnd: end, value } = textarea; let newValue = value;let newStart = start;let newEnd = end; if (action.type === 'wrap') { const before = action.before ?? ''; const after = action.after ?? ''; const selected = value.slice(start, end) || action.placeholder || ''; const replacement = `${before}${selected}${after}`; newValue = value.slice(0, start) + replacement + value.slice(end); newStart = start + before.length; newEnd = newStart + selected.length; } else if (action.type === 'linePrefix') { const prefix = action.prefix ?? ''; const lineStart = value.lastIndexOf('\n', start - 1) + 1; const lineEndIdx = value.indexOf('\n', end); const effectiveEnd = lineEndIdx === -1 ? value.length : lineEndIdx; const block = value.slice(lineStart, effectiveEnd); const lines = block.split('\n'); const allPrefixed = prefix.length > 0 && lines.every((l) => l.startsWith(prefix)); let newLines: string[]; if (allPrefixed) { // Toggle off prefix newLines = lines.map((l) => l.slice(prefix.length)); } else if (prefix.match(/^#{1,6} /)) { // Heading switch: replace old heading level marker newLines = lines.map((l) => prefix + l.replace(/^#{1,6} /, '')); } else { newLines = lines.map((l) => prefix + l); } const newBlock = newLines.join('\n'); newValue = value.slice(0, lineStart) + newBlock + value.slice(effectiveEnd); newStart = lineStart; newEnd = lineStart + newBlock.length;} if (action.type !== 'custom') { onChange(newValue);}return { newValue, newStart, newEnd };} ``` Enter fullscreen mode Exit fullscreen mode ### Automated Formatting Verification The pure formatting logic is thoroughly validated using vitest unit tests to ensure edge cases—such as multi-line prefix toggles or mid-string cursor placements—are handled cleanly: ```javascript // tests/applyAction.test.tsimport { describe, it, expect, vi } from 'vitest';import { applyAction } from './applyAction'; describe('applyAction – linePrefix & wrap', () => {it('wraps selected text with bold markers', () => { const state = { value: 'hello world', selectionStart: 6, selectionEnd: 11 }; const onChange = vi.fn(); const { newValue, newStart, newEnd } = applyAction( state, { type: 'wrap', before: '**', after: '**', placeholder: 'text' }, onChange ); expect(newValue).toBe('hello **world**'); expect(onChange).toHaveBeenCalledWith('hello **world**'); expect(newStart).toBe(8); expect(newEnd).toBe(13);}); it('switches heading level, removing old marker', () => { const state = { value: '# Old heading', selectionStart: 0, selectionEnd: 13 }; const onChange = vi.fn(); const { newValue } = applyAction( state, { type: 'linePrefix', prefix: '## ', placeholder: 'Heading 2' }, onChange ); expect(newValue).toBe('## Old heading');});}); ``` Enter fullscreen mode Exit fullscreen mode * * * ## Rewriting with Rust & Tauri for Maximum Performance ![ ]() While Electron allowed us to prototype a fully featured Markdown editor rapidly, bundling a ~150 MB Chromium binary and consuming ~200 MB of RAM at idle felt unnecessarily heavy. To create a lightweight, blazingly fast alternative, we rebuilt the application core using **Rust** and **Tauri v2**. By leveraging Tauri, the application delegates rendering to the OS’s built-in web engine (WKWebView on macOS) while handing off high-performance logic, native menu creation, and non-blocking disk I/O entirely to a compiled **Rust backend**. ![ ]() ## Architectural Overview In the Tauri edition, the React UI runs inside the native WebKit window, while all system integrations and file mutations happen on asynchronously spawned Rust tasks. ```sql ┌──────────────────────────────────────────────────────────┐ │ Native Desktop Window (Rust) │ │ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ WKWebView (OS Native Engine) │ │ │ │ - React 18 UI / Live Preview / Local Mermaid │ │ │ └─────────────────────────┬──────────────────────────┘ │ │ │ │ │ IPC Channels (invoke / listen) │ │ │ │ │ ┌─────────────────────────▼──────────────────────────┐ │ │ │ Compiled Rust Backend │ │ │ │ - Non-blocking File Dialogs (oneshot channels) │ │ │ │ - Async Disk Write Operations │ │ │ │ - Native macOS System Menu Construction │ │ │ └────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────┘ ``` Enter fullscreen mode Exit fullscreen mode ## Core Rust Implementation Details ### Application Entry Point & Plugin Initializer The main entry point sets up system-level plugins (file dialogs, shell handling, filesystem access), mounts custom commands, and constructs the native application menu during setup. ```rust // src-tauri/src/main.rs#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod commands;mod menu; fn main() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_shell::init()) .invoke_handler(tauri::generate_handler![ commands::file_open, commands::file_save, commands::file_save_as, commands::file_export_html, ]) .setup(|app| { menu::build_menu(app)?; Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application");} ``` Enter fullscreen mode Exit fullscreen mode ### Non-Blocking File Dialogs & Async Disk Operations To prevent blocking the Tokio runtime thread or freezing UI animations while waiting for a user to choose a file path, custom pickers utilize asynchronous `oneshot` channels to yield execution back to the event loop. ```xml // src-tauri/src/commands.rsuse std::path::PathBuf;use tauri::AppHandle;use tauri_plugin_dialog::{DialogExt, FilePath};use tokio::sync::oneshot; #[derive(serde::Serialize)]pub struct FileResult { pub path: String, pub name: String, pub content: Option,} /// Asynchronous, non-blocking native file picker using Tokio oneshot channelsasync fn pick_open_file(app: &AppHandle) -> Option { let (tx, rx) = oneshot::channel(); app.dialog() .file() .add_filter("Markdown Files", &["md"]) .add_filter("All Files", &["*"]) .pick_file(move |fp| { let _ = tx.send(fp); }); match rx.await.ok()? { Some(FilePath::Path(p)) => Some(p), _ => None, }} /// Open a Markdown file and return its metadata + content#[tauri::command]pub async fn file_open(app: AppHandle) -> Result, String> { let Some(path) = pick_open_file(&app).await else { return Ok(None); }; let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; let name = path .file_name() .unwrap_or_default() .to_string_lossy() .to_string(); Ok(Some(FileResult { path: path.to_string_lossy().to_string(), name, content: Some(content), }))} /// Export live-rendered HTML output as a standalone file#[tauri::command]pub async fn file_export_html( app: AppHandle, html_content: String, title: String,) -> Result, String> { let default_name = format!("{}.html", title); let Some(p) = pick_save_file(&app, &default_name, "html", "HTML Document").await else { return Ok(None); }; let document = format!( "\n\n\n\n{}\n\n\n{}\n\n", title, html_content ); std::fs::write(&p, document).map_err(|e| e.to_string())?; let name = p.file_name().unwrap_or_default().to_string_lossy().to_string(); Ok(Some(FileResult { path: p.to_string_lossy().to_string(), name, content: None, }))} ``` Enter fullscreen mode Exit fullscreen mode ### Native macOS Menu Routing & Event Dispatch Tauri constructs native OS dropdown menus and maps native accelerator keys (such as `Cmd+N`, `Cmd+S`, `Cmd+Shift+P`) directly to event channels that emit clean messages directly into the React UI. ```javascript // src-tauri/src/menu.rsuse tauri::{ menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}, App, Emitter,}; pub fn build_menu(app: &mut App) -> tauri::Result<()> { let h = app.handle(); // File Submenu let file_menu = Submenu::with_items(h, "File", true, &[ &MenuItem::with_id(h, "file_new", "New", true, Some("CmdOrCtrl+N"))?, &MenuItem::with_id(h, "file_open", "Open…", true, Some("CmdOrCtrl+O"))?, &PredefinedMenuItem::separator(h)?, &MenuItem::with_id(h, "file_save", "Save", true, Some("CmdOrCtrl+S"))?, &MenuItem::with_id(h, "file_save_as", "Save As…", true, Some("CmdOrCtrl+Shift+S"))?, &PredefinedMenuItem::separator(h)?, &MenuItem::with_id(h, "file_export", "Export as HTML…", true, Some("CmdOrCtrl+E"))?, &PredefinedMenuItem::separator(h)?, &PredefinedMenuItem::close_window(h, Some("Close"))?, ])?; let menu = Menu::with_items(h, &[&file_menu])?; app.set_menu(menu)?; // Global Menu Event Handler app.on_menu_event(move |app_handle, event| { let id = event.id().0.as_str(); let payload_channel = match id { "file_new" => "menu:new", "file_open" => "menu:open", "file_save" => "menu:save", "file_save_as" => "menu:saveAs", "file_export" => "menu:exportHtml", _ => return, }; // Emit native event directly into WKWebView JS context let _ = app_handle.emit(payload_channel, ()); }); Ok(())} ``` Enter fullscreen mode Exit fullscreen mode * * * Both applications have the same exact UI ![ ]() And once the build is made, either app could be launched through an icon! ![ ]() * * * ## Conclusion What started as a sudden licensing headache turned into one of the most rewarding weekend projects I’ve tackled in a while. Having an AI partner like Bob made bridging the gap from concept to a native, dual-architecture desktop app surprisingly fast — and now I have a lightweight, tailored editor that fits my workflow perfectly. Both the Electron and Rust/Tauri versions are completely open-source, so feel free to explore the repository, grab the latest builds, or even customize the code for your own setup. Happy writing, and may your Markdown files always render smoothly! **Thanks for reading 🎴** * * * ## Links - Github repo for this post: [https://github.com/aairom/aam-markdown-editor]() - IBM Bob: [https://bob.ibm.com/]()