Skip to content
Open
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
7 changes: 0 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,6 @@
"email": "ptheofan@gmail.com"
},
"license": "GPL-3.0",
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"macos-alias",
"fs-xattr"
]
},
"devDependencies": {
"@electron-forge/cli": "^7.11.1",
"@electron-forge/maker-dmg": "^7.11.1",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
allowBuilds:
electron: true
esbuild: true
fs-xattr: true
macos-alias: true
nodeLinker: hoisted
executionEnv:
nodeVersion: 22.17.0
43 changes: 43 additions & 0 deletions src/plugins/builtin/GithubFlavoredPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,49 @@ export class GithubFlavoredPlugin implements MarkdownPlugin {

// Add task list support
this.addTaskLists(md);

// Add GitHub-style ids to headings so in-document anchors work
this.addHeadingAnchors(md);
}

/**
* Assign GitHub-style slug ids to headings (e.g. "TextField API" ->
* "textfield-api") so that in-document links like [foo](#textfield-api)
* resolve. Duplicate slugs get a numeric suffix, matching GitHub.
*/
private addHeadingAnchors(md: MarkdownIt): void {
md.core.ruler.push('heading_anchors', (state) => {
const usedSlugs = new Map<string, number>();
const tokens = state.tokens;

for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (!token || token.type !== 'heading_open') continue;

const inline = tokens[i + 1];
if (!inline || inline.type !== 'inline') continue;

const text = (inline.children ?? [])
.filter((t) => t.type === 'text' || t.type === 'code_inline')
.map((t) => t.content)
.join('');

let slug = text
.trim()
.toLowerCase()
.replace(/[^\w\- ]+/g, '')
.replace(/\s+/g, '-');
if (!slug) continue;

const count = usedSlugs.get(slug) ?? 0;
usedSlugs.set(slug, count + 1);
if (count > 0) {
slug = `${slug}-${count}`;
}

token.attrSet('id', slug);
}
});
}

/**
Expand Down
31 changes: 24 additions & 7 deletions src/preload/assetResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ import { pathToFileURL } from 'node:url';
import { ASSET_PROTOCOL_SCHEME, ASSET_PROTOCOL_HOST } from '@shared/constants';

/**
* Resolve an image reference against the markdown file that contains it.
* Resolve a document reference to an absolute filesystem path.
*
* @param baseFilePath - Absolute path to the markdown file being rendered.
* @param ref - The raw `src`/`srcset` reference from the document.
* @returns An `om-asset:` URL for local files, or `null` when the reference
* should be left untouched (already a URL, protocol-relative, or empty).
* @param ref - The raw reference from the document (e.g. `./other.md`,
* `images/logo.svg`, `/abs/path/file.md`).
* @returns The absolute path on disk, or `null` when the reference is not a
* local file (already a URL, protocol-relative, in-document anchor, or
* empty). Query strings and fragments are stripped.
*/
export function resolveAssetUrl(baseFilePath: string, ref: string): string | null {
export function resolveLocalPath(baseFilePath: string, ref: string): string | null {
if (!baseFilePath || !ref) {
return null;
}
Expand All @@ -35,7 +37,7 @@ export function resolveAssetUrl(baseFilePath: string, ref: string): string | nul
return null;
}

// Pure in-document anchors are not assets.
// Pure in-document anchors are not local files.
if (trimmed.startsWith('#')) {
return null;
}
Expand All @@ -53,9 +55,24 @@ export function resolveAssetUrl(baseFilePath: string, ref: string): string | nul
decoded = cleaned;
}

const resolved = path.isAbsolute(decoded)
return path.isAbsolute(decoded)
? decoded
: path.resolve(path.dirname(baseFilePath), decoded);
}

