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
13 changes: 10 additions & 3 deletions .eleventy.js
Original file line number Diff line number Diff line change
Expand Up @@ -415,11 +415,18 @@ module.exports = function(eleventyConfig) {
}
const vaultFilePath = normalizedInput.slice(rootIndex + notesRoot.length);
const sourceDir = nodePath.posix.dirname(vaultFilePath);
return convertMdHrefs(str, sourceDir === "." ? "" : sourceDir, (vaultPath) => {
const { attributes } = getAnchorAttributes(vaultPath);
return convertMdHrefs(str, sourceDir === "." ? "" : sourceDir, (candidates) => {
let firstAttempt = null;
for (const vaultPath of candidates) {
const { attributes } = getAnchorAttributes(vaultPath);
if (!attributes.class.includes("is-unresolved")) {
return attributes;
}
firstAttempt = firstAttempt || attributes;
}
// Unresolved targets keep getAnchorAttributes' /404 behavior, matching
// how dead wikilinks are handled.
return attributes;
return firstAttempt;
});
});

Expand Down
46 changes: 38 additions & 8 deletions src/helpers/linkUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,46 @@ function resolveVaultPath(linkTarget, sourceDir) {
return resolved;
}

/**
* Possible vault-root-relative interpretations of a markdown link target,
* most likely first. Obsidian resolves targets relative to the note, but
* dataview and Obsidian's "absolute path in vault" link setting emit
* vault-root paths with no ./ or ../ prefix, so those targets get a
* vault-root candidate as fallback.
*/
function vaultPathCandidates(linkTarget, sourceDir) {
const noteRelative = resolveVaultPath(linkTarget, sourceDir);
const candidates = noteRelative ? [noteRelative] : [];
if (
linkTarget &&
!linkTarget.startsWith("/") &&
!linkTarget.startsWith("./") &&
!linkTarget.startsWith("../")
) {
const vaultRoot = resolveVaultPath(linkTarget, "");
if (vaultRoot && !candidates.includes(vaultRoot)) {
candidates.push(vaultRoot);
}
}
return candidates;
}

/**
* Extract markdown-style links to .md files, resolved against the source
* note's vault path and stripped of extension/fragment so they match the
* stem URLs used by the graph.
* stem URLs used by the graph. Ambiguous targets yield every candidate
* interpretation; the graph drops the ones that match no note.
*/
function extractMarkdownLinks(content, sourcePath) {
const links = [];
const sourceDir = path.posix.dirname(sourcePath || "");
let match;
markdownMdLinkRegex.lastIndex = 0;
while ((match = markdownMdLinkRegex.exec(content)) !== null) {
const resolved = resolveVaultPath(match[1], sourceDir === "." ? "" : sourceDir);
if (resolved) {
for (const resolved of vaultPathCandidates(
match[1],
sourceDir === "." ? "" : sourceDir,
)) {
links.push(resolved.replace(/\.(md|markdown)$/i, ""));
}
}
Expand All @@ -68,12 +95,15 @@ function extractMarkdownLinks(content, sourcePath) {

/**
* Rewrite relative .md hrefs in rendered HTML to their resolved permalinks.
* resolveAnchor(vaultPath, fragment, originalHref) returns an attributes
* resolveAnchor(vaultPathCandidates, fragment, originalHref) receives the
* candidate interpretations (most likely first) and returns an attributes
* object ({ href, ... }) or null to leave the anchor untouched.
*/
function convertMdHrefs(html, sourceDir, resolveAnchor) {
// (?:[^>]*?\s)? requires href to start the tag or follow whitespace, so
// attributes like data-href (dataviewjs anchors) are never rewritten.
return html.replace(
/(<a\s[^>]*?href=")([^"]+)("[^>]*>)/gi,
/(<a\s(?:[^>]*?\s)?href=")([^"]+)("[^>]*>)/gi,
(fullMatch, before, href, after) => {
const [target, ...fragmentParts] = href.split("#");
const fragment = fragmentParts.length ? `#${fragmentParts.join("#")}` : "";
Expand All @@ -84,11 +114,11 @@ function convertMdHrefs(html, sourceDir, resolveAnchor) {
) {
return fullMatch;
}
const vaultPath = resolveVaultPath(target, sourceDir);
if (!vaultPath) {
const candidates = vaultPathCandidates(target, sourceDir);
if (!candidates.length) {
return fullMatch;
}
const attrs = resolveAnchor(vaultPath, fragment, href);
const attrs = resolveAnchor(candidates, fragment, href);
if (!attrs || !attrs.href) {
return fullMatch;
}
Expand Down
55 changes: 49 additions & 6 deletions src/helpers/mdLinkResolution.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ describe("extractLinks with markdown-style links", () => {
expect(links).toContain("wiki/concepts/feedback");
});

it("extracts vault-root markdown links from nested notes", () => {
const links = extractLinks(
"[Assessment](wiki/concepts/assessment.md)",
"journal/2026/some-note",
);
expect(links).toContain("wiki/concepts/assessment");
});

it("ignores image embeds and external markdown links", () => {
const links = extractLinks(
"![img](../img/pic.md) [ext](https://example.com/a.md)",
Expand All @@ -77,11 +85,16 @@ describe("extractLinks with markdown-style links", () => {
});

describe("convertMdHrefs", () => {
const resolver = (vaultPath) =>
({
"wiki/concepts/feedback.md": { href: "/wiki/concepts/feedback/" },
"wiki/authors/andrade.md": { href: "/wiki/authors/andrade/" },
})[vaultPath] || null;
const knownPaths = {
"wiki/concepts/feedback.md": { href: "/wiki/concepts/feedback/" },
"wiki/authors/andrade.md": { href: "/wiki/authors/andrade/" },
};
const resolver = (candidates) => {
for (const vaultPath of candidates) {
if (knownPaths[vaultPath]) return knownPaths[vaultPath];
}
return null;
};

it("rewrites a relative .md href to the resolved permalink", () => {
const html =
Expand All @@ -108,9 +121,39 @@ describe("convertMdHrefs", () => {

it("leaves unresolvable .md hrefs to the resolver's discretion", () => {
const html = '<a href="../concepts/missing.md" class="internal-link">m</a>';
const out = convertMdHrefs(html, "wiki/concepts", (p, frag, orig) => null);
const out = convertMdHrefs(html, "wiki/concepts", () => null);
expect(out).toBe(html);
});

it("does not rewrite data-href attributes on dataviewjs anchors", () => {
// DataviewJS output contains Obsidian-rendered anchors where data-href
// (and href) hold a vault-root path. data-href must survive untouched:
// the dataview-js-links transform resolves the anchor from it.
const html =
'<a data-href="wiki/concepts/feedback.md" href="wiki/concepts/feedback.md" class="internal-link" target="_blank" rel="noopener nofollow">Feedback</a>';
const out = convertMdHrefs(html, "wiki/concepts", resolver);
expect(out).toContain('data-href="wiki/concepts/feedback.md"');
expect(out).toContain('href="/wiki/concepts/feedback/"');
});

it("falls back to vault-root resolution for non-relative targets", () => {
// A note nested in wiki/concepts linking to a vault-root path, as
// dataview and Obsidian's "absolute path in vault" setting produce.
const html =
'<a href="wiki/authors/andrade.md" class="internal-link">A</a>';
const out = convertMdHrefs(html, "wiki/concepts", resolver);
expect(out).toContain('href="/wiki/authors/andrade/"');
});

it("prefers the note-relative interpretation when both resolve", () => {
const html = '<a href="feedback.md" class="internal-link">F</a>';
const seen = [];
convertMdHrefs(html, "wiki/concepts", (candidates) => {
seen.push(candidates);
return null;
});
expect(seen).toEqual([["wiki/concepts/feedback.md", "feedback.md"]]);
});
});

describe("getGraph with markdown-style links", () => {
Expand Down
Loading