A cross-platform desktop application for browsing and reading markdown files generated by AI agentic workflows. Read-only — no editing, no authoring. Open a folder, navigate the file tree, and read fully rendered markdown.
One-click installers for the latest version:
⬇ WindowsMDViewer-windows-setup.exe
|
⬇ macOSMDViewer-mac.dmg
|
⬇ LinuxMDViewer-linux.AppImage
|
macOS users: the DMG is unsigned (no Apple Developer account). On first launch, right-click MDViewer.app → Open, then click Open in the dialog. After that it launches normally.
- Two-panel layout — collapsible file tree on the left, rendered markdown on the right
- Full markdown rendering powered by
unified/remark/rehype:- GFM (tables, strikethrough, task lists)
- Syntax-highlighted code blocks via
highlight.js - LaTeX math via KaTeX (
$inline$and$$display$$) - GitHub-style callouts:
> [!NOTE],> [!WARNING],> [!TIP],> [!IMPORTANT],> [!CAUTION] - YAML frontmatter parsed and shown in a collapsible panel
- Mermaid diagrams rendered to SVG, theme-aware
- OS-adaptive theme — follows your system dark/light mode by default, with a manual toggle (Dark / Light / System)
- Persistent state — remembers the last folder you opened, your theme choice, etc. (via
electron-store) - Live file watching — tree and preview refresh automatically when files change on disk (useful when an AI agent is writing files in real time)
- Fuzzy file search —
Ctrl/Cmd + Popens an overlay to jump to any.mdin the open folder - Keyboard navigation —
Ctrl/Cmd + Oopens a folder from the menu
| Layer | Choice |
|---|---|
| Desktop framework | Electron 30 |
| Frontend | React 18 + TypeScript |
| Build tool | Vite 5 + vite-plugin-electron |
| Markdown pipeline | unified / remark / rehype |
| Diagrams | mermaid.js |
| Math | KaTeX |
| Code highlighting | rehype-highlight (highlight.js) |
| Persistence | electron-store |
| Packaging | electron-builder |
| Tests | vitest + @testing-library/react |
- Node.js 18+ (Node 20 LTS recommended)
- npm (ships with Node)
- Windows users: for packaged builds, enable Developer Mode OR run the build terminal as Administrator so
electron-buildercan extractwinCodeSignsymlinks. See Troubleshooting.
git clone https://github.com/ecogs-sys/MDViewer.git
cd MDViewer
npm installRun the app in hot-reload dev mode:
npm run devThis launches Electron with the renderer served by Vite. Changes to React/CSS hot-reload instantly; changes to main/preload trigger a quick Electron restart.
Other dev scripts:
npm test # Run the full test suite (vitest, jsdom + node projects)
npm run test:ui # Run vitest with the UI
npm run coverage # Run tests with V8 coverage
npm run lint # ESLintThe full build runs TypeScript → Vite → electron-builder for your current platform:
npm run buildPlatform-specific scripts:
npm run build:win # Windows NSIS installer (.exe)
npm run build:mac # macOS DMG (.dmg)
npm run build:linux # Linux AppImage (.AppImage)Output goes to release/<version>/:
| Platform | Artifact |
|---|---|
| Windows | release/<version>/MDViewer-windows-setup.exe (NSIS installer) release/<version>/win-unpacked/MDViewer.exe (portable, no install) |
| macOS | release/<version>/MDViewer-mac.dmg |
| Linux | release/<version>/MDViewer-linux.AppImage |
For local testing on Windows, you can build only the unpacked folder — much faster and avoids the winCodeSign symlink issue:
npx vite build
npx electron-builder --dir --winThen run release\0.0.0\win-unpacked\MDViewer.exe directly.
npm run devAfter running the NSIS installer, launch MDViewer from the Start Menu or Desktop shortcut.
Double-click release\0.0.0\win-unpacked\MDViewer.exe — no installation needed; copy the whole win-unpacked folder anywhere.
You'll see a Welcome Screen with a single "Open Folder" button. Pick the folder containing your markdown files. MDViewer remembers it, so subsequent launches go straight to the file tree.
If you need to inspect renderer errors in a packaged build, launch with --devtools (DevTools opens in a detached window automatically):
# Windows
MDViewer.exe --devtools
# macOS
open -a MDViewer --args --devtools
# Linux (AppImage)
./MDViewer-0.1.0.AppImage --devtoolsEquivalent via env var:
MDVIEWER_DEVTOOLS=1 MDViewer.exeYou can also open DevTools any time from the View → Toggle Developer Tools menu item.
Cross-platform installers are built automatically by GitHub Actions on every version tag and attached to a GitHub Release.
# bump the version in package.json (e.g. 0.1.0 → 0.1.1)
npm version patch # or `minor` / `major`
# push the new tag — this triggers the workflow
git push --follow-tagsThe workflow (.github/workflows/release.yml) runs in parallel on:
| Runner | Builds |
|---|---|
ubuntu-latest |
MDViewer-linux.AppImage |
macos-latest |
MDViewer-mac.dmg (unsigned) |
windows-latest |
MDViewer-windows-setup.exe (NSIS) |
Filenames are version-less so the /releases/latest/download/<file> URLs in the Download section above always point at the newest build. All artifacts are uploaded to the GitHub Release matching the tag. GitHub Actions is free for public repositories across all runner types.
Builds are not Apple-notarized. On first launch, macOS users will see "MDViewer can't be opened because Apple cannot check it for malicious software". They need to right-click → Open (or xattr -d com.apple.quarantine MDViewer.app from Terminal).
To sign with an Apple Developer ID, add CSC_LINK, CSC_KEY_PASSWORD, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID as repo secrets and electron-builder will pick them up automatically.
MDViewer follows the standard Electron split:
┌─────────────────────────────────────────────────────────┐
│ Main Process (Node.js) │
│ - BrowserWindow lifecycle, native menu │
│ - File-system access (walkTree, readFile) │
│ - electron-store preferences │
│ - Native folder picker via dialog API │
└───────────────────┬─────────────────────────────────────┘
│ IPC (typed channels in src/shared/types.ts)
┌───────────────────┴─────────────────────────────────────┐
│ Renderer Process (React, sandboxed) │
│ - All UI │
│ - unified/remark/rehype markdown pipeline │
│ - Mermaid + KaTeX client-side rendering │
│ - NO direct fs access — everything via IPC │
└─────────────────────────────────────────────────────────┘
| Channel | Direction | Purpose |
|---|---|---|
dialog:open-folder |
renderer ⇄ main | Native folder picker |
fs:read-tree |
renderer ⇄ main | Recursive walk for .md files |
fs:read-file |
renderer ⇄ main | UTF-8 read |
fs:watch-start |
renderer → main | Begin watching a folder |
fs:watch-event |
main → renderer | Filesystem change notification |
prefs:get / prefs:set |
renderer ⇄ main | electron-store wrapper |
menu:open-folder |
main → renderer | Native menu "Open Folder…" click |
src/
├── shared/types.ts # Types + IPC channel constants (used by both sides)
├── main/ # Electron main process (Node)
│ ├── index.ts # Window creation, menu, lifecycle
│ ├── ipc-handlers.ts # All ipcMain.handle() registrations
│ ├── file-service.ts # Recursive .md walker, file reader
│ └── preferences.ts # electron-store wrapper
├── preload/index.ts # contextBridge: exposes window.electronAPI
└── renderer/src/ # React app
├── App.tsx # Root layout, theme + selected file state
├── components/ # Toolbar, FileTree, MarkdownPreview, ...
├── hooks/ # useTheme, useFileTree, useFileContent
├── lib/ # markdown-pipeline, mermaid-renderer, frontmatter, callouts-plugin
└── styles/ # global.css (theme tokens) + markdown.css
Built once at module load in markdown-pipeline.ts:
remark-parse
→ remark-gfm (tables, strikethrough, task lists)
→ remark-math ($…$ and $$…$$)
→ remark-rehype (MDAST → HAST)
→ rehype-highlight (syntax highlighting)
→ rehype-katex (LaTeX → HTML)
→ calloutsPlugin (> [!NOTE] → styled <div>)
→ rehype-stringify (HAST → HTML)
Frontmatter is extracted upstream by a small browser-safe parser in src/renderer/src/lib/frontmatter.ts (we don't use gray-matter because it's Node-only — see Notable gotchas).
Mermaid is rendered post-HTML: after React sets innerHTML, mermaid-renderer.ts scans for <code class="language-mermaid"> blocks and replaces them with rendered SVG, re-running when the theme changes.
These are baked into the codebase — but if you fork or restructure, watch out:
-
Preload must be
.cjs, not.mjs.package.jsonhas"type": "module", so any.mjsfile is loaded as ESM by Node.vite-plugin-electronoutputs the preload using CommonJSrequire(), so it must have a.cjsextension or it will fail withrequire is not defined. Seevite.config.ts(preload.vite.build.rollupOptions.output.entryFileNames: 'preload.cjs'). -
ESM main has no
__dirname. The main bundle is ESM (importsyntax), where__dirnameisundefined. We useapp.getAppPath()instead — it works in ESM, CJS, dev, packaged-asar, and packaged-unpacked modes. -
Don't import
gray-matterfrom the renderer. It doesrequire("fs")at the top of one of its modules and breaks the browser bundle. Usesrc/renderer/src/lib/frontmatter.tsinstead. -
if (!prefsLoaded) return null+show: falseis dangerous. If anything in the prefs-loading effect throws, the window never gets a non-empty paint,ready-to-shownever fires, and the window stays invisible forever.App.tsxwraps the prefs call in.catch().finally()for this reason, andmain/index.tshas adid-fail-loadhandler that forces the window visible as a safety net.
electron-builder extracts winCodeSign archives that contain macOS symlinks. Windows requires either Developer Mode or Administrator rights to create symlinks. Fix:
- Settings → Privacy & security → For developers → Developer Mode: ON
- OR run your terminal as Administrator
Alternatively, skip the installer and use --dir (see Skip the installer).
This was the original Windows build bug, now fixed. If you ever see it again, the cause is one of:
- Preload failed to load → check the renderer Console (uncomment the
openDevToolsinmain/index.ts) __dirnameundefined in ESM main → useapp.getAppPath()for all path joins- A renderer-bundled module uses
require()→ check the browser bundle forrequire(
If npm run build:win fails mid-way at the NSIS step, electron-builder may leave behind the slim asar package.json instead of restoring the original. The full package.json lives in this repo's git history — git checkout package.json restores it. Prefer npx electron-builder --dir --win for local testing to avoid this.
See LICENSE in the repo root.