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);