import { collectNodesOfType, findNodeOfType, renderToText, traverseTree, } from "@silverbulletmd/silverbullet/lib/tree"; import type { IndexTreeEvent, ObjectValue, } from "@silverbulletmd/silverbullet/types"; import { isLocalPath, resolvePath, } from "@silverbulletmd/silverbullet/lib/resolve"; import { indexObjects, queryObjects } from "./api.ts"; import { extractFrontmatter } from "@silverbulletmd/silverbullet/lib/frontmatter"; import { updateITags } from "@silverbulletmd/silverbullet/lib/tags"; import { looksLikePathWithExtension, parsePageRef, } from "@silverbulletmd/silverbullet/lib/page_ref"; import { extractSnippetAroundIndex } from "./snippet_extractor.ts"; import { mdLinkRegex, wikiLinkRegex, } from "$common/markdown_parser/constants.ts"; import { space } from "@silverbulletmd/silverbullet/syscalls"; export type LinkObject = ObjectValue< { //Page Link // The page the link points to toPage: string; // The page the link occurs in page: string; pos: number; snippet: string; alias?: string; asTemplate: boolean; toFile?: never; } | { // Attachment Link // The file the link points to toFile: string; // The page the link occurs in page: string; pos: number; snippet: string; alias?: string; asTemplate: boolean; toPage?: never; } >; /** * Represents a page that does not yet exist, but is being linked to. A page "aspiring" to be created. */ export type AspiringPageObject = ObjectValue<{ // ref: page@pos // The page the link appears on page: string; // And the position pos: number; // The page the link points to name: string; }>; export async function indexLinks({ name, tree }: IndexTreeEvent) { const links: ObjectValue[] = []; const frontmatter = await extractFrontmatter(tree); const pageText = renderToText(tree); traverseTree(tree, (n): boolean => { // Index [[WikiLinks]] if (n.type === "WikiLink") { const wikiLinkPage = findNodeOfType(n, "WikiLinkPage")!; const wikiLinkAlias = findNodeOfType(n, "WikiLinkAlias"); const url = resolvePath(name, "/" + wikiLinkPage.children![0].text!); const pos = wikiLinkPage.from!; const link: any = { ref: `${name}@${pos}`, tag: "link", snippet: extractSnippetAroundIndex(pageText, pos), pos, page: name, asTemplate: false, }; // Assume link is to an attachment if it has // an extension, to a page otherwise if (looksLikePathWithExtension(url)) { link.toFile = url; } else { link.toPage = parsePageRef(url).page; } if (wikiLinkAlias) { link.alias = wikiLinkAlias.children![0].text!; } updateITags(link, frontmatter); links.push(link); return true; } // Also index [Markdown style]() links if (n.type === "URL") { const linkNode = findNodeOfType(n, "URL")!; if (!linkNode) { return false; } const text = /\[(?[^\]]*)\]\((?<url>.+)\)/ .exec(renderToText(linkNode.parent)); if (!text) { return false; } let [/* fullMatch */, alias, url] = text; // Check if local link if (!isLocalPath(url)) { return false; } const pos = linkNode.from!; url = resolvePath(name, decodeURI(url)); const link: any = { ref: `${name}@${pos}`, tag: "link", snippet: extractSnippetAroundIndex(pageText, pos), pos, page: name, asTemplate: false, }; // Assume link is to an attachment if it has // an extension, to a page otherwise if (looksLikePathWithExtension(url)) { link.toFile = url; } else { link.toPage = parsePageRef(url).page; } if (alias) { link.alias = alias; } updateITags(link, frontmatter); links.push(link); return true; } // Also index links used inside query and template fenced code blocks if (n.type === "FencedCode") { const codeInfo = findNodeOfType(n, "CodeInfo")!; if (!codeInfo) { return false; } const codeLang = codeInfo.children![0].text!; if (codeLang === "template" || codeLang === "query") { const codeText = findNodeOfType(n, "CodeText"); if (!codeText) { return false; } const code = codeText.children![0].text!; const wikiLinkMatches = code.matchAll(wikiLinkRegex); for (const match of wikiLinkMatches) { const [_fullMatch, firstMark, url, alias, _lastMark] = match; const pos = codeText.from! + match.index! + firstMark.length; const link: any = { ref: `${name}@${pos}`, tag: "link", page: name, snippet: extractSnippetAroundIndex(pageText, pos), pos: pos, asTemplate: true, }; // Assume link is to an attachment if it has // an extension, to a page otherwise if (looksLikePathWithExtension(url)) { link.toFile = resolvePath(name, "/" + url); } else { link.toPage = resolvePath(name, "/" + parsePageRef(url).page); } if (alias) { link.alias = alias; } updateITags(link, frontmatter); links.push(link); } const mdLinkMatches = code.matchAll(mdLinkRegex); for (const match of mdLinkMatches) { const [_fullMatch, alias, url] = match; const pos = codeText.from! + match.index! + 1; const link: any = { ref: `${name}@${pos}`, tag: "link", page: name, snippet: extractSnippetAroundIndex(pageText, pos), pos: pos, asTemplate: true, }; if (looksLikePathWithExtension(url)) { link.toFile = resolvePath(name, url); } else { link.toPage = resolvePath(name, parsePageRef(url).page); } if (alias) { link.alias = alias; } updateITags(link, frontmatter); links.push(link); } } } // Also index links used inside quoted frontmatter strings like "[[Page]]" // must match the full string node, only allowing for quotes and whitespace around it if (n.type === "FrontMatter") { // The YAML in frontmatter is parsed by CodeMirror itself for (const textNode of collectNodesOfType(n, "string")) { const text = textNode.children![0].text!; const trimmed = text.replace(/^["'\s]*/, "").replace(/["'\s]*$/, ""); // Make sure we search from the beginning, when reusing a Regex object with global flag wikiLinkRegex.lastIndex = 0; const match = wikiLinkRegex.exec(text); // Search in entire node text to get correct position, but check for full match against trimmed if (match && match[0] === trimmed) { const [_fullMatch, firstMark, url, alias, _lastMark] = match; const pos = textNode.from! + match.index! + firstMark.length; const link: any = { ref: `${name}@${pos}`, tag: "link", page: name, snippet: extractSnippetAroundIndex(pageText, pos), pos: pos, asTemplate: false, }; if (looksLikePathWithExtension(url)) { link.toFile = resolvePath(name, url); } else { link.toPage = resolvePath(name, parsePageRef(url).page); } if (alias) { link.alias = alias; } updateITags(link, frontmatter); links.push(link); } } } return false; }); // console.log("Found", links, "page link(s)"); if (links.length > 0) { await indexObjects(name, links); } // Now let's check which are aspiring pages const aspiringPages: ObjectValue<AspiringPageObject>[] = []; for (const link of links) { if (link.toPage) { // No federated links, nothing with template directives if (link.toPage.startsWith("!") || link.toPage.includes("{{")) { continue; } if (!await space.fileExists(`${link.toPage}.md`)) { aspiringPages.push({ ref: `${name}@${link.pos}`, tag: "aspiring-page", page: name, pos: link.pos, name: link.toPage, } as AspiringPageObject); } } } if (aspiringPages.length > 0) { await indexObjects(name, aspiringPages); } } export async function getBackLinks( name: string, ): Promise<LinkObject[]> { return (await queryObjects<LinkObject>("link", { filter: ["or", ["=", ["attr", "toPage"], ["string", name]], ["=", [ "attr", "toFile", ], ["string", name]]], })); }