diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 0a3bc55..86923c0 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,9 +1,9 @@ import MarkdownIt from "markdown-it"; import { type KatexPluginEnv, katexPlugin } from "./markdown/katex-plugin.ts"; import { - type WikilinkTarget, - type WikilinkEnv, parseWikilinkTarget, + type WikilinkEnv, + type WikilinkTarget, wikilinkPlugin, } from "./markdown/wikilink-plugin.ts"; import { type VizPluginEnv, vizPlugin } from "./viz/plugin.ts"; @@ -88,11 +88,12 @@ export function extractWikilinks( content.slice(pos + 2, closeIdx), currentWikiSlug, ); - if (target) { - const key = `${target.wikiSlug ?? ""}:${target.noteSlug}`; + if (target && (target.noteSlug || target.wikiSlug)) { + const noteSlug = target.noteSlug || "home"; + const key = `${target.wikiSlug ?? ""}:${noteSlug}`; if (!seen.has(key)) { seen.add(key); - links.push(target); + links.push({ ...target, noteSlug }); } } pos = closeIdx + 2; diff --git a/src/lib/markdown/wikilink-plugin.ts b/src/lib/markdown/wikilink-plugin.ts index 85dcf4d..7f0e6ff 100644 --- a/src/lib/markdown/wikilink-plugin.ts +++ b/src/lib/markdown/wikilink-plugin.ts @@ -1,7 +1,7 @@ import type MarkdownIt from "markdown-it"; import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs"; import { escapeHtml } from "../html.ts"; -import { noteUrl } from "../urls.ts"; +import { noteUrl, wikiUrl } from "../urls.ts"; export interface WikilinkEnv { wikiSlug?: string; @@ -11,6 +11,7 @@ export interface WikilinkTarget { wikiSlug?: string; noteSlug: string; label: string; + section?: string; } export function parseWikilinkTarget( @@ -21,19 +22,37 @@ export function parseWikilinkTarget( if (!trimmed) return null; const pipeIdx = trimmed.indexOf("|"); - const rawTarget = (pipeIdx >= 0 ? trimmed.slice(0, pipeIdx) : trimmed).trim(); - const rawLabel = (pipeIdx >= 0 ? trimmed.slice(pipeIdx + 1) : trimmed).trim(); + const beforePipe = ( + pipeIdx >= 0 ? trimmed.slice(0, pipeIdx) : trimmed + ).trim(); + const rawLabel = (pipeIdx >= 0 ? trimmed.slice(pipeIdx + 1) : "").trim(); + + // Split path from section fragment + const hashIdx = beforePipe.indexOf("#"); + const rawTarget = ( + hashIdx >= 0 ? beforePipe.slice(0, hashIdx) : beforePipe + ).trim(); + const section = + hashIdx >= 0 ? beforePipe.slice(hashIdx + 1).trim() : undefined; + + // Anchor-only link [[#section]] + if (!rawTarget && section) { + return { noteSlug: "", label: rawLabel || section, section }; + } + if (!rawTarget) return null; + // Cross-wiki detection: wiki/note or wiki/ const separatorIdx = rawTarget.indexOf("/"); - if (separatorIdx > 0 && separatorIdx < rawTarget.length - 1) { + if (separatorIdx > 0) { const wikiSlug = rawTarget.slice(0, separatorIdx).trim(); const noteSlug = rawTarget.slice(separatorIdx + 1).trim(); - if (wikiSlug && noteSlug) { + if (wikiSlug) { return { wikiSlug, noteSlug, - label: rawLabel || `${wikiSlug}/${noteSlug}`, + label: rawLabel || (noteSlug ? `${wikiSlug}/${noteSlug}` : wikiSlug), + ...(section ? { section } : {}), }; } } @@ -42,6 +61,7 @@ export function parseWikilinkTarget( ...(currentWikiSlug ? { wikiSlug: currentWikiSlug } : {}), noteSlug: rawTarget, label: rawLabel || rawTarget, + ...(section ? { section } : {}), }; } @@ -57,12 +77,29 @@ export function wikilinkPlugin(mdi: MarkdownIt): void { if (!silent) { const env = state.env as WikilinkEnv; - const target = parseWikilinkTarget(src.slice(pos + 2, closeIdx), env.wikiSlug); + const target = parseWikilinkTarget( + src.slice(pos + 2, closeIdx), + env.wikiSlug, + ); if (!target) return false; - const href = target.wikiSlug - ? noteUrl(target.wikiSlug, target.noteSlug) - : target.noteSlug; + let href: string; + if (!target.wikiSlug && !target.noteSlug && target.section) { + // [[#section]] — anchor only + href = `#${target.section}`; + } else if (!target.noteSlug && target.wikiSlug) { + // [[wiki/]] — wiki home page + href = wikiUrl(target.wikiSlug); + if (target.section) href += `#${target.section}`; + } else if (target.wikiSlug) { + // [[wiki/note]] or [[wiki/note#section]] + href = noteUrl(target.wikiSlug, target.noteSlug); + if (target.section) href += `#${target.section}`; + } else { + // [[note]] or [[note#section]] (bare slug, no current wiki) + href = target.noteSlug; + if (target.section) href += `#${target.section}`; + } const token = state.push("html_inline", "", 0); token.content = `${escapeHtml(target.label)}`; diff --git a/tests/lib/markdown.test.ts b/tests/lib/markdown.test.ts index 19e73d1..44507ed 100644 --- a/tests/lib/markdown.test.ts +++ b/tests/lib/markdown.test.ts @@ -79,6 +79,36 @@ describe("wikilinks", () => { expect(html).toContain("Other Page"); }); + test("renders same-wiki link with section anchor", () => { + const { html } = renderMarkdown("see [[my-page#introduction]]", WIKI); + expect(html).toContain('href="/wiki/test-wiki/my-page#introduction"'); + expect(html).toContain(">my-page<"); + }); + + test("renders cross-wiki link with section anchor", () => { + const { html } = renderMarkdown("see [[other-wiki/my-page#details]]", WIKI); + expect(html).toContain('href="/wiki/other-wiki/my-page#details"'); + expect(html).toContain(">other-wiki/my-page<"); + }); + + test("renders wiki home page link", () => { + const { html } = renderMarkdown("see [[other-wiki/]]", WIKI); + expect(html).toContain('href="/wiki/other-wiki"'); + expect(html).toContain(">other-wiki<"); + }); + + test("renders bare anchor on current page", () => { + const { html } = renderMarkdown("jump to [[#section]]", WIKI); + expect(html).toContain('href="#section"'); + expect(html).toContain(">section<"); + }); + + test("section links with custom labels", () => { + const { html } = renderMarkdown("[[my-page#intro|Introduction]]", WIKI); + expect(html).toContain('href="/wiki/test-wiki/my-page#intro"'); + expect(html).toContain("Introduction"); + }); + test("handles multiple wikilinks in one line", () => { const { html } = renderMarkdown("[[page-a]] and [[page-b]]", WIKI); expect(html).toContain('href="/wiki/test-wiki/page-a"'); @@ -188,4 +218,36 @@ describe("extractWikilinks", () => { { noteSlug: "b", label: "b" }, ]); }); + + test("excludes anchor-only links from extracted targets", () => { + expect(extractWikilinks("see [[#section]]")).toEqual([]); + }); + + test("normalises wiki home links to home note slug", () => { + expect(extractWikilinks("see [[docs/]]")).toEqual([ + { wikiSlug: "docs", noteSlug: "home", label: "docs" }, + ]); + }); + + test("extracts links with section fragments", () => { + expect(extractWikilinks("see [[page#section]]", "test-wiki")).toEqual([ + { + wikiSlug: "test-wiki", + noteSlug: "page", + label: "page", + section: "section", + }, + ]); + }); + + test("extracts cross-wiki links with section fragments", () => { + expect(extractWikilinks("see [[docs/page#intro]]")).toEqual([ + { + wikiSlug: "docs", + noteSlug: "page", + label: "docs/page", + section: "intro", + }, + ]); + }); });