diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml
index 49e84dc..75e1f97 100644
--- a/.github/workflows/development.yml
+++ b/.github/workflows/development.yml
@@ -20,9 +20,7 @@ jobs:
node-version: 22
- name: Setup pnpm
- uses: pnpm/action-setup@v3
- with:
- version: 10
+ uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -40,4 +38,35 @@ jobs:
run: pnpm test:coverage
- name: Build Electron app
- run: pnpm dist
\ No newline at end of file
+ run: pnpm dist
+
+ security:
+ name: Security Audit
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: pnpm audit
+ run: pnpm audit --audit-level=high
+
+ - name: Run Trivy vulnerability scanner
+ uses: aquasecurity/trivy-action@v0.36.0
+ with:
+ scan-type: fs
+ scan-ref: .
+ severity: CRITICAL,HIGH
+ format: table
+ exit-code: 1
diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml
index 422f004..ad3cb95 100644
--- a/.github/workflows/production.yml
+++ b/.github/workflows/production.yml
@@ -20,7 +20,7 @@ jobs:
node-version: 22
- name: Setup pnpm
- uses: pnpm/action-setup@v3
+ uses: pnpm/action-setup@v4
with:
version: 10
@@ -40,4 +40,37 @@ jobs:
run: pnpm test:coverage
- name: Build Electron app
- run: pnpm dist
\ No newline at end of file
+ run: pnpm dist
+
+ security:
+ name: Security Audit
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: pnpm audit
+ run: pnpm audit --audit-level=high
+
+ - name: Run Trivy vulnerability scanner
+ uses: aquasecurity/trivy-action@v0.36.0
+ with:
+ scan-type: fs
+ scan-ref: .
+ severity: CRITICAL,HIGH
+ format: table
+ exit-code: 1
diff --git a/.gitignore b/.gitignore
index ea064ff..3073ad1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,40 @@
-node_modules
-dist
-build
+# Dependencies
+node_modules/
+
+# Build outputs
+out/
+dist/
+build/
+release/
+*.tgz
+.vite/
+
+# Electron builder
+.cache/
+
+# Environment
.env
-out
-coverage
-release
-.vite/
\ No newline at end of file
+.env.local
+.env.*.local
+
+# Logs
+*.log
+npm-debug.log*
+pnpm-debug.log*
+
+# OS artifacts
+.DS_Store
+Thumbs.db
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+
+# Test coverage
+coverage/
+
+# Docusaurus
+docs/.docusaurus/
+docs/build/
diff --git a/.husky/pre-push b/.husky/pre-push
new file mode 100644
index 0000000..a8ad12c
--- /dev/null
+++ b/.husky/pre-push
@@ -0,0 +1 @@
+pnpm test
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
index 44b5b73..2efcb8d 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,4 +1,9 @@
node_modules
dist
.next
-build
\ No newline at end of file
+build
+out/
+release/
+coverage/
+docs/.docusaurus/
+docs/build/
\ No newline at end of file
diff --git a/apps/main-processor/tests/cli.test.ts b/apps/main-processor/__tests__/cli.test.ts
similarity index 100%
rename from apps/main-processor/tests/cli.test.ts
rename to apps/main-processor/__tests__/cli.test.ts
diff --git a/apps/main-processor/tests/docx.test.ts b/apps/main-processor/__tests__/docx.test.ts
similarity index 100%
rename from apps/main-processor/tests/docx.test.ts
rename to apps/main-processor/__tests__/docx.test.ts
diff --git a/apps/main-processor/tests/export.test.ts b/apps/main-processor/__tests__/export.test.ts
similarity index 71%
rename from apps/main-processor/tests/export.test.ts
rename to apps/main-processor/__tests__/export.test.ts
index 568fbf1..6c5ebac 100644
--- a/apps/main-processor/tests/export.test.ts
+++ b/apps/main-processor/__tests__/export.test.ts
@@ -1,7 +1,11 @@
import { describe, it, expect } from 'vitest';
+import { mkdtemp, writeFile, rm } from 'node:fs/promises';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
import { buildDocument } from '../src/export/buildDocument';
import { sanitizeCss } from '../src/export/sanitizeCss';
import { getImage } from '../src/export/getImage';
+import { inlineImages } from '../src/export/inlineImage';
describe('build html document', () => {
it('wraps content in a valid HTML5 document shell', () => {
@@ -50,3 +54,17 @@ describe('get image of mime type', () => {
expect(getImage('image.webp')).toBe('image/webp');
});
});
+
+describe('inline images for HTML export', () => {
+ it('keeps local images as base 64 data URIs', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'markdown-reader-export-'));
+ try {
+ const imagePath = join(dir, 'image.png');
+ await writeFile(imagePath, Buffer.from([137, 80, 78, 71]));
+ const html = await inlineImages(``);
+ expect(html).toContain('src="data:image/png;base64,');
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/apps/main-processor/tests/file.test.ts b/apps/main-processor/__tests__/file.test.ts
similarity index 72%
rename from apps/main-processor/tests/file.test.ts
rename to apps/main-processor/__tests__/file.test.ts
index f4a8498..9ec3934 100644
--- a/apps/main-processor/tests/file.test.ts
+++ b/apps/main-processor/__tests__/file.test.ts
@@ -2,6 +2,7 @@ import { vi, describe, expect, it, beforeAll, afterAll } from 'vitest';
import { writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
+import { getWatcherDiagnostics } from '../src/file';
import { readFile, watchFile, unWatchFile } from '../src/file';
@@ -47,6 +48,7 @@ describe('File watcher', () => {
writeFileSync(TEST_FILE, 'change after unwatch');
await new Promise((r) => setTimeout(r, 200));
expect(cb).not.toHaveBeenCalled();
+ expect(getWatcherDiagnostics().debounceTimers).toBe(0);
});
it('should protect from double call', async () => {
@@ -61,4 +63,23 @@ describe('File watcher', () => {
it('should not crash if I unwatch a file which is not watched', async () => {
await expect(unWatchFile('fake-file.txt')).resolves.toBeUndefined();
});
+
+ it('should clean debounce timer references after unwatching', async () => {
+ const cb = vi.fn();
+ await watchFile(TEST_FILE, cb);
+ writeFileSync(TEST_FILE, 'change with pending debounce');
+ await new Promise((r) => setTimeout(r, 25));
+ await unWatchFile(TEST_FILE);
+ expect(getWatcherDiagnostics().debounceTimers).toBe(0);
+ });
+
+ it('does not call callback for a file unwatched during debounce', async () => {
+ const cb = vi.fn();
+ await watchFile(TEST_FILE, cb);
+ writeFileSync(TEST_FILE, 'change before immediate unwatch');
+ await new Promise((r) => setTimeout(r, 25));
+ await unWatchFile(TEST_FILE);
+ await new Promise((r) => setTimeout(r, 200));
+ expect(cb).not.toHaveBeenCalled();
+ });
});
diff --git a/apps/main-processor/__tests__/folder-search.test.ts b/apps/main-processor/__tests__/folder-search.test.ts
new file mode 100644
index 0000000..3b9d644
--- /dev/null
+++ b/apps/main-processor/__tests__/folder-search.test.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { searchFolder } from '../src/folder-search';
+
+const DIR = join(tmpdir(), 'md-search-test');
+
+beforeAll(() => {
+ mkdirSync(DIR, { recursive: true });
+ writeFileSync(join(DIR, 'README.md'), '# Project\nThis project uses React.\nReact is great.');
+ writeFileSync(join(DIR, 'CHANGELOG.md'), '# Changes\nVersion 1.0 released.');
+ writeFileSync(join(DIR, 'notes.txt'), 'not a markdown file');
+});
+afterAll(() => {
+ rmSync(DIR, { recursive: true, force: true });
+});
+
+describe('search folder', () => {
+ it('should find matches in .md files and returns results', async () => {
+ const results = await searchFolder(DIR, 'React');
+ expect(results.length).toBeGreaterThan(0);
+ expect(results.some((r) => r.filePath.endsWith('README.md'))).toBe(true);
+ });
+
+ it('does not search non .md files', async () => {
+ const results = await searchFolder(DIR, 'not a markdown');
+ expect(results.every((r) => r.filePath.endsWith('.md'))).toBe(true);
+ });
+
+ it('should return empty array when query matches nothing', async () => {
+ const results = await searchFolder(DIR, 'xyznotfound');
+ expect(results).toHaveLength(0);
+ });
+
+ it('has search case insensitive', async () => {
+ const lower = await searchFolder(DIR, 'react');
+ const upper = await searchFolder(DIR, 'REACT');
+ expect(lower.length).toBe(upper.length);
+ });
+
+ it('returns an empty array if the directory does not exist', async () => {
+ const nonExistentDir = join(DIR, 'does-not-exist-folder');
+
+ const results = await searchFolder(nonExistentDir, 'React');
+ expect(results).toEqual([]);
+ });
+});
diff --git a/apps/main-processor/__tests__/folder.test.ts b/apps/main-processor/__tests__/folder.test.ts
new file mode 100644
index 0000000..6ae5277
--- /dev/null
+++ b/apps/main-processor/__tests__/folder.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { getFolder } from '../src/folder';
+
+describe('folder reader testing', async () => {
+ it('should enforce the recursion depth', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'markdown-reader-folder-'));
+ let current = root;
+ for (let index = 0; index < 12; index += 1) {
+ current = join(current, `level-${index}`);
+ await mkdir(current);
+ await writeFile(join(current, `file-${index}.md`), '# test', 'utf-8');
+ }
+
+ const tree = await getFolder(root, 2);
+ const first = tree.children?.find((child) => child.isDir);
+ const second = first?.children?.find((child) => child.isDir);
+ expect(second?.children).toEqual([]);
+ });
+});
diff --git a/apps/main-processor/tests/ipc.test.ts b/apps/main-processor/__tests__/ipc.test.ts
similarity index 100%
rename from apps/main-processor/tests/ipc.test.ts
rename to apps/main-processor/__tests__/ipc.test.ts
diff --git a/apps/main-processor/tests/menu.test.ts b/apps/main-processor/__tests__/menu.test.ts
similarity index 100%
rename from apps/main-processor/tests/menu.test.ts
rename to apps/main-processor/__tests__/menu.test.ts
diff --git a/apps/main-processor/tests/recent.test.ts b/apps/main-processor/__tests__/recent.test.ts
similarity index 92%
rename from apps/main-processor/tests/recent.test.ts
rename to apps/main-processor/__tests__/recent.test.ts
index d91170c..775d097 100644
--- a/apps/main-processor/tests/recent.test.ts
+++ b/apps/main-processor/__tests__/recent.test.ts
@@ -1,9 +1,4 @@
-import { vi, describe, it, expect } from 'vitest';
-vi.mock('electron', () => ({
- app: {
- isPackaged: false,
- },
-}));
+import { describe, it, expect } from 'vitest';
import { RecentFile } from '@package/shared-types';
import { addToRecentList } from '../src/recent/addToRecentList';
import { getUniqueRecentFile } from '../src/recent/getUniqueRecentFile';
diff --git a/apps/main-processor/__tests__/settings.test.ts b/apps/main-processor/__tests__/settings.test.ts
new file mode 100644
index 0000000..149afa4
--- /dev/null
+++ b/apps/main-processor/__tests__/settings.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest';
+import { validateSettings } from '../src/utils/helper/setting-helper';
+import { READING_WIDTHS } from '../src/utils/constants/setting-constants';
+import { THEMES } from '@package/shared-constants';
+describe('settings validation', () => {
+ it('accepts valid settings', () => {
+ expect(validateSettings({ fontSize: 18 })).toEqual({ fontSize: 18 });
+ });
+
+ it('rejects unknown keys', () => {
+ expect(() => validateSettings({ unexpected: true } as never)).toThrow('Unknown settings key');
+ });
+
+ it('rejects invalid values', () => {
+ expect(() => validateSettings({ fontSize: 100 })).toThrow('Invalid fontSize');
+ });
+
+ it('validates theme values', () => {
+ expect(validateSettings({ theme: THEMES[0] })).toEqual({ theme: THEMES[0] });
+ expect(() => validateSettings({ theme: 'unknown-theme' as never })).toThrow('Invalid theme');
+ });
+
+ it('validates readingWidth values', () => {
+ expect(READING_WIDTHS.has('default')).toBe(true);
+ expect(validateSettings({ readingWidth: 'default' })).toEqual({ readingWidth: 'default' });
+ expect(() => validateSettings({ readingWidth: 'full' as never })).toThrow(
+ 'Invalid readingWidth'
+ );
+ });
+
+ it('validates boolean settings', () => {
+ expect(validateSettings({ lineNumbers: true })).toEqual({ lineNumbers: true });
+ expect(validateSettings({ showHiddenFiles: false })).toEqual({ showHiddenFiles: false });
+ expect(() => validateSettings({ lineNumbers: 'yes' as never })).toThrow('Invalid lineNumbers');
+ expect(() => validateSettings({ showHiddenFiles: 'no' as never })).toThrow(
+ 'Invalid showHiddenFiles'
+ );
+ });
+
+ it('validates customCss values', () => {
+ expect(validateSettings({ customCss: '.markdown-body { color: red; }' })).toEqual({
+ customCss: '.markdown-body { color: red; }',
+ });
+ expect(() => validateSettings({ customCss: 42 as never })).toThrow('Invalid customCss');
+ });
+
+ it('validates zoom range', () => {
+ expect(validateSettings({ zoom: 50 })).toEqual({ zoom: 50 });
+ expect(validateSettings({ zoom: 200 })).toEqual({ zoom: 200 });
+ expect(() => validateSettings({ zoom: 49 })).toThrow('Invalid zoom');
+ expect(() => validateSettings({ zoom: 201 })).toThrow('Invalid zoom');
+ });
+
+ it('validates recentFilesLimit range', () => {
+ expect(validateSettings({ recentFilesLimit: 1 })).toEqual({ recentFilesLimit: 1 });
+ expect(validateSettings({ recentFilesLimit: 50 })).toEqual({ recentFilesLimit: 50 });
+ expect(() => validateSettings({ recentFilesLimit: 0 })).toThrow('Invalid recentFilesLimit');
+ expect(() => validateSettings({ recentFilesLimit: 51 })).toThrow('Invalid recentFilesLimit');
+ });
+});
diff --git a/apps/main-processor/setup.ts b/apps/main-processor/setup.ts
new file mode 100644
index 0000000..00953d2
--- /dev/null
+++ b/apps/main-processor/setup.ts
@@ -0,0 +1,8 @@
+import { vi } from 'vitest';
+
+vi.mock('electron', () => ({
+ app: {
+ isPackaged: false,
+ getPath: vi.fn(),
+ },
+}));
diff --git a/apps/main-processor/src/export/inlineImage.ts b/apps/main-processor/src/export/inlineImage.ts
index a178504..be5d112 100644
--- a/apps/main-processor/src/export/inlineImage.ts
+++ b/apps/main-processor/src/export/inlineImage.ts
@@ -9,7 +9,7 @@ export async function inlineImages(html: string): Promise {
for (const match of matches) {
const fullMatch = match[0];
- const src = match[1];
+ const src = match[2];
if (!src) continue;
diff --git a/apps/main-processor/src/file.ts b/apps/main-processor/src/file.ts
index 1778dbb..6f2670d 100644
--- a/apps/main-processor/src/file.ts
+++ b/apps/main-processor/src/file.ts
@@ -1,5 +1,6 @@
import { readFile as fsReadFile } from 'node:fs/promises';
import chokidar, { type FSWatcher } from 'chokidar';
+import { WatchFileOptions } from './types/watch-file-types';
//file read logic
export async function readFile(filePath: string): Promise {
@@ -12,11 +13,17 @@ export async function readFile(filePath: string): Promise {
}
const currentWatchers = new Map();
+const debounceTimers = new Map();
//file watching logic
-export async function watchFile(filePath: string, onChange: () => void): Promise {
+export async function watchFile(
+ filePath: string,
+ options: WatchFileOptions | (() => void)
+): Promise {
+ const { onChange, onDeleted, onError } =
+ typeof options === 'function' ? { onChange: options } : options;
+
if (currentWatchers.has(filePath)) {
- await currentWatchers.get(filePath)!.close();
- currentWatchers.delete(filePath);
+ await unWatchFile(filePath);
}
const watcher = chokidar.watch(filePath, {
@@ -30,13 +37,47 @@ export async function watchFile(filePath: string, onChange: () => void): Promise
await new Promise((resolve) => {
watcher.on('ready', resolve);
});
- watcher.on('change', onChange);
+ watcher.on('change', () => {
+ const existingTimer = debounceTimers.get(filePath);
+ if (existingTimer) {
+ clearTimeout(existingTimer);
+ }
+
+ const timer = setTimeout(() => {
+ if (!currentWatchers.has(filePath)) {
+ debounceTimers.delete(filePath);
+ return;
+ }
+ debounceTimers.delete(filePath);
+ onChange();
+ }, 100);
+ debounceTimers.set(filePath, timer);
+ });
+ watcher.on('unlink', () => {
+ void unWatchFile(filePath).then(() => onDeleted?.());
+ });
+ watcher.on('error', (error) => {
+ const watcherError = error instanceof Error ? error : new Error(String(error));
+ void unWatchFile(filePath).then(() => onError?.(watcherError));
+ });
currentWatchers.set(filePath, watcher);
}
//unwatch file
export async function unWatchFile(filePath: string): Promise {
+ const timer = debounceTimers.get(filePath);
+ if (timer) {
+ clearTimeout(timer);
+ debounceTimers.delete(filePath);
+ }
if (!currentWatchers.has(filePath)) return;
await currentWatchers.get(filePath)!.close();
currentWatchers.delete(filePath);
}
+
+export function getWatcherDiagnostics(): { watchers: number; debounceTimers: number } {
+ return {
+ watchers: currentWatchers.size,
+ debounceTimers: debounceTimers.size,
+ };
+}
diff --git a/apps/main-processor/src/folder-search.ts b/apps/main-processor/src/folder-search.ts
new file mode 100644
index 0000000..b691183
--- /dev/null
+++ b/apps/main-processor/src/folder-search.ts
@@ -0,0 +1,45 @@
+import { readFile } from 'node:fs/promises';
+import { basename } from 'node:path';
+import { FolderSearchResult } from '@package/shared-types';
+import { MAX_RESULTS } from './utils/constants/folder-constants';
+import { collectMarkdownFiles } from './utils/helper/folder-search-helper';
+
+/*This searches Markdown files in a folder for text matches and returns formatted results*/
+export async function searchFolder(
+ folderPath: string,
+ query: string
+): Promise {
+ const normalizedQuery = query.trim().toLowerCase();
+ if (!normalizedQuery) return [];
+
+ const results: FolderSearchResult[] = [];
+ const files = await collectMarkdownFiles(folderPath);
+
+ for (const filePath of files) {
+ let content: string;
+ try {
+ content = await readFile(filePath, 'utf-8');
+ } catch (error) {
+ const code = (error as { code?: string }).code;
+ if (code === 'ENOENT' || code === 'EACCES' || code === 'EPERM') continue;
+ throw error;
+ }
+ const lines = content.split(/\r?\n/);
+ for (let index = 0; index < lines.length; index += 1) {
+ const lineText = lines[index] ?? '';
+ if (!lineText.toLowerCase().includes(normalizedQuery)) continue;
+
+ results.push({
+ filePath,
+ fileName: basename(filePath),
+ line: index + 1,
+ column: lineText.toLowerCase().indexOf(normalizedQuery) + 1,
+ preview: lineText.trim().slice(0, 240),
+ });
+
+ if (results.length >= MAX_RESULTS) return results;
+ }
+ }
+
+ return results;
+}
diff --git a/apps/main-processor/src/folder.ts b/apps/main-processor/src/folder.ts
index a2d5f09..36bf038 100644
--- a/apps/main-processor/src/folder.ts
+++ b/apps/main-processor/src/folder.ts
@@ -1,19 +1,42 @@
-import { readdir } from 'node:fs/promises';
+import { readdir, realpath } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { FileType } from '@package/shared-types';
import { isMarkdownFile } from './utils/helper/path-helper';
+import { MAX_FOLDER_DEPTH } from './utils/constants/path-constants';
-export async function getFolder(folderPath: string): Promise {
- const entries = await readdir(folderPath, { withFileTypes: true });
+export async function getFolder(
+ folderPath: string,
+ maxDepth = MAX_FOLDER_DEPTH,
+ currentDepth = 0,
+ seenPaths = new Set()
+): Promise {
+ let realFolderPath: string;
+ try {
+ realFolderPath = await realpath(folderPath);
+ } catch {
+ realFolderPath = folderPath;
+ }
+ if (currentDepth >= maxDepth || seenPaths.has(realFolderPath)) {
+ return {
+ name: basename(folderPath),
+ path: folderPath,
+ isDir: true,
+ children: [],
+ };
+ }
+ seenPaths.add(realFolderPath);
+ const entries = (await readdir(folderPath, { withFileTypes: true })).sort((a, b) =>
+ a.name.localeCompare(b.name)
+ );
const children: FileType[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
-
+ if (entry.isSymbolicLink()) continue;
const fullPath = join(folderPath, entry.name);
if (entry.isDirectory()) {
- children.push(await getFolder(fullPath));
+ children.push(await getFolder(fullPath, maxDepth, currentDepth + 1, seenPaths));
continue;
}
diff --git a/apps/main-processor/src/index.ts b/apps/main-processor/src/index.ts
index e330de4..422f9c3 100644
--- a/apps/main-processor/src/index.ts
+++ b/apps/main-processor/src/index.ts
@@ -6,16 +6,18 @@ import { PATHS } from './utils/constants/path-constants';
import { registerMenu } from './register-menu';
import { parseFilePathFromArgv } from './cli';
import { setupAutoUpdater } from './updater';
+import { resolveMarkdownFilePath } from './utils/helper/ipc-path-resolver';
let mainWindow: BrowserWindow | null = null;
let pendingFilePath: string | null = null;
-function sendFilePathToRenderer(filePath: string): void {
+async function sendFilePathToRenderer(filePath: string): Promise {
+ const safeFilePath = await resolveMarkdownFilePath(filePath);
if (!mainWindow) {
- pendingFilePath = filePath;
+ pendingFilePath = safeFilePath;
return;
}
- mainWindow.webContents.send('open-file-path', filePath);
+ mainWindow.webContents.send('open-file-path', safeFilePath);
}
// register all IPC before window is created
@@ -71,7 +73,9 @@ function createWindow(): void {
app.on('open-file', (event, filePath) => {
event.preventDefault();
- sendFilePathToRenderer(filePath);
+ void sendFilePathToRenderer(filePath).catch((error) => {
+ console.error('Failed to open file from Os:-', error);
+ });
});
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
diff --git a/apps/main-processor/src/ipc.ts b/apps/main-processor/src/ipc.ts
index 2ea84b7..6410d76 100644
--- a/apps/main-processor/src/ipc.ts
+++ b/apps/main-processor/src/ipc.ts
@@ -1,4 +1,4 @@
-import { ipcMain, dialog } from 'electron';
+import { app, ipcMain, dialog } from 'electron';
import { readFile, unWatchFile, watchFile } from './file';
import { getFolder } from './folder';
import { validatePath, validateSender, allowedFolderRoots } from './utils/constants/ipc-validation';
@@ -13,6 +13,10 @@ import {
resolveDirectoryPath,
resolveWatchedMarkdownPath,
} from './utils/helper/ipc-path-resolver';
+import { searchFolder } from './folder-search';
+import { AppSettings } from '@package/shared-types';
+import { getSettings } from './settings/get-settings';
+import { saveSettings } from './settings/save-settings';
//registers all IPC handlers for main process
export function registerIPCHandlers(): void {
@@ -113,6 +117,27 @@ export function registerIPCHandlers(): void {
return await getFolder(safeFolderPath);
});
+ ipcMain.handle(IPC_CONSTANTS.GET_SETTINGS, async (event) => {
+ if (!validateSender(event)) {
+ throw new Error('Untrusted sender');
+ }
+ return await getSettings();
+ });
+
+ ipcMain.handle(IPC_CONSTANTS.SAVE_SETTINGS, async (event, settings: Partial) => {
+ if (!validateSender(event)) {
+ throw new Error('Untrusted sender');
+ }
+ return await saveSettings(settings);
+ });
+
+ ipcMain.handle(IPC_CONSTANTS.GET_APP_VERSION, async (event) => {
+ if (!validateSender(event)) {
+ throw new Error('Untrusted sender');
+ }
+ return app.getVersion();
+ });
+
ipcMain.handle(
IPC_CONSTANTS.EXPORT_HTML,
async (event, html: string, css: string, outPath: string) => {
@@ -169,4 +194,20 @@ export function registerIPCHandlers(): void {
await exportDOCX(html, css, outPath);
}
);
+
+ ipcMain.handle(IPC_CONSTANTS.SEARCH_FOLDER, async (event, folderPath: string, query: string) => {
+ if (!validateSender(event)) {
+ throw new Error('Untrusted sender');
+ }
+
+ const safeFolderPath = await resolveDirectoryPath(folderPath);
+ allowedFolderRoots.add(safeFolderPath);
+ const isAllowed = Array.from(allowedFolderRoots).some(
+ (root) => safeFolderPath === root || safeFolderPath.startsWith(`${root}/`)
+ );
+ if (!isAllowed) {
+ throw new Error('Folder path is not authorized');
+ }
+ return await searchFolder(safeFolderPath, query);
+ });
}
diff --git a/apps/main-processor/src/menu.ts b/apps/main-processor/src/menu.ts
index f1bc096..061453f 100644
--- a/apps/main-processor/src/menu.ts
+++ b/apps/main-processor/src/menu.ts
@@ -1,5 +1,5 @@
import type { MenuItemConstructorOptions } from 'electron';
-import { MENU_EVENTS, MENU_LABELS, SHORTCUTS } from '@package/shared-constants';
+import { MENU_EVENTS, MENU_LABELS, SHORTCUTS, THEMES } from '@package/shared-constants';
import { createMenuSender } from './utils/helper/menu-helper';
export function buildMenuTemplate(): MenuItemConstructorOptions[] {
@@ -60,12 +60,24 @@ export function buildMenuTemplate(): MenuItemConstructorOptions[] {
accelerator: SHORTCUTS.FOCUS_MODE,
click: send(MENU_EVENTS.FOCUS_MODE),
},
+ {
+ label: MENU_LABELS.SETTINGS,
+ click: send(MENU_EVENTS.OPEN_SETTINGS),
+ },
{ type: 'separator' },
{
label: MENU_LABELS.CYCLE_THEME,
accelerator: SHORTCUTS.CYCLE_THEME,
click: send(MENU_EVENTS.CYCLE_THEME),
},
+ {
+ label: MENU_LABELS.THEME,
+ submenu: THEMES.map((theme) => ({
+ label: theme,
+ type: 'radio' as const,
+ click: send(MENU_EVENTS.SET_THEME, theme),
+ })),
+ },
{ type: 'separator' },
{
label: MENU_LABELS.ZOOM_IN,
@@ -83,7 +95,7 @@ export function buildMenuTemplate(): MenuItemConstructorOptions[] {
click: send(MENU_EVENTS.ZOOM_RESET),
},
{ type: 'separator' },
- { role: 'toggleDevTools' },
+ ...(process.env.NODE_ENV === 'development' ? [{ role: 'toggleDevTools' as const }] : []),
],
},
{
diff --git a/apps/main-processor/src/settings/get-settings.ts b/apps/main-processor/src/settings/get-settings.ts
new file mode 100644
index 0000000..0204a21
--- /dev/null
+++ b/apps/main-processor/src/settings/get-settings.ts
@@ -0,0 +1,19 @@
+import { readFile } from 'node:fs/promises';
+import { DEFAULT_SETTINGS } from '@package/shared-types';
+import { AppSettings } from '@package/shared-types';
+import { validateSettings, getSettingsPath } from '../utils/helper/setting-helper';
+
+/*Loads settings from local file system */
+export async function getSettings(): Promise {
+ const settingsPath = getSettingsPath();
+ try {
+ const data = await readFile(settingsPath, 'utf-8');
+ const parsed = JSON.parse(data);
+ if (parsed && typeof parsed === 'object') {
+ return { ...DEFAULT_SETTINGS, ...validateSettings(parsed) };
+ }
+ return DEFAULT_SETTINGS;
+ } catch {
+ return DEFAULT_SETTINGS;
+ }
+}
diff --git a/apps/main-processor/src/settings/save-settings.ts b/apps/main-processor/src/settings/save-settings.ts
new file mode 100644
index 0000000..4aa64d7
--- /dev/null
+++ b/apps/main-processor/src/settings/save-settings.ts
@@ -0,0 +1,23 @@
+import { AppSettings } from '@package/shared-types';
+import {
+ validateSettings,
+ getSettingsPath,
+ writeSettingsAtomically,
+} from '../utils/helper/setting-helper';
+import { getSettings } from './get-settings';
+import { runExclusive } from '../utils/helper/setting-helper';
+
+/*Save settings changes to local file*/
+export async function saveSettings(settings: Partial): Promise {
+ return runExclusive(async () => {
+ const validatedSettings = validateSettings(settings);
+ const nextSettings: AppSettings = { ...(await getSettings()), ...validatedSettings };
+ const settingsPath = getSettingsPath();
+ try {
+ await writeSettingsAtomically(settingsPath, nextSettings);
+ return nextSettings;
+ } catch (error) {
+ throw new Error('Failed to save settings file', { cause: error });
+ }
+ });
+}
diff --git a/apps/main-processor/src/types/watch-file-types.ts b/apps/main-processor/src/types/watch-file-types.ts
new file mode 100644
index 0000000..cc19e8a
--- /dev/null
+++ b/apps/main-processor/src/types/watch-file-types.ts
@@ -0,0 +1,5 @@
+export type WatchFileOptions = {
+ onChange: () => void;
+ onDeleted?: () => void;
+ onError?: (error: Error) => void;
+};
diff --git a/apps/main-processor/src/utils/constants/folder-constants.ts b/apps/main-processor/src/utils/constants/folder-constants.ts
new file mode 100644
index 0000000..ef3305c
--- /dev/null
+++ b/apps/main-processor/src/utils/constants/folder-constants.ts
@@ -0,0 +1,2 @@
+export const MAX_FOLDER_DEPTH = 10;
+export const MAX_RESULTS = 500;
diff --git a/apps/main-processor/src/utils/constants/path-constants.ts b/apps/main-processor/src/utils/constants/path-constants.ts
index 1a20707..a8c72c6 100644
--- a/apps/main-processor/src/utils/constants/path-constants.ts
+++ b/apps/main-processor/src/utils/constants/path-constants.ts
@@ -11,4 +11,6 @@ export const PATHS = {
export const MAX_RECENT = 20;
+export const MAX_FOLDER_DEPTH = 10;
+
export const MARKDOWN_FILE_PATTERN = /\.(md|markdown)$/i;
diff --git a/apps/main-processor/src/utils/constants/setting-constants.ts b/apps/main-processor/src/utils/constants/setting-constants.ts
new file mode 100644
index 0000000..edf0b6c
--- /dev/null
+++ b/apps/main-processor/src/utils/constants/setting-constants.ts
@@ -0,0 +1,14 @@
+import { AppSettings } from '@package/shared-types';
+
+export const SETTINGS_KEYS = new Set([
+ 'theme',
+ 'fontSize',
+ 'readingWidth',
+ 'lineNumbers',
+ 'customCss',
+ 'zoom',
+ 'recentFilesLimit',
+ 'showHiddenFiles',
+]);
+
+export const READING_WIDTHS = new Set(['narrow', 'default', 'wide']);
diff --git a/apps/main-processor/src/utils/helper/folder-search-helper.ts b/apps/main-processor/src/utils/helper/folder-search-helper.ts
new file mode 100644
index 0000000..91a95ba
--- /dev/null
+++ b/apps/main-processor/src/utils/helper/folder-search-helper.ts
@@ -0,0 +1,35 @@
+import { readdir } from 'node:fs/promises';
+import { join } from 'node:path';
+import { MAX_FOLDER_DEPTH, MAX_RESULTS } from '../constants/folder-constants';
+import { MARKDOWN_FILE_PATTERN } from '@package/shared-constants';
+
+/*It finds and collects Markdown file paths up to depth and result limits */
+export async function collectMarkdownFiles(
+ folderPath: string,
+ files: string[] = [],
+ currentDepth = 0
+): Promise {
+ if (currentDepth >= MAX_FOLDER_DEPTH || files.length >= MAX_RESULTS) return files;
+
+ let entries;
+ try {
+ entries = await readdir(folderPath, { withFileTypes: true });
+ } catch (error) {
+ const code = (error as { code?: string }).code;
+ if (code === 'ENOENT' || code === 'EACCES' || code === 'EPERM') return files;
+ throw error;
+ }
+ entries.sort((a, b) => a.name.localeCompare(b.name));
+ for (const entry of entries) {
+ if (entry.name.startsWith('.') || entry.isSymbolicLink()) continue;
+ const fullPath = join(folderPath, entry.name);
+ if (entry.isDirectory()) {
+ await collectMarkdownFiles(fullPath, files, currentDepth + 1);
+ } else if (MARKDOWN_FILE_PATTERN.test(entry.name)) {
+ files.push(fullPath);
+ }
+ if (files.length >= MAX_RESULTS) break;
+ }
+
+ return files;
+}
diff --git a/apps/main-processor/src/utils/helper/menu-helper.ts b/apps/main-processor/src/utils/helper/menu-helper.ts
index df0781e..50e698e 100644
--- a/apps/main-processor/src/utils/helper/menu-helper.ts
+++ b/apps/main-processor/src/utils/helper/menu-helper.ts
@@ -1,9 +1,9 @@
import { BrowserWindow, BaseWindow, MenuItem, KeyboardEvent } from 'electron';
-export function createMenuSender(eventName: string) {
+export function createMenuSender(eventName: string, payload?: unknown) {
return (_menuItem: MenuItem, window: BaseWindow | undefined, _event: KeyboardEvent): void => {
if (window instanceof BrowserWindow) {
- window.webContents.send(eventName);
+ window.webContents.send(eventName, payload);
}
};
}
diff --git a/apps/main-processor/src/utils/helper/setting-helper.ts b/apps/main-processor/src/utils/helper/setting-helper.ts
new file mode 100644
index 0000000..5e96b3d
--- /dev/null
+++ b/apps/main-processor/src/utils/helper/setting-helper.ts
@@ -0,0 +1,112 @@
+import { app } from 'electron';
+import path from 'path';
+import { mkdir, open, rename, unlink, writeFile } from 'node:fs/promises';
+import { AppSettings } from '@package/shared-types';
+import { THEMES } from '@package/shared-constants';
+import { SETTINGS_KEYS, READING_WIDTHS } from '../constants/setting-constants';
+
+/* Gives file system path for settings.json */
+export function getSettingsPath(): string {
+ return path.join(app.getPath('userData'), 'settings.json');
+}
+
+/* To validate the settings preference before saving */
+export function validateSettings(partial: Partial): Partial {
+ if (!partial || typeof partial !== 'object' || Array.isArray(partial)) {
+ throw new Error('Invalid settings object');
+ }
+
+ const validated: Partial = {};
+ for (const [key, value] of Object.entries(partial)) {
+ if (!SETTINGS_KEYS.has(key as keyof AppSettings)) {
+ throw new Error(`Unknown settings key: ${key}`);
+ }
+
+ if (key === 'theme') {
+ if (typeof value !== 'string' || !THEMES.includes(value as AppSettings['theme'])) {
+ throw new Error('Invalid theme');
+ }
+ validated.theme = value as AppSettings['theme'];
+ }
+ if (key === 'fontSize') {
+ if (typeof value !== 'number' || value < 12 || value > 24)
+ throw new Error('Invalid fontSize');
+ validated.fontSize = value;
+ }
+ if (key === 'readingWidth') {
+ if (!READING_WIDTHS.has(value as AppSettings['readingWidth'])) {
+ throw new Error('Invalid readingWidth');
+ }
+ validated.readingWidth = value as AppSettings['readingWidth'];
+ }
+ if (key === 'lineNumbers') {
+ if (typeof value !== 'boolean') throw new Error('Invalid lineNumbers');
+ validated.lineNumbers = value;
+ }
+ if (key === 'customCss') {
+ if (typeof value !== 'string') throw new Error('Invalid customCss');
+ validated.customCss = value;
+ }
+ if (key === 'zoom') {
+ if (typeof value !== 'number' || value < 50 || value > 200) throw new Error('Invalid zoom');
+ validated.zoom = value;
+ }
+ if (key === 'recentFilesLimit') {
+ if (typeof value !== 'number' || value < 1 || value > 50) {
+ throw new Error('Invalid recentFilesLimit');
+ }
+ validated.recentFilesLimit = value;
+ }
+ if (key === 'showHiddenFiles') {
+ if (typeof value !== 'boolean') throw new Error('Invalid showHiddenFiles');
+ validated.showHiddenFiles = value;
+ }
+ }
+
+ return validated;
+}
+
+/* writes settings to disk without risking file corruption*/
+export async function writeSettingsAtomically(
+ settingsPath: string,
+ nextSettings: AppSettings
+): Promise {
+ const dir = path.dirname(settingsPath);
+ const tempPath = `${settingsPath}.tmp`;
+
+ try {
+ await mkdir(dir, { recursive: true });
+
+ const tempFile = await open(tempPath, 'w');
+ try {
+ const data = JSON.stringify(nextSettings, null, 2);
+ await tempFile.writeFile(data, 'utf-8');
+ await tempFile.sync();
+ } finally {
+ await tempFile.close();
+ }
+ await rename(tempPath, settingsPath);
+ } catch (error) {
+ await unlink(tempPath).catch(() => {});
+ throw error;
+ }
+}
+
+/* Runs an asynchronous operation sequentially to prevent concurrent execution*/
+let mutex: Promise = Promise.resolve();
+
+export async function runExclusive(operation: () => Promise): Promise {
+ const previous = mutex;
+ let release!: () => void;
+ mutex = new Promise((resolve) => {
+ release = resolve;
+ });
+
+ await previous.catch(() => {});
+
+ try {
+ return await operation();
+ } finally {
+ release();
+ }
+}
diff --git a/apps/main-processor/vitest.config.ts b/apps/main-processor/vitest.config.ts
index ad8c5b7..d210e65 100644
--- a/apps/main-processor/vitest.config.ts
+++ b/apps/main-processor/vitest.config.ts
@@ -6,6 +6,7 @@ export default defineConfig({
environment: 'node',
globals: true,
exclude: ['dist/**', 'node_modules/**'],
+ setupFiles: ['./setup.ts'],
},
resolve: {
alias: {
diff --git a/apps/preload/src/index.ts b/apps/preload/src/index.ts
index 3a6179b..8c73d60 100644
--- a/apps/preload/src/index.ts
+++ b/apps/preload/src/index.ts
@@ -1,8 +1,9 @@
-import { contextBridge, ipcRenderer, webUtils } from 'electron';
+import { contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from 'electron';
import { MarkdownReaderAPI } from '@package/shared-types';
import { IPC_CONSTANTS } from '@package/shared-constants';
import { BRIDGE_NAME } from '@package/shared-constants';
import { MENU_EVENT_LIST } from '@package/shared-constants';
+import { isMenuEvent } from './utils/menu-event-helper';
const apiContract: MarkdownReaderAPI = {
readFile: (path) => ipcRenderer.invoke(IPC_CONSTANTS.READ_FILE, path),
@@ -15,12 +16,20 @@ const apiContract: MarkdownReaderAPI = {
getSettings: () => ipcRenderer.invoke(IPC_CONSTANTS.GET_SETTINGS),
saveSettings: (settings) => ipcRenderer.invoke(IPC_CONSTANTS.SAVE_SETTINGS, settings),
getAppVersion: () => ipcRenderer.invoke(IPC_CONSTANTS.GET_APP_VERSION),
+ searchFolder: (path, query) => ipcRenderer.invoke(IPC_CONSTANTS.SEARCH_FOLDER, path, query),
watchFile: (path) => ipcRenderer.invoke(IPC_CONSTANTS.WATCH_FILE, path),
unWatchFile: (path) => ipcRenderer.invoke(IPC_CONSTANTS.UNWATCH_FILE, path),
onFileChanged: (callback: (path: string) => void) =>
ipcRenderer.on(IPC_CONSTANTS.FILE_CHANGED, (_event, path: string) => callback(path)),
removeFileChangedListener: () => ipcRenderer.removeAllListeners(IPC_CONSTANTS.FILE_CHANGED),
- onMenuEvent: (event: string, callback: () => void) => ipcRenderer.on(event, () => callback()),
+ onMenuEvent: (event: string, callback: (payload?: unknown) => void) => {
+ if (!isMenuEvent(event)) {
+ throw new Error(`Unsupported menu event: ${event}`);
+ }
+ const handler = (_event: IpcRendererEvent, payload?: unknown) => callback(payload);
+ ipcRenderer.on(event, handler);
+ return () => ipcRenderer.removeListener(event, handler);
+ },
removeMenuListeners: () =>
MENU_EVENT_LIST.forEach((event) => {
ipcRenderer.removeAllListeners(event);
diff --git a/apps/preload/src/utils/menu-event-helper.ts b/apps/preload/src/utils/menu-event-helper.ts
new file mode 100644
index 0000000..98595eb
--- /dev/null
+++ b/apps/preload/src/utils/menu-event-helper.ts
@@ -0,0 +1,7 @@
+import { MENU_EVENT_LIST } from '@package/shared-constants';
+
+export type MenuEventName = (typeof MENU_EVENT_LIST)[number];
+
+export function isMenuEvent(event: string): event is MenuEventName {
+ return MENU_EVENT_LIST.includes(event as MenuEventName);
+}
diff --git a/apps/renderer/tests/components/Sidebar.test.tsx b/apps/renderer/__tests__/components/Sidebar.test.tsx
similarity index 100%
rename from apps/renderer/tests/components/Sidebar.test.tsx
rename to apps/renderer/__tests__/components/Sidebar.test.tsx
diff --git a/apps/renderer/tests/components/StatusBar.test.tsx b/apps/renderer/__tests__/components/StatusBar.test.tsx
similarity index 100%
rename from apps/renderer/tests/components/StatusBar.test.tsx
rename to apps/renderer/__tests__/components/StatusBar.test.tsx
diff --git a/apps/renderer/tests/components/TabBar.test.tsx b/apps/renderer/__tests__/components/TabBar.test.tsx
similarity index 100%
rename from apps/renderer/tests/components/TabBar.test.tsx
rename to apps/renderer/__tests__/components/TabBar.test.tsx
diff --git a/apps/renderer/tests/components/Welcome.test.tsx b/apps/renderer/__tests__/components/Welcome.test.tsx
similarity index 100%
rename from apps/renderer/tests/components/Welcome.test.tsx
rename to apps/renderer/__tests__/components/Welcome.test.tsx
diff --git a/apps/renderer/tests/hooks/useCollapsibleToc.test.ts b/apps/renderer/__tests__/hooks/useCollapsibleToc.test.ts
similarity index 92%
rename from apps/renderer/tests/hooks/useCollapsibleToc.test.ts
rename to apps/renderer/__tests__/hooks/useCollapsibleToc.test.ts
index 664b22b..5d952ea 100644
--- a/apps/renderer/tests/hooks/useCollapsibleToc.test.ts
+++ b/apps/renderer/__tests__/hooks/useCollapsibleToc.test.ts
@@ -20,15 +20,19 @@ describe('Table of Contents Hook Tests', () => {
test('should hide nested sub items when their parent is collapsed', () => {
const { result } = renderHook(() => useCollapsibleToc(sampleDocumentItems));
- expect(result.current.visibleItems.length).toBe(4);
+ expect(result.current.visibleItems.length).toBe(3);
act(() => {
result.current.toggleItem('section-1.1');
});
- expect(result.current.visibleItems.length).toBe(3);
+ expect(result.current.visibleItems.length).toBe(4);
const containsSubSection = result.current.visibleItems.some(
(item) => item.id === 'subsection-1.1.1'
);
- expect(containsSubSection).toBe(false);
+ expect(containsSubSection).toBe(true);
+ act(() => {
+ result.current.toggleItem('section-1.1');
+ });
+ expect(result.current.visibleItems.length).toBe(3);
});
test('should handle completely empty lists without crashing the app', () => {
diff --git a/apps/renderer/tests/hooks/useSearch.test.ts b/apps/renderer/__tests__/hooks/useSearch.test.ts
similarity index 100%
rename from apps/renderer/tests/hooks/useSearch.test.ts
rename to apps/renderer/__tests__/hooks/useSearch.test.ts
diff --git a/apps/renderer/tests/hooks/useSettings.test.ts b/apps/renderer/__tests__/hooks/useSettings.test.ts
similarity index 100%
rename from apps/renderer/tests/hooks/useSettings.test.ts
rename to apps/renderer/__tests__/hooks/useSettings.test.ts
diff --git a/apps/renderer/tests/renderer/callout.test.ts b/apps/renderer/__tests__/renderer/callout.test.ts
similarity index 100%
rename from apps/renderer/tests/renderer/callout.test.ts
rename to apps/renderer/__tests__/renderer/callout.test.ts
diff --git a/apps/renderer/tests/renderer/drag-drop.test.ts b/apps/renderer/__tests__/renderer/drag-drop.test.ts
similarity index 100%
rename from apps/renderer/tests/renderer/drag-drop.test.ts
rename to apps/renderer/__tests__/renderer/drag-drop.test.ts
diff --git a/apps/renderer/tests/renderer/katex.test.ts b/apps/renderer/__tests__/renderer/katex.test.ts
similarity index 100%
rename from apps/renderer/tests/renderer/katex.test.ts
rename to apps/renderer/__tests__/renderer/katex.test.ts
diff --git a/apps/renderer/tests/renderer/markdown.test.ts b/apps/renderer/__tests__/renderer/markdown.test.ts
similarity index 100%
rename from apps/renderer/tests/renderer/markdown.test.ts
rename to apps/renderer/__tests__/renderer/markdown.test.ts
diff --git a/apps/renderer/tests/renderer/mermaid.test.ts b/apps/renderer/__tests__/renderer/mermaid.test.ts
similarity index 100%
rename from apps/renderer/tests/renderer/mermaid.test.ts
rename to apps/renderer/__tests__/renderer/mermaid.test.ts
diff --git a/apps/renderer/tests/renderer/sanitize.test.ts b/apps/renderer/__tests__/renderer/sanitize.test.ts
similarity index 100%
rename from apps/renderer/tests/renderer/sanitize.test.ts
rename to apps/renderer/__tests__/renderer/sanitize.test.ts
diff --git a/apps/renderer/tests/renderer/sikhi.test.ts b/apps/renderer/__tests__/renderer/sikhi.test.ts
similarity index 100%
rename from apps/renderer/tests/renderer/sikhi.test.ts
rename to apps/renderer/__tests__/renderer/sikhi.test.ts
diff --git a/apps/renderer/tests/renderer/toc.test.ts b/apps/renderer/__tests__/renderer/toc.test.ts
similarity index 52%
rename from apps/renderer/tests/renderer/toc.test.ts
rename to apps/renderer/__tests__/renderer/toc.test.ts
index b8f7b43..ecc359a 100644
--- a/apps/renderer/tests/renderer/toc.test.ts
+++ b/apps/renderer/__tests__/renderer/toc.test.ts
@@ -4,47 +4,63 @@ import { extractTOC } from '../../src/renderer/toc';
describe('extract table of contents', () => {
//test 1: checks empty array return from no heading
it('returns empty array for HTML with no headings', () => {
- const html = '
My name is Ashminita
';
- const toc = extractTOC(html);
+ const markdown = 'My name is Ashminita';
+ const toc = extractTOC(markdown);
expect(toc).toEqual([]);
});
// test 2:- checks single heading extraction
it('extracts a single H1 headings', () => {
- const html = '
Introduction
text
';
- const toc = extractTOC(html);
+ const markdown = '# Introduction\n\ntext';
+ const toc = extractTOC(markdown);
expect(toc).toHaveLength(1);
- expect(toc[0]).toEqual({ id: 'intro', text: 'Introduction', level: 1 });
+ expect(toc[0]).toEqual({ id: 'introduction', text: 'Introduction', level: 1 });
});
//test 3:-headings without id generate a slug from the text
it('generates a id when heading has no id attribute', () => {
- const html = '
Getting Started
';
+ const html = '## Getting Started';
const toc = extractTOC(html);
expect(toc[0]?.id).toBe('getting-started');
});
//test 4:- checks duplicate id
it('generates unique ids for duplicate headings', () => {
- const html = `