Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12,275 changes: 8,422 additions & 3,853 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"dev": "npm -w @mdview/chrome-ext run dev",
"build": "npm -w @mdview/chrome-ext run build",
"build:ext": "npm -w @mdview/chrome-ext run build",
"dev:electron": "npm -w @mdview/electron run dev",
"build:electron": "npm -w @mdview/electron run build",
"export:docx": "tsx scripts/export-docx-mermaid-cli.ts",
"preview": "vite preview",
"test": "vitest",
Expand Down
Binary file added packages/electron/build/icon.icns
Binary file not shown.
Binary file added packages/electron/build/icon.ico.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packages/electron/build/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
52 changes: 52 additions & 0 deletions packages/electron/electron-builder.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
appId: net.mdview.app
productName: mdview
directories:
buildResources: build
output: dist
files:
- out/**/*
- '!out/**/*.map'

fileAssociations:
- ext: md
name: Markdown
role: Viewer
mimeType: text/markdown
- ext: markdown
name: Markdown
role: Viewer
mimeType: text/markdown
- ext: mdown
name: Markdown
role: Viewer
- ext: mkd
name: Markdown
role: Viewer
- ext: mkdn
name: Markdown
role: Viewer
- ext: mdx
name: MDX
role: Viewer

mac:
target:
- dmg
- zip
category: public.app-category.developer-tools
icon: build/icon.icns

linux:
target:
- AppImage
category: Development
icon: build/icon.png

win:
target:
- nsis
icon: build/icon.ico

nsis:
oneClick: false
allowToChangeInstallationDirectory: true
38 changes: 38 additions & 0 deletions packages/electron/electron.vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { resolve } from 'path';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';

export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/main/index.ts'),
},
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/preload/index.ts'),
},
},
},
},
renderer: {
root: resolve(__dirname, 'src/renderer'),
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
},
},
},
worker: {
format: 'es',
},
},
});
34 changes: 34 additions & 0 deletions packages/electron/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@mdview/electron",
"version": "0.0.1",
"description": "Electron desktop viewer for mdview",
"type": "module",
"private": true,
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"test": "vitest",
"test:ci": "vitest --run",
"dist": "electron-builder",
"dist:mac": "electron-builder --mac",
"dist:linux": "electron-builder --linux",
"dist:win": "electron-builder --win"
},
"dependencies": {
"@mdview/core": "*",
"chokidar": "^4.0.3",
"electron-store": "^11.0.2"
},
"devDependencies": {
"@types/node": "^20.10.0",
"electron": "^35.2.1",
"electron-builder": "^26.0.12",
"electron-vite": "^3.1.0",
"vite": "^6.3.5",
"vitest": "^4.0.15"
},
"author": "James Ainslie",
"license": "MIT"
}
48 changes: 48 additions & 0 deletions packages/electron/scripts/generate-icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Generate placeholder app icons for electron-builder.
* Run: npx tsx packages/electron/scripts/generate-icons.ts
*/
import sharp from 'sharp';
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const buildDir = join(__dirname, '..', 'build');

if (!existsSync(buildDir)) {
mkdirSync(buildDir, { recursive: true });
}

