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
2 changes: 1 addition & 1 deletion .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 22 additions & 4 deletions apps/main-processor/src/export/exportPdf.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) => {
Expand All @@ -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);
}
}
}
2 changes: 1 addition & 1 deletion apps/main-processor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/main-processor/src/ipc.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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');
Expand Down
3 changes: 2 additions & 1 deletion apps/main-processor/src/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -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),
})),
},
Expand Down
4 changes: 2 additions & 2 deletions apps/main-processor/src/register-menu.ts
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 1 addition & 1 deletion apps/main-processor/src/settings/get-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export async function getSettings(): Promise<AppSettings> {
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;
Expand Down
4 changes: 4 additions & 0 deletions apps/main-processor/src/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment on lines +9 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether other modules also register autoUpdater listeners
# (if yes, removeAllListeners here is unsafe and can remove them).
rg -n -C3 --type=ts '\bautoUpdater\.(on|once)\s*\('

Repository: mindfiredigital/markdown-reader

Length of output: 1112


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the current updater.ts context around the removeAllListeners calls
sed -n '1,120p' apps/main-processor/src/updater.ts | nl -ba | sed -n '1,120p'

# Find all uses of autoUpdater across the repo
rg -n --type=ts '\bautoUpdater\.' apps/main-processor/src || true
rg -n --type=ts '\bautoUpdater\.' . || true

# Find any other listener registrations for these event names
rg -n --type=ts "autoUpdater\.(on|once)\('update-(available|downloaded)'" . || true
rg -n --type=ts "autoUpdater\.(on|once)\('error'" . || true

# Find any removeAllListeners/off usage on autoUpdater
rg -n --type=ts '\bautoUpdater\.removeAllListeners\s*\(' . || true
rg -n --type=ts '\bautoUpdater\.(off|removeListener|removeListeners)\b' . || true

Repository: mindfiredigital/markdown-reader

Length of output: 118


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the current updater.ts context around the removeAllListeners calls
sed -n '1,120p' apps/main-processor/src/updater.ts | nl -ba | sed -n '1,120p'

# Find all uses of autoUpdater across the repo
rg -n --type=ts '\bautoUpdater\.' apps/main-processor/src || true
rg -n --type=ts '\bautoUpdater\.' . || true

# Find any other listener registrations for these event names
rg -n --type=ts "autoUpdater\.(on|once)\('update-(available|downloaded)'" . || true
rg -n --type=ts "autoUpdater\.(on|once)\('error'" . || true

# Find any removeAllListeners/off usage on autoUpdater
rg -n --type=ts '\bautoUpdater\.removeAllListeners\s*\(' . || true
rg -n --type=ts '\bautoUpdater\.(off|removeListener|removeListeners)\b' . || true

Repository: mindfiredigital/markdown-reader

Length of output: 118


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show updater.ts with line numbers (nl isn't available)
cat -n apps/main-processor/src/updater.ts | sed -n '1,140p'

# Find all uses of autoUpdater across the repo
rg -n --type=ts '\bautoUpdater\.' apps/main-processor/src || true
rg -n --type=ts '\bautoUpdater\.' . || true

# Find any other listener registrations for these event names
rg -n --type=ts "autoUpdater\.(on|once)\('update-(available|downloaded)'" . || true
rg -n --type=ts "autoUpdater\.(on|once)\('error'" . || true

# Find any removeAllListeners/off usage on autoUpdater
rg -n --type=ts '\bautoUpdater\.removeAllListeners\s*\(' . || true
rg -n --type=ts '\bautoUpdater\.(off|removeListener|removeListeners)\b' . || true

Repository: mindfiredigital/markdown-reader

Length of output: 3377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Where setupAutoUpdater is called
rg -n --type=ts "setupAutoUpdater\(" .

# Any autoUpdater listener registrations (broader patterns) elsewhere
rg -n --type=ts "\bautoUpdater\.(on|once)\s*\(" . || true

# Any autoUpdater listener cleanup elsewhere
rg -n --type=ts "\bautoUpdater\.(off|removeListener|removeAllListeners)\s*\(" . || true

Repository: mindfiredigital/markdown-reader

Length of output: 792


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show relevant portion of index.ts around setupAutoUpdater call
cat -n apps/main-processor/src/index.ts | sed -n '1,140p'

# Check if main window creation is potentially re-invoked (search for createMainWindow or BrowserWindow instantiation)
rg -n --type=ts "new BrowserWindow|createMainWindow|mainWindow|setupAutoUpdater\(" apps/main-processor/src || true

Repository: mindfiredigital/markdown-reader

Length of output: 5615


Avoid autoUpdater.removeAllListeners(...) clobbering other handlers (apps/main-processor/src/updater.ts lines 9-11): those calls remove all listeners for the given events; even though this repo currently only registers these handlers in setupAutoUpdater, a second feature (or another createWindow() run) could silently wipe it. Keep module-scoped handler references and use autoUpdater.off('update-available', handler) / off('update-downloaded', handler) / off('error', handler) (or removeListener) before re-registering.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/main-processor/src/updater.ts` around lines 9 - 11, Currently the code
calls
autoUpdater.removeAllListeners('update-available'|'update-downloaded'|'error'),
which can clobber other feature listeners; instead declare module-scoped handler
functions (e.g. onUpdateAvailable, onUpdateDownloaded, onUpdateError) and use
autoUpdater.off('update-available', onUpdateAvailable) /
off('update-downloaded', onUpdateDownloaded) / off('error', onUpdateError) (or
removeListener) before re-registering them in setupAutoUpdater so only these
specific handlers are removed and re-bound (refer to autoUpdater and
setupAutoUpdater in updater.ts).


autoUpdater.on('update-available', (info: UpdateInfo) => {
window.webContents.send(IPC_CONSTANTS.UPDATE_AVAILABLE, info.version);
});
Expand Down
11 changes: 6 additions & 5 deletions apps/main-processor/src/utils/constants/ipc-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\\`,
Comment on lines +42 to +48
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Block protected Windows folders on all volumes, not only %SystemDrive%.

On Line 42 and Lines 44-48, forbidden prefixes are derived only from %SystemDrive%. Volume-scoped protected directories (notably \$Recycle.Bin and \System Volume Information) on other drives can still pass validatePath (e.g., D:\$Recycle.Bin\...), and those paths flow into export/directory IPC checks.

Suggested fix
-      const sysDrive = (process.env.SystemDrive ?? 'C:').toLowerCase();
+      const sysDrive = (process.env.SystemDrive ?? 'C:').toLowerCase();
+      const driveMatch = lower.match(/^([a-z]:)\\/);
+      if (!driveMatch) return false;
+      const inputDrive = driveMatch[1];
       const forbiddenPrefixes = [
         `${sysDrive}\\windows\\`,
         `${sysDrive}\\winnt\\`,
         `${sysDrive}\\boot\\`,
-        `${sysDrive}\\system volume information\\`,
-        `${sysDrive}\\$recycle.bin\\`,
+        `${inputDrive}\\system volume information\\`,
+        `${inputDrive}\\$recycle.bin\\`,
       ];

As per coding guidelines: "File/folder access must guard path traversal, missing files, permissions, symlinks, and deleted watched files."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/main-processor/src/utils/constants/ipc-validation.ts` around lines 42 -
48, The current forbiddenPrefixes array in ipc-validation.ts uses sysDrive and
only blocks protected folders on the system drive; update the logic so these
protected folder checks are drive-agnostic: change forbiddenPrefixes to use
drive-independent names (e.g., "\\windows\\", "\\winnt\\", "\\boot\\", "\\system
volume information\\", "\\$recycle.bin\\") and update the validatePath check to
normalize the input with path.win32.normalize().toLowerCase() and compare using
either a regex that allows any drive letter prefix
(/^[a-z]:\\(windows|winnt|boot|system volume information|\\$recycle\\.bin)\\/)
or by stripping path.parse(p).root and checking the remainder startsWith these
folder names with a trailing backslash; reference sysDrive, forbiddenPrefixes
and validatePath in ipc-validation.ts when making the change.

];
if (forbiddenPrefixes.some((p) => lower === p.slice(0, -1) || lower.startsWith(p))) {
return false;
Expand Down
2 changes: 1 addition & 1 deletion apps/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden CSP further with explicit deny directives.

This policy still omits object-src, base-uri, frame-ancestors, and form-action, which leaves unnecessary surface open. Add explicit deny rules for defense-in-depth.

Suggested CSP tightening
-      content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:"
+      content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/renderer/index.html` at line 12, Update the Content-Security-Policy meta
string in apps/renderer/index.html to explicitly deny unused sinks: add
directives object-src 'none', base-uri 'none', frame-ancestors 'none', and
form-action 'none' to the existing policy (the meta tag whose content currently
starts with "default-src 'self'; script-src 'self'; ..."). Keep existing
directives (style-src, img-src, font-src) and preserve any 'unsafe-inline' or
data allowances intentionally used; simply append the new deny directives
separated by semicolons to harden the policy.

/>
</head>

Expand Down
3 changes: 1 addition & 2 deletions apps/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -198,7 +197,7 @@ useShortcuts({
)}
{!focusMode && (
<Sidebar
tocItems={activeTab.toc??extractTOC(activeTab.html)}
tocItems={activeToc}
activeId={activeId}
onSelect={scrollToHeading}
isVisible={sidebarOpen}
Expand Down
13 changes: 7 additions & 6 deletions apps/renderer/src/config/marked.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@ import { THEMES } from '@package/shared-constants';
import { escapeHtml, heading } from '../utils/helpers/heading-helper';
import { MARKDOWN_LANGUAGES } from '../utils/constants/markdown-constants';

let instance: Marked | null = null;

export function getMarkdown(): Marked {
if (instance) return instance;
instance = new Marked();
export function getMarkdown(registry: Map<string, number>): 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
Expand Down
1 change: 1 addition & 0 deletions apps/renderer/src/hooks/useFileActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
30 changes: 24 additions & 6 deletions apps/renderer/src/hooks/useFilePersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,35 @@ export function useFilePersistence({
contentRef,
setShowToast,
}: FilePersistenceProps) {
const debounceTimer = useRef<number | undefined>(undefined);
const scrollTimer = useRef<number | undefined>(undefined);
const debounceTimer = useRef<ReturnType<typeof window.setTimeout> | undefined>(undefined);
const scrollTimer = useRef<ReturnType<typeof window.setTimeout> | undefined>(undefined);
const isMounted = useRef<boolean>(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: {
Expand All @@ -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({
Expand All @@ -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 };
}
5 changes: 4 additions & 1 deletion apps/renderer/src/renderer/markdown.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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');
Expand Down
3 changes: 3 additions & 0 deletions apps/renderer/src/renderer/shiki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export async function shikiHighlighter(): Promise<HighlighterCore> {
themes,
langs,
engine: createJavaScriptRegexEngine(),
}).catch((err) => {
highlighter = null;
throw err;
});
}
return highlighter;
Expand Down
16 changes: 8 additions & 8 deletions apps/renderer/src/renderer/toc.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();
const registry = createHeadingRegistry();
const tokens = lexer(html);

for (const token of tokens) {
Expand All @@ -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;
Expand Down
15 changes: 11 additions & 4 deletions apps/renderer/src/utils/helpers/heading-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> {
return new Map<string, number>();
}

// converts heading text into a ID
export function getHeadingId(text: string): string {
export function getHeadingId(text: string, registry: Map<string, number>): 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<string, number>) {
const plainText = stripHtml(text);
const id = getHeadingId(plainText);
const id = getHeadingId(plainText, registry);
const safeText = escapeHtml(plainText);
return `<h${depth} id="${id}">${safeText}</h${depth}>\n`;
}
Expand Down
2 changes: 0 additions & 2 deletions docs/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
title: Architecture
---

# Architecture

# Architecture Overview

## 1. System Communication Flow
Expand Down
2 changes: 0 additions & 2 deletions docs/versioned_docs/version-1.0.0/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
title: Architecture
---

# Architecture

# Architecture Overview

## 1. System Communication Flow
Expand Down
Loading