diff --git a/src/lib/import-export/markdown-transform.ts b/src/lib/import-export/markdown-transform.ts index 027ba20..19ab9e8 100644 --- a/src/lib/import-export/markdown-transform.ts +++ b/src/lib/import-export/markdown-transform.ts @@ -54,6 +54,9 @@ export function rewriteForImport( const pipeIdx = inner.indexOf("|"); const title = pipeIdx >= 0 ? inner.slice(0, pipeIdx) : inner; const display = pipeIdx >= 0 ? inner.slice(pipeIdx + 1) : null; + if (title.includes("/")) { + return _match; + } const slug = findSlug(slugMap, title.trim()); if (slug) { @@ -96,6 +99,9 @@ export function rewriteForExport( const pipeIdx = inner.indexOf("|"); const slug = pipeIdx >= 0 ? inner.slice(0, pipeIdx) : inner; const display = pipeIdx >= 0 ? inner.slice(pipeIdx + 1) : null; + if (slug.includes("/")) { + return _match; + } const title = slugToTitle.get(slug.trim()); if (title) { diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index cb17e90..0a3bc55 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,7 +1,9 @@ import MarkdownIt from "markdown-it"; import { type KatexPluginEnv, katexPlugin } from "./markdown/katex-plugin.ts"; import { + type WikilinkTarget, type WikilinkEnv, + parseWikilinkTarget, wikilinkPlugin, } from "./markdown/wikilink-plugin.ts"; import { type VizPluginEnv, vizPlugin } from "./viz/plugin.ts"; @@ -68,25 +70,35 @@ export function renderMarkdown( } /** - * Extract unique wikilink target slugs from markdown content. - * Matches [[slug]] and [[slug|label]], returns deduplicated trimmed slugs. + * Extract unique wikilink targets from markdown content. + * Matches [[slug]], [[slug|label]], [[wiki/slug]], and [[wiki/slug|label]]. */ -export function extractWikilinks(content: string): string[] { +export function extractWikilinks( + content: string, + currentWikiSlug?: string, +): WikilinkTarget[] { const seen = new Set(); + const links: WikilinkTarget[] = []; let pos = 0; while (pos < content.length) { if (content[pos] === "[" && content[pos + 1] === "[") { const closeIdx = content.indexOf("]]", pos + 2); if (closeIdx === -1) break; - const inner = content.slice(pos + 2, closeIdx); - const slug = (inner.includes("|") ? inner.split("|")[0] : inner)?.trim(); - if (slug) { - seen.add(slug); + const target = parseWikilinkTarget( + content.slice(pos + 2, closeIdx), + currentWikiSlug, + ); + if (target) { + const key = `${target.wikiSlug ?? ""}:${target.noteSlug}`; + if (!seen.has(key)) { + seen.add(key); + links.push(target); + } } pos = closeIdx + 2; } else { pos++; } } - return [...seen]; + return links; } diff --git a/src/lib/markdown/wikilink-plugin.ts b/src/lib/markdown/wikilink-plugin.ts index a3653c3..85dcf4d 100644 --- a/src/lib/markdown/wikilink-plugin.ts +++ b/src/lib/markdown/wikilink-plugin.ts @@ -1,11 +1,50 @@ 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"; export interface WikilinkEnv { wikiSlug?: string; } +export interface WikilinkTarget { + wikiSlug?: string; + noteSlug: string; + label: string; +} + +export function parseWikilinkTarget( + content: string, + currentWikiSlug?: string, +): WikilinkTarget | null { + const trimmed = content.trim(); + 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(); + if (!rawTarget) return null; + + const separatorIdx = rawTarget.indexOf("/"); + if (separatorIdx > 0 && separatorIdx < rawTarget.length - 1) { + const wikiSlug = rawTarget.slice(0, separatorIdx).trim(); + const noteSlug = rawTarget.slice(separatorIdx + 1).trim(); + if (wikiSlug && noteSlug) { + return { + wikiSlug, + noteSlug, + label: rawLabel || `${wikiSlug}/${noteSlug}`, + }; + } + } + + return { + ...(currentWikiSlug ? { wikiSlug: currentWikiSlug } : {}), + noteSlug: rawTarget, + label: rawLabel || rawTarget, + }; +} + export function wikilinkPlugin(mdi: MarkdownIt): void { mdi.inline.ruler.push("wikilink", (state: StateInline, silent: boolean) => { const src = state.src; @@ -17,24 +56,16 @@ export function wikilinkPlugin(mdi: MarkdownIt): void { if (closeIdx === -1) return false; if (!silent) { - const content = src.slice(pos + 2, closeIdx); - let slug: string; - let label: string; - if (content.includes("|")) { - const parts = content.split("|"); - slug = parts[0] ?? content; - label = parts.slice(1).join("|"); - } else { - slug = content; - label = content; - } - const env = state.env as WikilinkEnv; - const wikiSlug = env.wikiSlug; - const href = wikiSlug ? `/wiki/${wikiSlug}/${slug.trim()}` : slug.trim(); + 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; const token = state.push("html_inline", "", 0); - token.content = `${escapeHtml(label.trim())}`; + token.content = `${escapeHtml(target.label)}`; } state.pos = closeIdx + 2; diff --git a/src/server/db/queries/revision.ts b/src/server/db/queries/revision.ts index 7a07f8f..72ec6e8 100644 --- a/src/server/db/queries/revision.ts +++ b/src/server/db/queries/revision.ts @@ -24,7 +24,7 @@ export function appendRevisionTx( db: ReturnType, params: AppendRevisionParams, ): void { - const slugs = extractWikilinks(params.newContent); + const targets = extractWikilinks(params.newContent, params.wikiSlug); db.run( `INSERT INTO revisions (note_at_uri, did, at_uri, parent_revision_uri, diff, message) @@ -48,10 +48,16 @@ export function appendRevisionTx( [params.noteAtUri, params.newContent, params.revisionAtUri], ); db.run("DELETE FROM backlinks WHERE source_note_uri = ?", [params.noteAtUri]); - for (const slug of slugs) { + for (const target of targets) { + const targetWikiSlug = target.wikiSlug ?? params.wikiSlug; + const targetWikiExists = db + .query("SELECT 1 FROM wikis WHERE slug = ?") + .get(targetWikiSlug); + if (!targetWikiExists) continue; + db.run( "INSERT OR IGNORE INTO backlinks (source_note_uri, target_note_slug, wiki_slug) VALUES (?, ?, ?)", - [params.noteAtUri, slug, params.wikiSlug], + [params.noteAtUri, target.noteSlug, targetWikiSlug], ); } db.run( diff --git a/src/server/db/types.ts b/src/server/db/types.ts index 4d5c551..d50f065 100644 --- a/src/server/db/types.ts +++ b/src/server/db/types.ts @@ -36,6 +36,7 @@ export interface CurrentNoteRow { export interface BacklinkRow { source_note_uri: string; target_note_slug: string; + // Target wiki slug for the linked note. wiki_slug: string; } diff --git a/src/views/layout.ts b/src/views/layout.ts index 24504ed..56aa8be 100644 --- a/src/views/layout.ts +++ b/src/views/layout.ts @@ -301,8 +301,12 @@ ${extraScripts} ${ options?.enableSearchShortcut ? `document.addEventListener('keydown', (e) => { - const tag = document.activeElement?.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + const target = e.target instanceof Element ? e.target : document.activeElement; + if (target instanceof HTMLElement && ( + target.isContentEditable || + ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || + target.closest('input, textarea, select, [contenteditable="true"], .cm-editor') + )) return; const isSearchShortcut = (e.key === '/' && !e.ctrlKey && !e.metaKey) || ((e.ctrlKey || e.metaKey) && e.key === 'k'); if (!isSearchShortcut) return; e.preventDefault(); diff --git a/tests/lib/import-export/markdown-transform.test.ts b/tests/lib/import-export/markdown-transform.test.ts index dcbb9ec..2b33494 100644 --- a/tests/lib/import-export/markdown-transform.test.ts +++ b/tests/lib/import-export/markdown-transform.test.ts @@ -34,6 +34,12 @@ describe("rewriteForImport", () => { expect(result).toBe("See [[Unknown Page]] for info."); }); + test("preserves cross-wiki wikilinks on import", () => { + const input = "See [[other-wiki/shared-note|Shared Note]] for info."; + const result = rewriteForImport(input, slugMap, imageMap); + expect(result).toBe(input); + }); + test("rewrites Obsidian image embeds", () => { const input = "Here is ![[photo.png]] in text."; const result = rewriteForImport(input, slugMap, imageMap); @@ -101,6 +107,12 @@ describe("rewriteForExport", () => { expect(content).toBe("See [[unknown-slug]] for info."); }); + test("preserves cross-wiki wikilinks on export", () => { + const input = "See [[other-wiki/shared-note|Shared Note]] for info."; + const { content } = rewriteForExport(input, slugToTitle); + expect(content).toBe(input); + }); + test("rewrites blob refs to attachments and collects refs", () => { const input = "![photo](/blob/did:plc:abc/bafk123)"; const { content, blobRefs } = rewriteForExport(input, slugToTitle); diff --git a/tests/lib/markdown.test.ts b/tests/lib/markdown.test.ts index 7f9f1ae..19e73d1 100644 --- a/tests/lib/markdown.test.ts +++ b/tests/lib/markdown.test.ts @@ -64,6 +64,21 @@ describe("wikilinks", () => { expect(html).toContain("My Page Title"); }); + test("renders cross-wiki links with explicit wiki slug", () => { + const { html } = renderMarkdown("see [[other-wiki/my-page]]", WIKI); + expect(html).toContain('href="/wiki/other-wiki/my-page"'); + expect(html).toContain(">other-wiki/my-page<"); + }); + + test("renders cross-wiki links with custom label", () => { + const { html } = renderMarkdown( + "see [[other-wiki/my-page|Other Page]]", + WIKI, + ); + expect(html).toContain('href="/wiki/other-wiki/my-page"'); + expect(html).toContain("Other Page"); + }); + 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"'); @@ -118,22 +133,47 @@ describe("extractWikilinks", () => { expect(extractWikilinks("")).toEqual([]); }); - test("extracts slugs from [[slug]] and [[slug|label]]", () => { - expect(extractWikilinks("see [[my-page]]")).toEqual(["my-page"]); - expect(extractWikilinks("see [[my-page|My Page]]")).toEqual(["my-page"]); + test("extracts targets from [[slug]] and [[slug|label]]", () => { + expect(extractWikilinks("see [[my-page]]")).toEqual([ + { noteSlug: "my-page", label: "my-page" }, + ]); + expect(extractWikilinks("see [[my-page|My Page]]")).toEqual([ + { noteSlug: "my-page", label: "My Page" }, + ]); + }); + + test("extracts cross-wiki targets", () => { + expect(extractWikilinks("see [[docs/intro]]")).toEqual([ + { wikiSlug: "docs", noteSlug: "intro", label: "docs/intro" }, + ]); + expect(extractWikilinks("see [[docs/intro|Intro]]")).toEqual([ + { wikiSlug: "docs", noteSlug: "intro", label: "Intro" }, + ]); }); test("extracts multiple wikilinks and deduplicates", () => { expect(extractWikilinks("[[page-a]] and [[page-b]]")).toEqual([ - "page-a", - "page-b", + { noteSlug: "page-a", label: "page-a" }, + { noteSlug: "page-b", label: "page-b" }, + ]); + expect(extractWikilinks("[[foo]] then [[foo]] again")).toEqual([ + { noteSlug: "foo", label: "foo" }, ]); - expect(extractWikilinks("[[foo]] then [[foo]] again")).toEqual(["foo"]); }); test("trims whitespace from slugs", () => { - expect(extractWikilinks("[[ spaced ]]")).toEqual(["spaced"]); - expect(extractWikilinks("[[ spaced | Label ]]")).toEqual(["spaced"]); + expect(extractWikilinks("[[ spaced ]]")).toEqual([ + { noteSlug: "spaced", label: "spaced" }, + ]); + expect(extractWikilinks("[[ spaced | Label ]]")).toEqual([ + { noteSlug: "spaced", label: "Label" }, + ]); + }); + + test("applies current wiki slug to local extracted links when provided", () => { + expect(extractWikilinks("[[home]]", "test-wiki")).toEqual([ + { wikiSlug: "test-wiki", noteSlug: "home", label: "home" }, + ]); }); test("ignores unclosed brackets and empty slugs", () => { @@ -143,6 +183,9 @@ describe("extractWikilinks", () => { test("handles wikilinks across multiple lines", () => { const result = extractWikilinks("line1 [[a]]\nline2 [[b]]"); - expect(result).toEqual(["a", "b"]); + expect(result).toEqual([ + { noteSlug: "a", label: "a" }, + { noteSlug: "b", label: "b" }, + ]); }); }); diff --git a/tests/server/db/queries/revision.test.ts b/tests/server/db/queries/revision.test.ts index bd88bfd..94a6004 100644 --- a/tests/server/db/queries/revision.test.ts +++ b/tests/server/db/queries/revision.test.ts @@ -9,7 +9,11 @@ import { getSnapshots, saveNoteEdit, } from "../../../../src/server/db/queries/index.ts"; -import { cleanupNotes, ensureTestWiki } from "../../../helpers/cleanup.ts"; +import { + cleanupNotes, + cleanupWikiAndDependents, + ensureTestWiki, +} from "../../../helpers/cleanup.ts"; const db = getDb(); const TEST_DID = "did:plc:mock123"; @@ -19,6 +23,7 @@ const revUri = () => `at://${TEST_DID}/wiki.lichen.noteRevision/${TID.now()}`; beforeAll(() => { ensureTestWiki(); + cleanupWikiAndDependents("other-wiki"); cleanupNotes("test", "backlink-test-*"); cleanupNotes("test", "snapshot-test-*"); cleanupNotes("test", "blob-test-*"); @@ -28,6 +33,7 @@ afterAll(() => { cleanupNotes("test", "backlink-test-*"); cleanupNotes("test", "snapshot-test-*"); cleanupNotes("test", "blob-test-*"); + cleanupWikiAndDependents("other-wiki"); }); describe("backlinks", () => { @@ -142,6 +148,30 @@ describe("backlinks", () => { expect(link.wiki_slug).toBe("test"); } }); + + test("cross-wiki wikilinks store backlinks under target wiki", () => { + const nUri = noteUri(); + db.run( + "INSERT OR REPLACE INTO wikis (slug, did, name, visibility, language, description, at_uri, created_at, updated_at) VALUES (?, ?, ?, 'public', 'en', '', ?, datetime('now'), datetime('now'))", + [ + "other-wiki", + TEST_DID, + "Other Wiki", + `at://${TEST_DID}/wiki.lichen.wiki/other-wiki`, + ], + ); + createNote( + nUri, + revUri(), + "test", + "backlink-test-cross-wiki", + "Cross Wiki", + TEST_DID, + "See [[other-wiki/shared-note]].", + ); + const links = getBacklinks("other-wiki", "shared-note"); + expect(links.map((l) => l.source_note_uri)).toContain(nUri); + }); }); describe("snapshots", () => {