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
29 changes: 29 additions & 0 deletions .eleventy.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const matterOptions = {
};
const faviconsPlugin = require("eleventy-plugin-gen-favicons");
const normalizeFavicon = require("./src/site/normalize-favicon.js");
const { convertMdHrefs } = require("./src/helpers/linkUtils");
const nodePath = require("path");

const FAVICON_SOURCE = "./src/site/favicon.svg";
const FAVICON_NORMALIZED = "./.cache/favicon.normalized.svg";
Expand Down Expand Up @@ -394,6 +396,33 @@ module.exports = function(eleventyConfig) {
);
});

// Resolve markdown-style relative links to .md files (e.g. [X](../a/b.md))
// to their real permalinks. Obsidian resolves these in-app, but they reach
// the rendered HTML untouched, where trailing-slash page URLs make the
// browser resolve them one directory too deep.
// pageInputPath overrides this.page for contexts like the feed, where the
// rendered content belongs to a looped-over note rather than the current page.
eleventyConfig.addFilter("resolveMdLinks", function(str, pageInputPath) {
const inputPath = pageInputPath || (this.page && this.page.inputPath);
if (!str || !inputPath) {
return str;
}
const notesRoot = "src/site/notes/";
const normalizedInput = inputPath.replace(/^\.\//, "");
const rootIndex = normalizedInput.indexOf(notesRoot);
if (rootIndex === -1) {
return str;
}
const vaultFilePath = normalizedInput.slice(rootIndex + notesRoot.length);
const sourceDir = nodePath.posix.dirname(vaultFilePath);
return convertMdHrefs(str, sourceDir === "." ? "" : sourceDir, (vaultPath) => {
const { attributes } = getAnchorAttributes(vaultPath);
// Unresolved targets keep getAnchorAttributes' /404 behavior, matching
// how dead wikilinks are handled.
return attributes;
});
});

eleventyConfig.addFilter("taggify", function(str) {
return (
str &&
Expand Down
88 changes: 86 additions & 2 deletions src/helpers/linkUtils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
const path = require("path");

const wikiLinkRegex = /\[\[(.*?\|.*?)\]\]/g;
const internalLinkRegex = /href="\/(.*?)"/g;
// Markdown-style links to .md files, e.g. [Feedback](../concepts/feedback.md).
// Negative lookbehind excludes image embeds. Group 1 is the link target.
const markdownMdLinkRegex =
/(?<!\!)\[[^\]]*\]\(\s*([^)\s]+?\.(?:md|markdown))(#[^)\s]*)?\s*\)/gi;
const externalSchemeRegex = /^[a-z][a-z0-9+.-]*:/i;
// Match iframe src for canvas embedded files (internal links only, not external URLs)
// Format: <iframe src="/path/" class="canvas-file-iframe" ...>
// Use non-greedy [^>]*? to avoid over-matching
Expand All @@ -16,7 +23,81 @@ try {
// bases-engine not available, skip bases link extraction
}

function extractLinks(content) {
/**
* Resolve a markdown link target to a vault-root-relative path.
* Returns null for external links or paths escaping the vault root.
* sourceDir is the vault-relative directory of the linking note ("" for root).
*/
function resolveVaultPath(linkTarget, sourceDir) {
if (!linkTarget || externalSchemeRegex.test(linkTarget)) {
return null;
}
let decoded = linkTarget;
try {
decoded = decodeURI(linkTarget);
} catch {
// Keep the raw target if it is not valid percent-encoding
}
const resolved = decoded.startsWith("/")
? path.posix.normalize(decoded.slice(1))
: path.posix.normalize(path.posix.join(sourceDir || "", decoded));
if (resolved.startsWith("../") || resolved === "..") {
return null;
}
return resolved;
}

/**
* 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.
*/
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) {
links.push(resolved.replace(/\.(md|markdown)$/i, ""));
}
}
return links;
}

/**
* Rewrite relative .md hrefs in rendered HTML to their resolved permalinks.
* resolveAnchor(vaultPath, fragment, originalHref) returns an attributes
* object ({ href, ... }) or null to leave the anchor untouched.
*/
function convertMdHrefs(html, sourceDir, resolveAnchor) {
return html.replace(
/(<a\s[^>]*?href=")([^"]+)("[^>]*>)/gi,
(fullMatch, before, href, after) => {
const [target, ...fragmentParts] = href.split("#");
const fragment = fragmentParts.length ? `#${fragmentParts.join("#")}` : "";
if (
!/\.(md|markdown)$/i.test(target) ||
target.startsWith("/") ||
externalSchemeRegex.test(target)
) {
return fullMatch;
}
const vaultPath = resolveVaultPath(target, sourceDir);
if (!vaultPath) {
return fullMatch;
}
const attrs = resolveAnchor(vaultPath, fragment, href);
if (!attrs || !attrs.href) {
return fullMatch;
}
return `${before}${attrs.href}${fragment}${after}`;
},
);
}

function extractLinks(content, sourcePath) {
// Extract iframe sources for canvas embeds
const iframeLinks = [];
let match;
Expand Down Expand Up @@ -50,6 +131,7 @@ function extractLinks(content) {
.split("#")[0]
),
...iframeLinks,
...(sourcePath ? extractMarkdownLinks(content, sourcePath) : []),
];
}

Expand Down Expand Up @@ -127,7 +209,7 @@ async function getGraph(data) {
v.data["dg-home"] ||
(v.data.tags && v.data.tags.indexOf("gardenEntry") > -1) ||
false,
outBound: extractLinks(content),
outBound: extractLinks(content, fpath),
neighbors: new Set(),
backLinks: new Set(),
noteIcon: v.data.noteIcon || process.env.NOTE_ICON_DEFAULT,
Expand Down Expand Up @@ -222,5 +304,7 @@ async function getGraph(data) {
exports.wikiLinkRegex = wikiLinkRegex;
exports.internalLinkRegex = internalLinkRegex;
exports.extractLinks = extractLinks;
exports.resolveVaultPath = resolveVaultPath;
exports.convertMdHrefs = convertMdHrefs;
exports.getGraph = getGraph;
exports._basesNotesWithLinks = null;
142 changes: 142 additions & 0 deletions src/helpers/mdLinkResolution.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, it, expect } from "vitest";
import {
resolveVaultPath,
extractLinks,
convertMdHrefs,
getGraph,
} from "./linkUtils.js";

describe("resolveVaultPath", () => {
it("resolves a sibling-relative link with ../", () => {
expect(resolveVaultPath("../authors/andrade.md", "wiki/concepts")).toBe(
"wiki/authors/andrade.md",
);
});

it("resolves a link relative to the vault root note", () => {
expect(resolveVaultPath("wiki/concepts/assessment.md", "")).toBe(
"wiki/concepts/assessment.md",
);
});

it("treats a leading slash as vault-root", () => {
expect(resolveVaultPath("/wiki/concepts/assessment.md", "wiki")).toBe(
"wiki/concepts/assessment.md",
);
});

it("decodes URL-encoded characters", () => {
expect(resolveVaultPath("My%20Note.md", "")).toBe("My Note.md");
});

it("returns null for external links", () => {
expect(resolveVaultPath("https://example.com/x.md", "wiki")).toBe(null);
expect(resolveVaultPath("mailto:a@b.com", "wiki")).toBe(null);
});

it("returns null for links escaping the vault root", () => {
expect(resolveVaultPath("../../../etc/passwd.md", "wiki")).toBe(null);
});
});

describe("extractLinks with markdown-style links", () => {
it("extracts relative markdown links resolved against the source path", () => {
const content =
"See [Andrade](../authors/andrade.md) and [Feedback](../concepts/feedback.md).";
const links = extractLinks(content, "wiki/concepts/high-impact-practices");
expect(links).toContain("wiki/authors/andrade");
expect(links).toContain("wiki/concepts/feedback");
});

it("extracts root-note markdown links", () => {
const links = extractLinks(
"[Assessment](wiki/concepts/assessment.md)",
"index",
);
expect(links).toContain("wiki/concepts/assessment");
});

it("strips fragments from markdown links", () => {
const links = extractLinks("[X](../concepts/feedback.md#model)", "wiki/a/b");
expect(links).toContain("wiki/concepts/feedback");
});

it("ignores image embeds and external markdown links", () => {
const links = extractLinks(
"![img](../img/pic.md) [ext](https://example.com/a.md)",
"wiki/a/b",
);
expect(links).not.toContain("wiki/img/pic");
expect(links.some((l) => l.includes("example.com"))).toBe(false);
});

it("still extracts wikilinks without a source path (backwards compat)", () => {
const links = extractLinks("[[wiki/concepts/feedback|Feedback]]");
expect(links).toContain("wiki/concepts/feedback");
});
});

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

it("rewrites a relative .md href to the resolved permalink", () => {
const html =
'<p><a href="../concepts/feedback.md" class="internal-link">Feedback</a></p>';
const out = convertMdHrefs(html, "wiki/concepts", resolver);
expect(out).toContain('href="/wiki/concepts/feedback/"');
expect(out).not.toContain(".md");
expect(out).toContain(">Feedback</a>");
});

it("preserves fragments on rewritten hrefs", () => {
const html = '<a class="internal-link" href="../authors/andrade.md#bio">A</a>';
const out = convertMdHrefs(html, "wiki/concepts", resolver);
expect(out).toContain('href="/wiki/authors/andrade/#bio"');
});

it("leaves external, absolute and non-md hrefs untouched", () => {
const html =
'<a href="https://example.com/a.md">x</a>' +
'<a href="/already/resolved/">y</a>' +
'<a href="../img/photo.png">z</a>';
expect(convertMdHrefs(html, "wiki/concepts", resolver)).toBe(html);
});

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);
expect(out).toBe(html);
});
});