// Create a simple blue-gradient icon with "MD" text
const size = 512;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4A90D9"/>
<stop offset="100%" style="stop-color:#357ABD"/>
</linearGradient>
</defs>
<rect width="${size}" height="${size}" rx="80" fill="url(#bg)"/>
<text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle"
font-family="system-ui, -apple-system, sans-serif" font-weight="700"
font-size="220" fill="white" letter-spacing="-10">MD</text>
</svg>`;

async function generate() {
const svgBuffer = Buffer.from(svg);

// PNG for Linux + electron-builder
await sharp(svgBuffer).resize(512, 512).png().toFile(join(buildDir, 'icon.png'));

// ICO requires 256x256 PNG (electron-builder converts)
await sharp(svgBuffer).resize(256, 256).png().toFile(join(buildDir, 'icon.ico.png'));

// For macOS .icns, electron-builder can generate from a 512x512 PNG
// We provide the PNG and electron-builder handles the conversion
await sharp(svgBuffer).resize(512, 512).png().toFile(join(buildDir, 'icon.icns'));

console.log('Icons generated in', buildDir);
}

void generate();
86 changes: 86 additions & 0 deletions packages/electron/src/main/adapters/export-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ElectronExportAdapter } from './export-adapter';
import type { ExportSaveOptions } from '@mdview/core';

describe('ElectronExportAdapter', () => {
let showSaveDialog: ReturnType<typeof vi.fn>;
let printToPDF: ReturnType<typeof vi.fn>;
let adapter: ElectronExportAdapter;

beforeEach(() => {
showSaveDialog = vi.fn();
printToPDF = vi.fn();
adapter = new ElectronExportAdapter(showSaveDialog, printToPDF);
});

describe('saveFile', () => {
it('should call showSaveDialog with correct filters', async () => {
showSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.pdf' });

const options: ExportSaveOptions = {
filename: 'document.pdf',
mimeType: 'application/pdf',
data: new ArrayBuffer(10),
};

await adapter.saveFile(options);

expect(showSaveDialog).toHaveBeenCalledWith(
expect.objectContaining({
defaultPath: 'document.pdf',
})
);
});

it('should not write if dialog is canceled', async () => {
showSaveDialog.mockResolvedValue({ canceled: true });

const options: ExportSaveOptions = {
filename: 'test.pdf',
mimeType: 'application/pdf',
data: new ArrayBuffer(0),
};

await adapter.saveFile(options);
// No error thrown — dialog canceled gracefully
});

it('should handle DOCX mime type', async () => {
showSaveDialog.mockResolvedValue({ canceled: false, filePath: '/tmp/out.docx' });

const options: ExportSaveOptions = {
filename: 'doc.docx',
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
data: new ArrayBuffer(5),
};

await adapter.saveFile(options);

const callArgs = showSaveDialog.mock.calls[0]?.[0] as {
filters: { name: string; extensions: string[] }[];
};
const hasDocx = callArgs.filters.some((f: { extensions: string[] }) =>
f.extensions.includes('docx')
);
expect(hasDocx).toBe(true);
});
});

describe('printToPDF', () => {
it('should delegate to the injected printToPDF function', async () => {
const pdfBuffer = new ArrayBuffer(100);
printToPDF.mockResolvedValue(Buffer.from(pdfBuffer));

const result: ArrayBuffer = await adapter.printToPDF({ pageSize: 'A4' });
expect(printToPDF).toHaveBeenCalledWith({ pageSize: 'A4' });
expect(result).toBeInstanceOf(ArrayBuffer);
});

it('should pass default empty options', async () => {
printToPDF.mockResolvedValue(Buffer.from(new ArrayBuffer(0)));

await adapter.printToPDF();
expect(printToPDF).toHaveBeenCalledWith(undefined);
});
});
});
72 changes: 72 additions & 0 deletions packages/electron/src/main/adapters/export-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { ExportAdapter, ExportSaveOptions } from '@mdview/core';
import { writeFile } from 'fs/promises';

type ShowSaveDialogFn = (options: {
defaultPath: string;
filters: { name: string; extensions: string[] }[];
}) => Promise<{ canceled: boolean; filePath?: string }>;

type PrintToPDFFn = (options?: {
pageSize?: string;
margins?: string;
landscape?: boolean;
}) => Promise<Buffer>;

export class ElectronExportAdapter implements ExportAdapter {
constructor(
private showSaveDialog: ShowSaveDialogFn,
private printToPDFFn: PrintToPDFFn
) {}

async saveFile(options: ExportSaveOptions): Promise<void> {
const filters = this.getFilters(options.mimeType, options.filename);

const result = await this.showSaveDialog({
defaultPath: options.filename,
filters,
});

if (result.canceled || !result.filePath) {
return;
}

const buffer =
options.data instanceof ArrayBuffer
? Buffer.from(options.data)
: Buffer.from(await options.data.arrayBuffer());

await writeFile(result.filePath, buffer);
}

async printToPDF(options?: {
pageSize?: string;
margins?: string;
landscape?: boolean;
}): Promise<ArrayBuffer> {
const buffer = await this.printToPDFFn(options);
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
}

private getFilters(mimeType: string, filename: string): { name: string; extensions: string[] }[] {
const ext = filename.split('.').pop() || '';

const mimeMap: Record<string, { name: string; extensions: string[] }> = {
'application/pdf': { name: 'PDF', extensions: ['pdf'] },
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': {
name: 'Word Document',
extensions: ['docx'],
},
'text/html': { name: 'HTML', extensions: ['html', 'htm'] },
};

const filter = mimeMap[mimeType];
if (filter) {
return [filter, { name: 'All Files', extensions: ['*'] }];
}

return [
{ name: ext.toUpperCase(), extensions: [ext] },
{ name: 'All Files', extensions: ['*'] },
];
}
}
Loading
Loading