/**
* Resolve an image reference against the markdown file that contains it.
*
* @param baseFilePath - Absolute path to the markdown file being rendered.
* @param ref - The raw `src`/`srcset` reference from the document.
* @returns An `om-asset:` URL for local files, or `null` when the reference
* should be left untouched (already a URL, protocol-relative, or empty).
*/
export function resolveAssetUrl(baseFilePath: string, ref: string): string | null {
const resolved = resolveLocalPath(baseFilePath, ref);
if (!resolved) {
return null;
}

// Embed the (already percent-encoded) file path under a fixed host. A bare
// `om-asset:///path` URL is mangled by Chromium's standard-scheme parser,
Expand Down
6 changes: 5 additions & 1 deletion src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron';

import { IPC_CHANNELS } from '@shared/types/api';
import { resolveAssetUrl } from './assetResolver';
import { resolveAssetUrl, resolveLocalPath } from './assetResolver';

import type {
ElectronAPI,
Expand Down Expand Up @@ -339,6 +339,10 @@ const electronAPI: ElectronAPI = {
resolve: (baseFilePath: string, ref: string): string | null => {
return resolveAssetUrl(baseFilePath, ref);
},

resolvePath: (baseFilePath: string, ref: string): string | null => {
return resolveLocalPath(baseFilePath, ref);
},
},

};
Expand Down
8 changes: 8 additions & 0 deletions src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,14 @@ class App {
},
});

this.markdownViewer.setOnOpenLocalFile((filePath, fragment) => {
void this.loadFile(filePath).then(() => {
if (fragment) {
this.markdownViewer?.scrollToHeading(fragment);
}
});
});

this.dropZone.setOnFileDrop((filePath) => {
void this.handleFileDrop(filePath);
});
Expand Down
55 changes: 52 additions & 3 deletions src/renderer/components/MarkdownViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
createMermaidPlugin,
MermaidPlugin,
} from '@plugins/index';
import { BUILTIN_PLUGINS } from '@shared/constants';
import { BUILTIN_PLUGINS, MARKDOWN_EXTENSIONS } from '@shared/constants';
import { Toast } from './Toast';

import { EditModeController, createEditModeController } from './EditModeController';
Expand Down Expand Up @@ -49,6 +49,9 @@ export class MarkdownViewer {
private toast: Toast;
private editModeController: EditModeController | null = null;
private isEditMode = false;
private onOpenLocalFile:
| ((filePath: string, fragment: string | null) => void)
| null = null;

constructor(container: HTMLElement) {
this.container = container;
Expand Down Expand Up @@ -338,13 +341,59 @@ export class MarkdownViewer {
if (!(anchor instanceof HTMLAnchorElement)) return;

const href = anchor.getAttribute('href');
if (!href || !this.isExternalUrl(href)) return;
if (!href) return;

// In-document anchor links: scroll to the target heading
if (href.startsWith('#')) {
e.preventDefault();
this.scrollToHeading(decodeURIComponent(href.slice(1)));
return;
}

if (this.isExternalUrl(href)) {
e.preventDefault();
void window.electronAPI.shell.openExternal(href);
return;
}

// Anything else is a local reference. Default navigation would replace
// the whole app page (blanking it back to the welcome screen), so it is
// always prevented; markdown files are opened in the viewer instead.
e.preventDefault();
void window.electronAPI.shell.openExternal(href);
this.openLocalLink(href);
});
}

/**
* Open a link to a local markdown file in the viewer, resolving relative
* references against the current document's location
*/
private openLocalLink(href: string): void {
const basePath = this.state.filePath;
if (!basePath || !this.onOpenLocalFile) return;

const resolved = window.electronAPI.assets.resolvePath(basePath, href);
if (!resolved) return;

const ext = resolved.slice(resolved.lastIndexOf('.')).toLowerCase();
if (!(MARKDOWN_EXTENSIONS as readonly string[]).includes(ext)) return;

const hashIndex = href.indexOf('#');
const fragment =
hashIndex >= 0 ? decodeURIComponent(href.slice(hashIndex + 1)) : null;

this.onOpenLocalFile(resolved, fragment);
}

/**
* Set the callback invoked when a link to a local markdown file is clicked
*/
setOnOpenLocalFile(
callback: (filePath: string, fragment: string | null) => void
): void {
this.onOpenLocalFile = callback;
}

/**
* Determine whether a link points to the internet (vs. an in-document anchor)
*/
Expand Down
7 changes: 7 additions & 0 deletions src/shared/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,13 @@ export interface AssetsAPI {
* otherwise should be left untouched.
*/
resolve: (baseFilePath: string, ref: string) => string | null;

/**
* Resolve a document reference (e.g. a link to another markdown file) to an
* absolute filesystem path. Returns `null` when the reference is not a
* local file (already a URL or an in-document anchor).
*/
resolvePath: (baseFilePath: string, ref: string) => string | null;
}

/**
Expand Down