mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-03 02:11:07 +05:30
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88e9a5290d | ||
|
|
fff15d8d3e | ||
|
|
c574d77c20 | ||
|
|
272215f9af | ||
|
|
0192590567 |
@@ -1,103 +0,0 @@
|
||||
# CLAUDE.md — MarkdownConverter (master)
|
||||
|
||||
> General code-quality, JavaScript, git, security, and testing standards are in the **global CLAUDE.md**. This file holds project- and branch-specific notes.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Electron desktop app for Markdown editing and universal file conversion powered by Pandoc. Cross-platform (Win/macOS/Linux). Features: multi-tab editor with live preview, 25+ themes, PDF viewer/editor (merge/split/compress/rotate/watermark/password), export to 20+ formats (PDF/DOCX/ODT/EPUB/HTML/LaTeX/RTF/PPTX), batch conversion, syntax highlighting, diagram support (Mermaid), Git integration, and a plugin system.
|
||||
|
||||
- **Version:** 4.4.3
|
||||
- **License:** MIT
|
||||
- **App ID:** `com.concreteinfo.markdownconverter`
|
||||
|
||||
## Branch Specifics
|
||||
|
||||
This is the **primary/release branch** — a vanilla JavaScript Electron app with no bundler or framework in the renderer. The renderer is a single large `renderer.js` (5,300+ lines) loaded directly via `src/index.html`. All UI is hand-rolled DOM manipulation.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Main Process (`src/main.js` — 4,260 lines)
|
||||
Monolithic main process file. Contains all IPC handlers, Pandoc invocation, file operations, menu definitions (600+ lines), and window lifecycle. Key modules extracted:
|
||||
- `src/main/PDFOperations.js` — PDF manipulation via `pdf-lib` (merge, split, compress, rotate, delete, reorder, watermark, encrypt, decrypt, permissions)
|
||||
- `src/main/GitOperations.js` — Git status/stage/commit/log via `simple-git`
|
||||
|
||||
### Renderer (`src/renderer.js` — 5,361 lines)
|
||||
Vanilla JS, no framework. Directly manipulates DOM. Loads CodeMirror 6 via `src/editor/codemirror-setup.js`. Uses `marked` + `highlight.js` + `DOMPurify` + `mermaid` for rendering. Lazy-loads sidebar panels, REPL, command palette, zen mode.
|
||||
|
||||
### Preload (`src/preload.js` — 448 lines)
|
||||
Exists as IPC bridge, but **`contextIsolation: false` and `nodeIntegration: true`** — the renderer has full Node access. Preload is effectively a thin passthrough.
|
||||
|
||||
### Security Model
|
||||
- `contextIsolation: false` + `nodeIntegration: true` (legacy; the react-electron branch fixes this)
|
||||
- Pandoc invoked via `execFile` (not `exec`) to prevent shell injection
|
||||
- Path traversal protection: `validatePath()`, `resolveWritablePath()`, blocks sensitive system dirs
|
||||
- Permission handler only allows `clipboard-read`/`clipboard-write`
|
||||
- Rate limiter on conversions (2-second minimum interval)
|
||||
- File size limit: 50MB
|
||||
- Error message sanitization strips absolute paths
|
||||
|
||||
### Plugin System (`src/plugins/`)
|
||||
Manifest-based discovery (`manifest.json`). Built-in `writing-studio` plugin with sprint/goal/snapshot management. Plugin API exposed via `src/plugins/plugin-api.js`.
|
||||
|
||||
### Settings
|
||||
Custom JSON file store at `<userData>/settings.json` (NOT `electron-store` despite the dependency). Recent files at `<userData>/recent-files.json`.
|
||||
|
||||
## System Dependencies
|
||||
|
||||
| Dependency | Required | Notes |
|
||||
|---|---|---|
|
||||
| **Node.js** | >= 20 | Electron 41 bundles Node 20.x |
|
||||
| **Pandoc** | Yes (for exports) | Downloaded to `bin/<platform>/pandoc` via `scripts/download-tools.js` (v3.9.0.2). Falls back to system PATH. Must be present for DOCX/ODT/EPUB/LaTeX/PPTX export. |
|
||||
| **FFmpeg** | Bundled | `ffmpeg-static` npm package; `asarUnpacked` for packaged builds |
|
||||
| **MiKTeX / TeX Live** | Optional | For LaTeX PDF export; MiKTeX PATH injected on Windows automatically |
|
||||
| **ImageMagick** | Optional | Linux image conversion; listed as deb dependency |
|
||||
| **LibreOffice** | Optional | Enhanced document conversion; listed as deb dependency |
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
npm start # Launch Electron app (dev mode)
|
||||
npm test # Jest test suite
|
||||
npm test:watch # Jest in watch mode
|
||||
npm test:coverage # Jest with coverage report
|
||||
npm run lint # ESLint check (src + tests)
|
||||
npm run lint:fix # ESLint auto-fix
|
||||
npm run format # Prettier write
|
||||
npm run format:check # Prettier check only
|
||||
npm run download-tools # Download Pandoc binaries to bin/
|
||||
npm run generate-icons # Generate app icons via sharp
|
||||
```
|
||||
|
||||
## Build & Package
|
||||
|
||||
**Tool:** `electron-builder` (v26.0.12), config inline in `package.json` (no separate config file).
|
||||
|
||||
| Target | Platforms |
|
||||
|---|---|
|
||||
| `npm run build` | electron-builder (default platform) |
|
||||
| `npm run build:win` | Windows: NSIS installer + portable + zip (x64) |
|
||||
| `npm run build:mac` | macOS: default dmg |
|
||||
| `npm run build:linux` | Linux: deb + AppImage + snap |
|
||||
| `npm run dist` | Build without publish |
|
||||
| `npm run dist:all` | Build for all platforms |
|
||||
|
||||
**Bundled with builds:** Pandoc binary per platform. FFmpeg via `ffmpeg-static` (asarUnpacked). NSIS installer uses custom script at `scripts/nsis-installer.nsh`.
|
||||
|
||||
**Output:** `dist/` directory.
|
||||
|
||||
**CI:** GitHub Actions workflows in `.github/workflows/` (ci.yml, release.yml).
|
||||
|
||||
## Project Conventions / Gotchas
|
||||
|
||||
- **No bundler/transpilation.** The app uses vanilla CommonJS JavaScript. `src/main.js` is loaded directly by Electron. No webpack, no Vite, no TypeScript, no Babel.
|
||||
- **Monolithic files.** `main.js` (4,260 lines) and `renderer.js` (5,361 lines) contain most logic. Not ideal but is the current state of this branch.
|
||||
- **CodeMirror 6** for the editor, configured in `src/editor/codemirror-setup.js`.
|
||||
- **PDF rendering** uses `pdfjs-dist`; **PDF manipulation** uses `pdf-lib` in the main process.
|
||||
- **Renderer security is weak** — full Node access in renderer. Do NOT introduce new privileged renderer code without understanding this.
|
||||
- **Pandoc is external.** Must be installed separately or downloaded via `npm run download-tools`. HTML and built-in PDF export work without Pandoc; other formats require it.
|
||||
- **PDF export fallback chain:** xelatex -> pdflatex -> lualatex -> Electron built-in `printToPDF()`.
|
||||
- **ESLint flat config** (`eslint.config.js`) with ECMAScript 2022. Prettier with 2-space indent, single quotes, semicolons, 100-char width.
|
||||
- **Tests:** Jest with jsdom environment, 15% coverage threshold. 24 test files in `tests/`.
|
||||
- **File associations:** `.md`, `.markdown`, `.pdf` registered at install.
|
||||
- **Single instance lock** enforced via `app.requestSingleInstanceLock()`.
|
||||
- **Adapters layer** (`src/adapters/`) abstracts file system operations for potential future non-Electron targets.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "markdown-converter",
|
||||
"version": "4.4.3",
|
||||
"version": "4.4.2",
|
||||
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting",
|
||||
"main": "src/main.js",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<!-- CSP: unsafe-inline/unsafe-eval required for marked.js extensions and Mermaid -->
|
||||
<!-- TODO: Migrate to nonce-based CSP for better security -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://www.plantuml.com;">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self' https://www.plantuml.com;">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MarkdownConverter</title>
|
||||
<!-- Design tokens - loaded first for CSS variable availability -->
|
||||
|
||||
+3
-1
@@ -242,7 +242,9 @@ function runPandoc(args, callback) {
|
||||
function runPandocCmd(cmdString, callback) {
|
||||
const parsed = parseCommand(cmdString);
|
||||
// Skip 'pandoc' if it's the first element (command itself)
|
||||
const args = parsed.command === 'pandoc' ? parsed.args : [parsed.command, ...parsed.args];
|
||||
// Use path.basename to handle full paths like bin/linux/pandoc
|
||||
const cmdBase = path.basename(parsed.command).replace(/\.exe$/i, '');
|
||||
const args = cmdBase === 'pandoc' ? parsed.args : [parsed.command, ...parsed.args];
|
||||
const pandocPath = getPandocPath();
|
||||
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
|
||||
}
|
||||
|
||||
+65
-107
@@ -1,9 +1,20 @@
|
||||
/**
|
||||
* MarkdownConverter Renderer Process
|
||||
* @version 4.4.3
|
||||
* @version 4.4.2
|
||||
*/
|
||||
|
||||
const { ipcRenderer } = require('electron');
|
||||
|
||||
// Shim window.electronAPI for main window which uses nodeIntegration
|
||||
// without preload script (window.electronAPI is normally set by preload.js).
|
||||
if (typeof window !== 'undefined' && !window.electronAPI) {
|
||||
window.electronAPI = {
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
invoke: (channel, data) => ipcRenderer.invoke(channel, data),
|
||||
on: (channel, callback) => ipcRenderer.on(channel, (event, ...args) => callback(...args))
|
||||
};
|
||||
}
|
||||
|
||||
const marked = require('marked');
|
||||
const { markedHighlight } = require('marked-highlight');
|
||||
const createDOMPurify = require('dompurify');
|
||||
@@ -11,53 +22,12 @@ const DOMPurify = createDOMPurify(window);
|
||||
const hljs = require('highlight.js');
|
||||
const { createEditor } = require('./editor/codemirror-setup');
|
||||
const { undo, redo } = require('@codemirror/commands');
|
||||
|
||||
// Define a fallback window.electronAPI object for when contextIsolation is disabled
|
||||
// and the preload script is not loaded in the main window context.
|
||||
if (typeof window !== 'undefined' && !window.electronAPI) {
|
||||
window.electronAPI = {
|
||||
send: (channel, data) => {
|
||||
ipcRenderer.send(channel, data);
|
||||
},
|
||||
invoke: async (channel, data) => {
|
||||
return await ipcRenderer.invoke(channel, data);
|
||||
},
|
||||
on: (channel, callback) => {
|
||||
const subscription = (event, ...args) => callback(...args);
|
||||
ipcRenderer.on(channel, subscription);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(channel, subscription);
|
||||
};
|
||||
},
|
||||
once: (channel, callback) => {
|
||||
ipcRenderer.once(channel, (event, ...args) => callback(...args));
|
||||
},
|
||||
removeAllListeners: (channel) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
},
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
file: {
|
||||
read: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||
write: (filePath, content) => ipcRenderer.invoke('write-file', { path: filePath, content }),
|
||||
delete: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
||||
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-directory', dirPath),
|
||||
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
|
||||
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
|
||||
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
|
||||
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Use window.ModalManager if already set by script tag, otherwise require it.
|
||||
// This prevents "Identifier 'ModalManager' has already been declared" when
|
||||
// both the script tag in index.html and CommonJS require() declare it.
|
||||
let _ModalManager;
|
||||
if (typeof window !== 'undefined' && window.ModalManager) {
|
||||
_ModalManager = window.ModalManager;
|
||||
} else {
|
||||
// ModalManager is loaded via <script src="utils/ModalManager.js"> in index.html.
|
||||
// It already exists in the global scope — re-declaring with let/const causes
|
||||
// SyntaxError: "Identifier 'ModalManager' has already been declared".
|
||||
if (typeof ModalManager === 'undefined') {
|
||||
const result = require('./utils/ModalManager');
|
||||
_ModalManager = result.ModalManager || result;
|
||||
ModalManager = result.ModalManager || result;
|
||||
}
|
||||
// Lazy-loaded modules — defer heavy imports until first use
|
||||
let _SidebarManager, _renderTemplatesPanel, _renderExplorerPanel, _renderGitPanel, _renderSnippetsPanel;
|
||||
@@ -520,38 +490,7 @@ class TabManager {
|
||||
document.querySelector('.editor-container').appendChild(tabContent);
|
||||
|
||||
// Initialize CodeMirror editor
|
||||
const editorContainer = document.getElementById(`editor-cm-${tab.id}`);
|
||||
if (editorContainer) {
|
||||
const isDark = document.body.className.includes('dark');
|
||||
tab.editorView = createEditor(editorContainer, {
|
||||
content: tab.content,
|
||||
onChange: (newContent) => {
|
||||
tab.content = newContent;
|
||||
tab.isDirty = true;
|
||||
// Dynamically enable/disable Large File Mode on edit
|
||||
if (newContent.length > 1024 * 1024) {
|
||||
if (!tab.largeFileMode) {
|
||||
tab.largeFileMode = true;
|
||||
this.isPreviewVisible = false;
|
||||
this.updatePreviewVisibility();
|
||||
notifyUser('Large content detected (>1MB). Large File Mode enabled to maintain peak responsiveness. Live preview auto-render is disabled.', 'warning');
|
||||
}
|
||||
} else {
|
||||
tab.largeFileMode = false;
|
||||
}
|
||||
this.updatePreview(tab.id);
|
||||
this.updateWordCount();
|
||||
this.updateTabBar();
|
||||
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
||||
},
|
||||
onUpdate: (view) => {
|
||||
this.updateCursorPosition(view);
|
||||
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
|
||||
},
|
||||
isDark,
|
||||
showLineNumbers: this.showLineNumbers,
|
||||
});
|
||||
}
|
||||
this.ensureEditor(tab);
|
||||
}
|
||||
|
||||
switchToTab(tabId) {
|
||||
@@ -1522,9 +1461,47 @@ class TabManager {
|
||||
return tab?.content || '';
|
||||
}
|
||||
|
||||
// Ensure a tab has a CodeMirror editor (lazy creation fallback)
|
||||
ensureEditor(tab) {
|
||||
if (tab.editorView) return true;
|
||||
const editorContainer = document.getElementById(`editor-cm-${tab.id}`);
|
||||
if (!editorContainer) {
|
||||
console.error(`[ensureEditor] editor-cm-${tab.id} not found in DOM`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const isDark = document.body.className.includes('dark');
|
||||
tab.editorView = createEditor(editorContainer, {
|
||||
content: tab.content,
|
||||
onChange: (newContent) => {
|
||||
tab.content = newContent;
|
||||
tab.isDirty = true;
|
||||
this.updatePreview(tab.id);
|
||||
this.updateWordCount();
|
||||
this.updateTabBar();
|
||||
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
||||
},
|
||||
onUpdate: (view) => {
|
||||
this.updateCursorPosition(view);
|
||||
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
|
||||
},
|
||||
isDark,
|
||||
showLineNumbers: this.showLineNumbers,
|
||||
});
|
||||
console.log(`[ensureEditor] Created editor for tab ${tab.id}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[ensureEditor] Failed to create editor for tab ${tab.id}:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Set content in editor
|
||||
setEditorContent(tabId, content) {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (!tab?.editorView) {
|
||||
this.ensureEditor(tab);
|
||||
}
|
||||
if (tab?.editorView) {
|
||||
tab.editorView.dispatch({
|
||||
changes: { from: 0, to: tab.editorView.state.doc.length, insert: content }
|
||||
@@ -1581,7 +1558,6 @@ let tabManager;
|
||||
let replPanel;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const ModalManager = _ModalManager;
|
||||
tabManager = new TabManager();
|
||||
|
||||
// Load saved Custom Preview CSS if present
|
||||
@@ -1714,11 +1690,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
|
||||
const statusBarRight = document.querySelector('.status-bar-right');
|
||||
|
||||
// Initialize command palette
|
||||
const CommandPalette = getCommandPalette();
|
||||
const commandPalette = new CommandPalette();
|
||||
|
||||
const pluginRegistry = new PluginRegistry({
|
||||
sidebar: sidebarManager,
|
||||
commands: commandPalette,
|
||||
@@ -1774,7 +1745,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
const welcomeHtml = getCreateWelcomeContent()(recentFiles, appVersion);
|
||||
|
||||
const tab = tabManager.tabs.get(tabManager.activeTabId);
|
||||
if (tab) {
|
||||
// Only show welcome if no file was opened while we were awaiting
|
||||
if (tab && !tab.filePath && tab.content === '') {
|
||||
tab.title = 'Welcome';
|
||||
tab.content = '';
|
||||
const preview = document.getElementById(`preview-${tab.id}`);
|
||||
@@ -1855,6 +1827,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize command palette
|
||||
const CommandPalette = getCommandPalette();
|
||||
const commandPalette = new CommandPalette();
|
||||
|
||||
// Initialize print preview
|
||||
const PrintPreview = getPrintPreview();
|
||||
const printPreview = new PrintPreview();
|
||||
@@ -1939,27 +1915,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
|
||||
// Initialize CodeMirror for the initial tab (tab 1)
|
||||
const initialEditorContainer = document.getElementById('editor-cm-1');
|
||||
if (initialEditorContainer) {
|
||||
const tab = tabManager.tabs.get(1);
|
||||
const isDark = document.body.className.includes('dark');
|
||||
tab.editorView = createEditor(initialEditorContainer, {
|
||||
content: tab.content,
|
||||
onChange: (newContent) => {
|
||||
tab.content = newContent;
|
||||
tab.isDirty = true;
|
||||
tabManager.updatePreview(tab.id);
|
||||
tabManager.updateWordCount();
|
||||
tabManager.updateTabBar();
|
||||
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
|
||||
},
|
||||
onUpdate: (view) => {
|
||||
tabManager.updateCursorPosition(view);
|
||||
if (outlinePanelContainer?._setActiveHeading) outlinePanelContainer._setActiveHeading(view.state.doc.lineAt(view.state.selection.main.head).number);
|
||||
},
|
||||
isDark,
|
||||
showLineNumbers: tabManager.showLineNumbers,
|
||||
});
|
||||
const initialTab = tabManager.tabs.get(1);
|
||||
if (initialTab) {
|
||||
tabManager.ensureEditor(initialTab);
|
||||
}
|
||||
|
||||
// Request current theme
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* ModalManager - Unified modal system with accessibility support
|
||||
* @version 4.4.3
|
||||
* @version 4.4.2
|
||||
*/
|
||||
class ModalManager {
|
||||
#modal;
|
||||
|
||||
Reference in New Issue
Block a user