Add wikilink support as a markdown-it plugin with unit tests
This commit is contained in:
parent
5dcdec86b9
commit
1d705ac607
3 changed files with 190 additions and 1 deletions
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
82
src/renderer/wikilinks.ts
Normal file
82
src/renderer/wikilinks.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
107
tests/run-wikilinks-test.mjs
Normal file
107
tests/run-wikilinks-test.mjs
Normal file
|
|
@ -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('<a href="folio:chapter/introduction-1" class="wikilink internal">Introduction</a>'),
|
||||
"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('<a href="https://example.com">external</a>'), "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("<strong>bold</strong>"), "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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue