silverbullet/plugs/index/page_links.ts

289 lines
8.7 KiB
TypeScript

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<LinkObject>[] = [];
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 = /\[(?<title>[^\]]*)\]\((?<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]]],
}));
}