diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 8c61ecc..7ad8191 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -113,7 +113,7 @@ reviews: - Markdown prose must remain readable for tables, code, blockquotes, links, lists, and images. - Prefer existing tokens/classes over ad hoc inline styling. - - path: 'packages/shared-* /src/**/*.ts' + - path: 'packages/shared-*/src/**/*.ts' instructions: | Review shared package contracts. - IPC constants, menu constants, shortcuts, and shared types are public contracts. diff --git a/apps/main-processor/src/export/exportPdf.ts b/apps/main-processor/src/export/exportPdf.ts index 1912017..4f4613e 100644 --- a/apps/main-processor/src/export/exportPdf.ts +++ b/apps/main-processor/src/export/exportPdf.ts @@ -1,5 +1,8 @@ +import { once } from 'node:events'; import { BrowserWindow } from 'electron'; -import { writeFile } from 'node:fs/promises'; +import { writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { buildDocument } from './buildDocument'; import { sanitizeCss } from './sanitizeCss'; import { inlineImages } from './inlineImage'; @@ -11,12 +14,16 @@ export async function exportPDF(bodyHtml: string, css: string, outputPath: strin sandbox: true, }, }); - + const tempFilePath = join( + tmpdir(), + `pdf-export-${Date.now()}-${Math.random().toString(36).slice(2, 9)}.html` + ); try { const htmlWithInlineImages = await inlineImages(bodyHtml); const html = buildDocument(htmlWithInlineImages, sanitizeCss(css)); - await pdfWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + await writeFile(tempFilePath, html, 'utf8'); + await pdfWindow.loadFile(tempFilePath); await pdfWindow.webContents.executeJavaScript(` new Promise((resolve) => { @@ -41,6 +48,17 @@ export async function exportPDF(bodyHtml: string, css: string, outputPath: strin }); await writeFile(outputPath, pdfBuffer); } finally { - pdfWindow.close(); + const closed = pdfWindow.isDestroyed() + ? Promise.resolve() + : once(pdfWindow, 'closed').then(() => undefined); + if (!pdfWindow.isDestroyed()) { + pdfWindow.close(); + } + await closed; + try { + await rm(tempFilePath, { force: true }); + } catch (error) { + console.error('Failed to clean up PDF export temp file:', error); + } } } diff --git a/apps/main-processor/src/index.ts b/apps/main-processor/src/index.ts index 422f9c3..2fce901 100644 --- a/apps/main-processor/src/index.ts +++ b/apps/main-processor/src/index.ts @@ -97,7 +97,7 @@ if (!hasSingleInstanceLock) { // electron ready window app.whenReady().then(() => { - registerMenu(); + registerMenu('github-light'); createWindow(); //re create window when dock icon clicked in macOs diff --git a/apps/main-processor/src/ipc.ts b/apps/main-processor/src/ipc.ts index 76c28ee..1d86db9 100644 --- a/apps/main-processor/src/ipc.ts +++ b/apps/main-processor/src/ipc.ts @@ -1,4 +1,5 @@ import { app, ipcMain, dialog } from 'electron'; +import { sep } from 'node:path'; import { readFile, unWatchFile, watchFile } from './file'; import { getFolder } from './folder'; import { @@ -209,7 +210,7 @@ export function registerIPCHandlers(): void { } const safeFolderPath = await resolveDirectoryPath(folderPath); const isAllowed = Array.from(allowedFolderRoots).some( - (root) => safeFolderPath === root || safeFolderPath.startsWith(`${root}/`) + (root) => safeFolderPath === root || safeFolderPath.startsWith(`${root}${sep}`) ); if (!isAllowed) { throw new Error('Folder path is not authorized'); diff --git a/apps/main-processor/src/menu.ts b/apps/main-processor/src/menu.ts index 061453f..fb9b19f 100644 --- a/apps/main-processor/src/menu.ts +++ b/apps/main-processor/src/menu.ts @@ -2,7 +2,7 @@ import type { MenuItemConstructorOptions } from 'electron'; import { MENU_EVENTS, MENU_LABELS, SHORTCUTS, THEMES } from '@package/shared-constants'; import { createMenuSender } from './utils/helper/menu-helper'; -export function buildMenuTemplate(): MenuItemConstructorOptions[] { +export function buildMenuTemplate(currentTheme: string): MenuItemConstructorOptions[] { const send = createMenuSender; return [ @@ -75,6 +75,7 @@ export function buildMenuTemplate(): MenuItemConstructorOptions[] { submenu: THEMES.map((theme) => ({ label: theme, type: 'radio' as const, + checked: theme === currentTheme, click: send(MENU_EVENTS.SET_THEME, theme), })), }, diff --git a/apps/main-processor/src/register-menu.ts b/apps/main-processor/src/register-menu.ts index b57d678..5565d48 100644 --- a/apps/main-processor/src/register-menu.ts +++ b/apps/main-processor/src/register-menu.ts @@ -1,7 +1,7 @@ import { Menu } from 'electron'; import { buildMenuTemplate } from './menu'; -export function registerMenu(): void { - const menu = Menu.buildFromTemplate(buildMenuTemplate()); +export function registerMenu(currentTheme: string): void { + const menu = Menu.buildFromTemplate(buildMenuTemplate(currentTheme)); Menu.setApplicationMenu(menu); } diff --git a/apps/main-processor/src/settings/get-settings.ts b/apps/main-processor/src/settings/get-settings.ts index 0204a21..01763ac 100644 --- a/apps/main-processor/src/settings/get-settings.ts +++ b/apps/main-processor/src/settings/get-settings.ts @@ -9,7 +9,7 @@ export async function getSettings(): Promise { try { const data = await readFile(settingsPath, 'utf-8'); const parsed = JSON.parse(data); - if (parsed && typeof parsed === 'object') { + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return { ...DEFAULT_SETTINGS, ...validateSettings(parsed) }; } return DEFAULT_SETTINGS; diff --git a/apps/main-processor/src/updater.ts b/apps/main-processor/src/updater.ts index f95b69e..f5771dc 100644 --- a/apps/main-processor/src/updater.ts +++ b/apps/main-processor/src/updater.ts @@ -6,6 +6,10 @@ export function setupAutoUpdater(window: BrowserWindow): void { autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; + autoUpdater.removeAllListeners('update-available'); + autoUpdater.removeAllListeners('update-downloaded'); + autoUpdater.removeAllListeners('error'); + autoUpdater.on('update-available', (info: UpdateInfo) => { window.webContents.send(IPC_CONSTANTS.UPDATE_AVAILABLE, info.version); }); diff --git a/apps/main-processor/src/utils/constants/ipc-validation.ts b/apps/main-processor/src/utils/constants/ipc-validation.ts index 13d2238..b48925d 100644 --- a/apps/main-processor/src/utils/constants/ipc-validation.ts +++ b/apps/main-processor/src/utils/constants/ipc-validation.ts @@ -39,12 +39,13 @@ export function validatePath(filePath: string) { // Prevent access to sensitive OS system folders const lower = resolvedPath.toLowerCase(); if (process.platform === 'win32') { + const sysDrive = (process.env.SystemDrive ?? 'C:').toLowerCase(); const forbiddenPrefixes = [ - 'c:\\windows\\', - 'c:\\winnt\\', - 'c:\\boot\\', - 'c:\\system volume information\\', - 'c:\\$recycle.bin\\', + `${sysDrive}\\windows\\`, + `${sysDrive}\\winnt\\`, + `${sysDrive}\\boot\\`, + `${sysDrive}\\system volume information\\`, + `${sysDrive}\\$recycle.bin\\`, ]; if (forbiddenPrefixes.some((p) => lower === p.slice(0, -1) || lower.startsWith(p))) { return false; diff --git a/apps/renderer/index.html b/apps/renderer/index.html index 406194a..e35395f 100644 --- a/apps/renderer/index.html +++ b/apps/renderer/index.html @@ -9,7 +9,7 @@ diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx index 00cf48f..ce554f6 100644 --- a/apps/renderer/src/App.tsx +++ b/apps/renderer/src/App.tsx @@ -13,7 +13,6 @@ import { SearchBar } from './components/SearchBar'; import { useSettings } from './hooks/useSettings'; import { StatusBar } from './components/StatusBar'; import { FileBrowser } from './components/FileBrowser'; -import { extractTOC } from './renderer/toc'; import { TabBar } from './components/TabBar'; import { useTabStore } from './hooks/useTabStore'; import { Icons } from './utils/constants/icon-contants'; @@ -198,7 +197,7 @@ useShortcuts({ )} {!focusMode && ( ): Marked { + const instance = new Marked(); // configure marked with GFM options instance.use({ gfm: true, breaks: false, - renderer: { heading }, + renderer: { + heading(props) { + return heading(props, registry); + }, + }, }); //configure marked to use Shikhi for code blocks diff --git a/apps/renderer/src/hooks/useFileActions.ts b/apps/renderer/src/hooks/useFileActions.ts index 0eb1426..8e371d3 100644 --- a/apps/renderer/src/hooks/useFileActions.ts +++ b/apps/renderer/src/hooks/useFileActions.ts @@ -32,6 +32,7 @@ export function useFileActions({ loadFile, dispatch }: FileActionProps) { ); const openFileDialog = useCallback(() => { + if (!window.api) return; void window.api.openFileDialog().then((chosenPath) => { if (chosenPath) { void loadFileInTab(chosenPath); diff --git a/apps/renderer/src/hooks/useFilePersistence.ts b/apps/renderer/src/hooks/useFilePersistence.ts index 113cde6..86a3c5c 100644 --- a/apps/renderer/src/hooks/useFilePersistence.ts +++ b/apps/renderer/src/hooks/useFilePersistence.ts @@ -10,18 +10,35 @@ export function useFilePersistence({ contentRef, setShowToast, }: FilePersistenceProps) { - const debounceTimer = useRef(undefined); - const scrollTimer = useRef(undefined); + const debounceTimer = useRef | undefined>(undefined); + const scrollTimer = useRef | undefined>(undefined); + const isMounted = useRef(true); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + if (debounceTimer.current) { + window.clearTimeout(debounceTimer.current); + debounceTimer.current = undefined; + } + if (scrollTimer.current) { + window.clearTimeout(scrollTimer.current); + scrollTimer.current = undefined; + } + }; + }, []); const handleFileChange = useCallback(() => { if (!activeTab) return; if (debounceTimer.current) { - clearTimeout(debounceTimer.current); + window.clearTimeout(debounceTimer.current); } debounceTimer.current = window.setTimeout(async () => { + if (!debounceTimer.current) return; const currentScroll = contentRef.current?.scrollTop ?? 0; const result = await loadFile(activeTab.filePath); - if (!result) return; + if (!result || !isMounted.current) return; dispatch({ type: 'UPDATE_TAB_STATE', payload: { @@ -43,10 +60,11 @@ export function useFilePersistence({ if (!activeTab || !contentRef.current) return; if (scrollTimer.current) { - clearTimeout(scrollTimer.current); + window.clearTimeout(scrollTimer.current); } scrollTimer.current = window.setTimeout(() => { + if (!scrollTimer.current || !contentRef.current) return; saveScrollPos(activeTab.filePath, contentRef.current!.scrollTop); dispatch({ @@ -67,7 +85,7 @@ export function useFilePersistence({ contentRef.current.scrollTop = activeTab.scrollTop ?? getScrollPos(activeTab.filePath); } }); - }, [activeTab?.id, activeTab?.html]); + }, [activeTab?.id, activeTab?.html, contentRef]); return { scroll }; } diff --git a/apps/renderer/src/renderer/markdown.ts b/apps/renderer/src/renderer/markdown.ts index e5b6a4c..5ff6a0b 100644 --- a/apps/renderer/src/renderer/markdown.ts +++ b/apps/renderer/src/renderer/markdown.ts @@ -1,13 +1,16 @@ import { getMarkdown } from '../config/marked'; +import { createHeadingRegistry } from '../utils/helpers/heading-helper'; import { parseCallouts } from './callout'; // converts markdown text into plain HTML string export async function renderMarkdown(markdownText: string): Promise { + const registry = createHeadingRegistry(); + if (!markdownText || markdownText.trim() === '') { return ''; } - const marked = getMarkdown(); + const marked = getMarkdown(registry); let result = await marked.parse(markdownText); if (result.includes('$')) { const { processAllMath } = await import('./katex'); diff --git a/apps/renderer/src/renderer/shiki.ts b/apps/renderer/src/renderer/shiki.ts index bdfb94e..2fd89c0 100644 --- a/apps/renderer/src/renderer/shiki.ts +++ b/apps/renderer/src/renderer/shiki.ts @@ -18,6 +18,9 @@ export async function shikiHighlighter(): Promise { themes, langs, engine: createJavaScriptRegexEngine(), + }).catch((err) => { + highlighter = null; + throw err; }); } return highlighter; diff --git a/apps/renderer/src/renderer/toc.ts b/apps/renderer/src/renderer/toc.ts index 2c6db4f..2d3c930 100644 --- a/apps/renderer/src/renderer/toc.ts +++ b/apps/renderer/src/renderer/toc.ts @@ -1,11 +1,16 @@ import { lexer } from 'marked'; import { TOCType } from '../types/component-types'; -import { getHeadingId, isHeadingToken, headingText } from '../utils/helpers/heading-helper'; +import { + getHeadingId, + isHeadingToken, + headingText, + createHeadingRegistry, +} from '../utils/helpers/heading-helper'; // extracts table of content from HTML string export function extractTOC(html: string): TOCType[] { const items: TOCType[] = []; - const idCount = new Map(); + const registry = createHeadingRegistry(); const tokens = lexer(html); for (const token of tokens) { @@ -14,12 +19,7 @@ export function extractTOC(html: string): TOCType[] { const level = Math.min(token.depth, 3) as 1 | 2 | 3; const text = headingText(token); if (!text) continue; - const firstid = getHeadingId(text); - - const count = idCount.get(firstid) ?? 0; - idCount.set(firstid, count + 1); - const id = count === 0 ? firstid : `${firstid}-${count}`; - + const id = getHeadingId(text, registry); items.push({ id, text, level }); } return items; diff --git a/apps/renderer/src/utils/helpers/heading-helper.ts b/apps/renderer/src/utils/helpers/heading-helper.ts index c9dd5de..66b5e72 100644 --- a/apps/renderer/src/utils/helpers/heading-helper.ts +++ b/apps/renderer/src/utils/helpers/heading-helper.ts @@ -2,20 +2,27 @@ import { HeadingProps } from '../../types/component-types'; import { SLUG_PATTERNS, HTML_PATTERNS } from '../constants/regex-constants'; import { parseInline, type Tokens } from 'marked'; +export function createHeadingRegistry(): Map { + return new Map(); +} + // converts heading text into a ID -export function getHeadingId(text: string): string { +export function getHeadingId(text: string, registry: Map): string { let id = text.toLowerCase(); id = id.replace(SLUG_PATTERNS.NON_WORD, ''); id = id.replace(SLUG_PATTERNS.SPACES, '-'); id = id.trim().replace(SLUG_PATTERNS.TRIM_HYPHENS, ''); - return id; + const count = registry.get(id) || 0; + registry.set(id, count + 1); + + return count === 0 ? id : `${id}-${count}`; } // assigns id to the headings -export function heading({ text, depth }: HeadingProps) { +export function heading({ text, depth }: HeadingProps, registry: Map) { const plainText = stripHtml(text); - const id = getHeadingId(plainText); + const id = getHeadingId(plainText, registry); const safeText = escapeHtml(plainText); return `${safeText}\n`; } diff --git a/docs/docs/architecture.md b/docs/docs/architecture.md index 365d2d9..3238262 100644 --- a/docs/docs/architecture.md +++ b/docs/docs/architecture.md @@ -2,8 +2,6 @@ title: Architecture --- -# Architecture - # Architecture Overview ## 1. System Communication Flow diff --git a/docs/versioned_docs/version-1.0.0/architecture.md b/docs/versioned_docs/version-1.0.0/architecture.md index 365d2d9..3238262 100644 --- a/docs/versioned_docs/version-1.0.0/architecture.md +++ b/docs/versioned_docs/version-1.0.0/architecture.md @@ -2,8 +2,6 @@ title: Architecture --- -# Architecture - # Architecture Overview ## 1. System Communication Flow