From 1d705ac607cc74c71551052577a2760018b0e76a Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 17 Aug 2026 11:15:06 -0500 Subject: [PATCH] Add wikilink support as a markdown-it plugin with unit tests --- package.json | 2 +- src/renderer/wikilinks.ts | 82 +++++++++++++++++++++++++++ tests/run-wikilinks-test.mjs | 107 +++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/renderer/wikilinks.ts create mode 100644 tests/run-wikilinks-test.mjs diff --git a/package.json b/package.json index 208ad65..399aa32 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "start": "npm run build && npm run electron", "lint": "tsc --noEmit", - "test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs", + "test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs", "package": "electron-builder", "test:e2e": "sh tests/run-e2e.sh" }, diff --git a/src/renderer/wikilinks.ts b/src/renderer/wikilinks.ts new file mode 100644 index 0000000..846d01e --- /dev/null +++ b/src/renderer/wikilinks.ts @@ -0,0 +1,82 @@ +import type { MarkdownIt, StateInline } from "markdown-it"; + +export interface WikilinkTarget { + id: string; + title: string; +} + +export interface WikilinkOpts { + chapters: WikilinkTarget[]; +} + +export function wikilinkPlugin(md: MarkdownIt, opts: WikilinkOpts): void { + const chapters = opts?.chapters ?? []; + const byTitle = new Map( + chapters.map((c) => [String(c.title).toLowerCase(), c.id]) + ); + const byId = new Set(chapters.map((c) => String(c.id))); + + const resolveId = (target: string): string | null => { + const key = target.toLowerCase(); + if (byTitle.has(key)) return byTitle.get(key)!; + if (byId.has(target)) return target; + return null; + }; + + const parse = (state: StateInline, silent: boolean): boolean => { + const src = state.src; + const max = state.posMax; + const start = state.pos; + if (start + 2 > max) return false; + if (src.charCodeAt(start) !== 0x5b /* [ */ || src.charCodeAt(start + 1) !== 0x5b /* [ */) + return false; + + let end = -1; + for (let i = start + 2; i < max - 1; i++) { + const ch = src.charCodeAt(i); + if (ch === 0x5b /* [ */) return false; + if (ch === 0x0a /* newline */) return false; + if (ch === 0x5d /* ] */ && src.charCodeAt(i + 1) === 0x5d) { + end = i; + break; + } + } + if (end === -1) return false; + + const raw = src.slice(start + 2, end).trim(); + if (!raw) return false; + + let target = raw; + let label = raw; + const pipe = raw.indexOf("|"); + if (pipe >= 0) { + target = raw.slice(0, pipe).trim(); + const alias = raw.slice(pipe + 1).trim(); + label = alias.length ? alias : target; + } + if (!target) return false; + + if (silent) { + state.pos = end + 2; + return true; + } + + const id = resolveId(target); + const open = state.push("link_open", "a", 1); + if (id !== null) { + open.attrs = [ + ["href", `folio:chapter/${encodeURIComponent(id)}`], + ["class", "wikilink internal"], + ]; + } else { + open.attrs = [["href", "#"], ["class", "wikilink missing"]]; + } + const text = state.push("text", "", 0); + text.content = label; + state.push("link_close", "a", -1); + state.pos = end + 2; + return true; + }; + + md.inline.ruler.before("link", "wikilink", parse); +} diff --git a/tests/run-wikilinks-test.mjs b/tests/run-wikilinks-test.mjs new file mode 100644 index 0000000..a8672af --- /dev/null +++ b/tests/run-wikilinks-test.mjs @@ -0,0 +1,107 @@ +// Standalone test for the Obsidian-style wikilink plugin. +// Run with: node tests/run-wikilinks-test.mjs +import esbuild from "esbuild"; +import MarkdownIt from "markdown-it"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import url from "url"; + +const out = path.join(os.tmpdir(), `folio-wikilinks-${process.pid}.mjs`); +await esbuild.build({ + entryPoints: [path.resolve("src/renderer/wikilinks.ts")], + bundle: true, + platform: "node", + format: "esm", + outfile: out, + write: true, + logLevel: "silent", +}); + +const mod = await import(url.pathToFileURL(out).href); +const { wikilinkPlugin } = mod; + +const chapters = [ + { id: "introduction-1", title: "Introduction" }, + { id: "money-2", title: "Money" }, +]; + +function makeMd() { + const md = new MarkdownIt({ html: false, linkify: true, typographer: true }); + md.use(wikilinkPlugin, { chapters }); + return md; +} + +let failures = 0; +function ok(cond, msg) { + if (cond) console.log(" ok -", msg); + else { + failures++; + console.error("FAIL -", msg); + } +} + +const md = makeMd(); + +// basic wikilink resolves by title (case-insensitive) +const html1 = md.render("See [[Introduction]] for details."); +ok( + html1.includes('Introduction'), + "wikilink resolves by title -> internal link with chapter id" +); + +// wikilink resolves by id +const html2 = md.render("Link to [[introduction-1]] too."); +ok( + html2.includes('href="folio:chapter/introduction-1"') && + html2.includes("class=\"wikilink internal\""), + "wikilink resolves by chapter id" +); + +// label-only match: title takes priority over id +const html2b = md.render("[[Money]]"); +ok(html2b.includes('href="folio:chapter/money-2"'), "title match wins over id when both plausible"); + +// missing wikilink renders as missing +const html3 = md.render("A [[Nonexistent]] thing."); +ok( + html3.includes('href="#"') && html3.includes('class="wikilink missing"'), + "unresolved wikilink renders as missing" +); + +// alias / label syntax [[target|label]] +const html4 = md.render("[[Introduction|custom label]]"); +ok( + html4.includes('href="folio:chapter/introduction-1"') && + html4.includes(">custom label<"), + "alias syntax renders custom label pointing at resolved target" +); + +// alias of a missing target is still missing +const html5 = md.render("[[Nope|shown]]"); +ok( + html5.includes('class="wikilink missing"') && html5.includes(">shown<"), + "alias of missing target renders missing with alias label" +); + +// regression: standard markdown links still work alongside wikilinks +const html6 = md.render("[external](https://example.com) and [[Introduction]]"); +ok(html6.includes('external'), "standard link still renders"); +ok(html6.includes('href="folio:chapter/introduction-1"'), "wikilink renders next to standard link"); + +// regression: bold/italic still parse +const html7 = md.render("**bold** and [[Introduction]]"); +ok(html7.includes("bold"), "emphasis still parses with wikilinks present"); + +// nested brackets are not treated as wikilinks +const html8 = md.render("not a [[a [[b]] c]] wikilink"); +ok(!html8.includes('class="wikilink"'), "nested brackets not parsed as wikilink"); + +// empty [[ ]] is not a wikilink +const html9 = md.render("empty [[] here"); +ok(!html9.includes('class="wikilink"'), "empty wikilink target not parsed"); + +fs.rmSync(out, { force: true }); + +console.log("\nWikilink tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED")); +process.exit(failures === 0 ? 0 : 1);