How Losing My Typora License Led to Building My Own Markdown Editor

Iniciado por joomlamz, Hoje at 10:25

Respostas: 1   |   Visualizações: 4

Tópico anterior - Tópico seguinte

0 Membros e 1 Visitante estão a ver este tópico.

Olá, malta do **webmastersmz.com**! Como especialista em tecnologia, acompanhei com atenção o relato desse desenvolvedor que, ao ver-se privado da sua licença do Typora, decidiu meter a "mão na massa" e construir o seu próprio editor de Markdown.

Este é um cenário que ressoa muito com a nossa realidade em Moçambique, onde o acesso a licenças de software proprietário muitas vezes esbarra em barreiras de pagamento internacional ou modelos de subscrição que não favorecem o utilizador individual.

Aqui estão os pontos técnicos principais que extraí desta experiência e que gostaria de analisar convosco:

### 1. A Fragilidade do Software como Serviço (SaaS) e Licenciamento Restritivo
O autor destaca um ponto crítico: a dependência de servidores de activação de terceiros. Quando um software "comprado" depende de uma validação online constante, tu não és verdadeiramente dono da ferramenta. Para nós, webmasters, isto é um lembrete de que devemos sempre considerar alternativas *Open Source* ou ferramentas que permitam o funcionamento *offline* total para garantir a continuidade do negócio.

### 2. A Escolha da Stack Tecnológica
Desenvolver um editor de Markdown não é apenas sobre renderizar HTML. O autor teve de lidar com o parsing de **Abstract Syntax Trees (AST)** para garantir que a conversão de texto plano para elementos visuais fosse eficiente. Geralmente, nestes projectos, utiliza-se tecnologias como **Electron** ou **Tauri** (que é mais leve e performante) para criar uma experiência de desktop fluida. É um excelente exercício de engenharia de software para quem quer dominar manipulação de DOM e performance de interface.

### 3. Minimalismo vs. Bloatware
Um dos grandes ganhos ao criar a própria ferramenta é a remoção do "bloat" (funcionalidades inúteis). O Typora é fantástico, mas o autor conseguiu focar apenas no que usava. Isto levanta o debate: será que estamos a usar ferramentas pesadas demais para tarefas simples? Às vezes, um editor customizado que faz apenas o "essencial" aumenta drasticamente a produtividade.

### 4. A Curva de Aprendizagem e Soberania Digital
Construir a própria ferramenta é a forma máxima de soberania digital. Além de resolver o problema da licença, o desenvolvedor aprofundou conhecimentos em especificações de Markdown (como CommonMark ou GFM).

---

**Vamos debater no fórum?**
Gostaria de saber da vossa parte: Já passaram por situações onde perderam o acesso a um software pago e tiveram de encontrar soluções criativas? Acham que vale a pena "reinventar a roda" e criar as vossas próprias ferramentas de trabalho, ou preferem investir tempo a adaptar-se a alternativas gratuitas já existentes como o Obsidian ou VS Code?

Participem no debate aqui no **webmastersmz.com**! Partilhem as vossas experiências com ferramentas de escrita e produtividade no nosso contexto moçambicano.

---

Para garantir que os vossos projetos e fóruns rodam sem falhas e com a estabilidade que os vossos utilizadores merecem, convido-vos a conhecer as soluções de alojamento de alta performance da **AplicHost** em [https://aplichost.com](https://aplichost.com). Ter uma infraestrutura robusta é o primeiro passo para o sucesso de qualquer webmaster.

How Losing My Typora License Led to Building My Own Markdown Editor



Tópico: How Losing My Typora License Led to Building My Own Markdown Editor
Categoria: Tutoriais | Programação & Tecnologia
Idioma Principal: Português (Conteúdo de Tecnologia)

Descrição do Conteúdo / Informações:
-------------------------------------------------------------------------
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.

┌──────────────────────────────────────────────┐
│            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    │
└──────────────────────────────────────────────┘



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:

// src/main/index.ts
import { 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 Dialogs
ipcMain.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;
});



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:

// src/lib/applyAction.ts
export 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 };
}



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:

// tests/applyAction.test.ts
import { 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');
});
});



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.

┌──────────────────────────────────────────────────────────┐
│              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           │  │
│  └────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────┘



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.

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



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.

// src-tauri/src/commands.rs
use 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<String>,
}

/// Asynchronous, non-blocking native file picker using Tokio oneshot channels
async fn pick_open_file(app: &AppHandle) -> Option<PathBuf> {
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<Option<FileResult>, 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<Option<FileResult>, 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!(
"<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n</head>\n<body>\n{}\n</body>\n</html>",
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,
}))
}



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.

// src-tauri/src/menu.rs
use 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(())
}

Both application 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/


Joomlamz
Consultoria em Informática
-------------------------------------------------------
Especialista em Sistemas Web & Manutenção de Servidores.
A desenvolver o novo AplPortal com suporte a PHP 8.
Precisa de ajuda profissional? Contacte-me.

Tags: