diff --git a/.eleventy.js b/.eleventy.js index 9a468699ea..fd7077b2ca 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -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"; @@ -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 && diff --git a/src/helpers/linkUtils.js b/src/helpers/linkUtils.js index 0a827f39c2..019739f0be 100644 --- a/src/helpers/linkUtils.js +++ b/src/helpers/linkUtils.js @@ -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 = + /(? // Use non-greedy [^>]*? to avoid over-matching @@ -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( + /(]*?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; @@ -50,6 +131,7 @@ function extractLinks(content) { .split("#")[0] ), ...iframeLinks, + ...(sourcePath ? extractMarkdownLinks(content, sourcePath) : []), ]; } @@ -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, @@ -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; diff --git a/src/helpers/mdLinkResolution.test.js b/src/helpers/mdLinkResolution.test.js new file mode 100644 index 0000000000..6dde1155c0 --- /dev/null +++ b/src/helpers/mdLinkResolution.test.js @@ -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 = + '

Feedback

'; + const out = convertMdHrefs(html, "wiki/concepts", resolver); + expect(out).toContain('href="/wiki/concepts/feedback/"'); + expect(out).not.toContain(".md"); + expect(out).toContain(">Feedback"); + }); + + it("preserves fragments on rewritten hrefs", () => { + const html = '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 = + 'x' + + 'y' + + 'z'; + expect(convertMdHrefs(html, "wiki/concepts", resolver)).toBe(html); + }); + + it("leaves unresolvable .md hrefs to the resolver's discretion", () => { + const html = 'm'; + 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/", + ); + }); +}); diff --git a/src/site/_includes/layouts/index.njk b/src/site/_includes/layouts/index.njk index 597109311d..8b505ec2fe 100644 --- a/src/site/_includes/layouts/index.njk +++ b/src/site/_includes/layouts/index.njk @@ -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 %} diff --git a/src/site/_includes/layouts/note.njk b/src/site/_includes/layouts/note.njk index 1feeab01e6..da8d7b85aa 100644 --- a/src/site/_includes/layouts/note.njk +++ b/src/site/_includes/layouts/note.njk @@ -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 %} diff --git a/src/site/feed.njk b/src/site/feed.njk index 54fbe3d2e9..e0b095c9cf 100644 --- a/src/site/feed.njk +++ b/src/site/feed.njk @@ -18,7 +18,7 @@ {%- if note.title -%}{{ note.title }}{%- else -%}{{ note.fileSlug }}{%- endif -%} {%- if note.data.updated -%}{{ note.data.updated | dateToZulu }}{%- else -%}{{ note.date | dateToRfc3339 }}{%- endif -%} {{ meta.siteBaseUrl }}{{ note.url | url }} - {{- note.templateContent | hideDataview | taggify | link | htmlToAbsoluteUrls(meta.siteBaseUrl) | xmlSafe -}} + {{- note.templateContent | hideDataview | taggify | link | resolveMdLinks(note.inputPath) | htmlToAbsoluteUrls(meta.siteBaseUrl) | xmlSafe -}} {%- endif -%}