describe("getGraph with markdown-style links", () => {
function makeNote(slug, content, data = {}) {
return {
filePathStem: `/notes/${slug}`,
url: `/${slug}/`,
fileSlug: slug.split("/").pop(),
data: { title: slug, ...data },
template: { read: async () => ({ content }) },
};
}

it("creates graph edges from relative markdown links", async () => {
const a = makeNote(
"wiki/concepts/high-impact-practices",
"[Andrade](../authors/andrade.md)",
);
const b = makeNote("wiki/authors/andrade", "nothing here");
const graph = await getGraph({ collections: { note: [a, b] } });
expect(graph.links.length).toBe(1);
expect(
graph.nodes["/wiki/concepts/high-impact-practices/"].outBound,
).toContain("/wiki/authors/andrade/");
expect(graph.nodes["/wiki/authors/andrade/"].backLinks).toContain(
"/wiki/concepts/high-impact-practices/",
);
});
});
2 changes: 1 addition & 1 deletion src/site/_includes/layouts/index.njk
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
{% for imp in dynamics.index.beforeContent %}
{% include imp %}
{% endfor %}
{{ content | hideDataview | taggify | link | safe}}
{{ content | hideDataview | taggify | link | resolveMdLinks | safe}}
{% for imp in dynamics.common.afterContent %}
{% include imp %}
{% endfor %}
Expand Down
2 changes: 1 addition & 1 deletion src/site/_includes/layouts/note.njk
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ permalink: "notes/{{ page.fileSlug | slugify }}/"
{% for imp in dynamics.notes.beforeContent %}
{% include imp %}
{% endfor %}
{{ content | hideDataview | taggify | link | safe}}
{{ content | hideDataview | taggify | link | resolveMdLinks | safe}}
{% for imp in dynamics.common.afterContent %}
{% include imp %}
{% endfor %}
Expand Down
2 changes: 1 addition & 1 deletion src/site/feed.njk
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<title>{%- if note.title -%}{{ note.title }}{%- else -%}{{ note.fileSlug }}{%- endif -%}</title>
<updated>{%- if note.data.updated -%}{{ note.data.updated | dateToZulu }}{%- else -%}{{ note.date | dateToRfc3339 }}{%- endif -%}</updated>
<id>{{ meta.siteBaseUrl }}{{ note.url | url }}</id>
<content type="html">{{- note.templateContent | hideDataview | taggify | link | htmlToAbsoluteUrls(meta.siteBaseUrl) | xmlSafe -}}</content>
<content type="html">{{- note.templateContent | hideDataview | taggify | link | resolveMdLinks(note.inputPath) | htmlToAbsoluteUrls(meta.siteBaseUrl) | xmlSafe -}}</content>
<link href="{{ meta.siteBaseUrl }}{{ note.url | url }}"/>
</entry>
{%- endif -%}
Expand Down
Loading