393 lines
11 KiB
TypeScript
393 lines
11 KiB
TypeScript
import {
|
|
findNodeOfType,
|
|
type ParseTree,
|
|
renderToText,
|
|
replaceNodesMatchingAsync,
|
|
traverseTree,
|
|
} from "@silverbulletmd/silverbullet/lib/tree";
|
|
import {
|
|
getNameFromPath,
|
|
getPathExtension,
|
|
isMarkdownPath,
|
|
parseToRef,
|
|
type Ref,
|
|
} from "@silverbulletmd/silverbullet/lib/ref";
|
|
import {
|
|
isLocalURL,
|
|
resolveMarkdownLink,
|
|
} from "@silverbulletmd/silverbullet/lib/resolve";
|
|
import { mime } from "mimetypes";
|
|
import type { Client } from "../client.ts";
|
|
import type { LuaEnv, LuaStackFrame } from "../space_lua/runtime.ts";
|
|
import { parseMarkdown } from "../markdown_parser/parser.ts";
|
|
import { renderExpressionResult } from "./result_render.ts";
|
|
import { parseExpressionString } from "../space_lua/parse.ts";
|
|
import { evalExpression } from "../space_lua/eval.ts";
|
|
import type { LuaExpression } from "../space_lua/ast.ts";
|
|
import { mdLinkRegex, wikiLinkRegex } from "../markdown_parser/constants.ts";
|
|
|
|
import { fsEndpoint } from "../spaces/constants.ts";
|
|
|
|
/**
|
|
* Describes the dimensions of a transclusion, if provided through the alias.
|
|
* Can be parsed from the alias using {@link parseDimensionFromAlias}
|
|
*/
|
|
export type ContentDimensions = {
|
|
width?: number;
|
|
height?: number;
|
|
};
|
|
|
|
/**
|
|
* Expands custom markdown Lua directives and transclusions into plain markdown
|
|
* @param mdTree parsed markdown tree
|
|
* @returns modified mdTree
|
|
*/
|
|
export async function expandMarkdown(
|
|
client: Client,
|
|
mdTree: ParseTree,
|
|
env: LuaEnv,
|
|
sf: LuaStackFrame,
|
|
forbiddenPages: Set<string> = new Set(),
|
|
): Promise<ParseTree> {
|
|
await replaceNodesMatchingAsync(mdTree, async (n) => {
|
|
if (n.type === "Image") {
|
|
// Let's scan for ![[embeds]] that are codified as Images, confusingly
|
|
const text = renderToText(n);
|
|
|
|
const transclusion = parseTransclusion(text);
|
|
if (!transclusion || forbiddenPages.has(transclusion.url)) {
|
|
return n;
|
|
}
|
|
|
|
const result = await inlineContentFromURL(
|
|
client,
|
|
transclusion.url,
|
|
transclusion.alias,
|
|
transclusion.dimension,
|
|
transclusion.linktype !== "wikilink",
|
|
);
|
|
|
|
// We don't transclude anything that's not markdown
|
|
if (typeof result !== "string") {
|
|
return n;
|
|
}
|
|
|
|
// We know it's a markdown page and we know we are transcluding it. "Mark"
|
|
// it so we won't touch it down the line and cause endless recursion
|
|
forbiddenPages.add(transclusion.url);
|
|
|
|
const tree = parseMarkdown(result);
|
|
|
|
// Recursively process
|
|
return expandMarkdown(
|
|
client,
|
|
tree,
|
|
env,
|
|
sf,
|
|
forbiddenPages,
|
|
);
|
|
} else if (n.type === "LuaDirective") {
|
|
const expr = findNodeOfType(n, "LuaExpressionDirective") as
|
|
| LuaExpression
|
|
| null;
|
|
if (!expr) {
|
|
return;
|
|
}
|
|
const exprText = renderToText(expr);
|
|
|
|
try {
|
|
let result = await evalExpression(
|
|
parseExpressionString(exprText),
|
|
env,
|
|
sf,
|
|
);
|
|
|
|
if (result?.markdown) {
|
|
result = result.markdown;
|
|
}
|
|
const markdown = await renderExpressionResult(result);
|
|
return parseMarkdown(markdown);
|
|
} catch (e: any) {
|
|
// Reduce blast radius and give useful error message
|
|
console.error("Error evaluating Lua directive", exprText, e);
|
|
return parseMarkdown(`**Error:** ${e.message}`);
|
|
}
|
|
}
|
|
});
|
|
return mdTree;
|
|
}
|
|
|
|
/**
|
|
* Extracts the transclusion from a markdown tree. Right now this is only
|
|
* supported for headers, in which case the Function will extract the header
|
|
* plus all text till the next header
|
|
* @returns Returns null if the header isn't found
|
|
*/
|
|
export function extractTransclusion(
|
|
markdown: string,
|
|
details: Ref["details"],
|
|
): string | null {
|
|
if (!details) {
|
|
return markdown;
|
|
} else if (details.type !== "header") {
|
|
return null;
|
|
}
|
|
|
|
const parseTree = parseMarkdown(markdown);
|
|
|
|
let from: undefined | number = undefined, to: undefined | number = undefined;
|
|
traverseTree(parseTree, (subTree) => {
|
|
// We are done, but we can't properly cancel the traversal
|
|
if (from !== undefined && to !== undefined) {
|
|
return true;
|
|
}
|
|
|
|
if (!subTree.type || !subTree.type.startsWith("ATXHeading")) {
|
|
return false;
|
|
}
|
|
|
|
// We already found the first header
|
|
if (from !== undefined) {
|
|
to = subTree.from;
|
|
return true;
|
|
}
|
|
|
|
const mark = findNodeOfType(subTree, "HeaderMark");
|
|
if (!mark || mark.from === undefined || mark.to === undefined) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
renderToText(subTree)
|
|
.slice(mark.to - mark.from)
|
|
.trimStart() === details.header.trim()
|
|
) {
|
|
from = subTree.from;
|
|
}
|
|
|
|
// No need to continue into a header
|
|
return true;
|
|
});
|
|
|
|
// Go till end of file if we can't find a second header
|
|
to ??= parseTree.to;
|
|
|
|
if (from === undefined) {
|
|
return null;
|
|
}
|
|
|
|
return markdown.slice(from, to);
|
|
}
|
|
|
|
/**
|
|
* Function to generate HTML or markdown for a ![[<link>]] type transclusion.
|
|
* @param allowExternal In SB currently, wikilinks don't allow external links
|
|
* and markdown links do
|
|
* @returns a string for a markdown transclusion or html for everything else
|
|
*/
|
|
export function inlineContentFromURL(
|
|
client: Client | undefined,
|
|
url: string,
|
|
alias: string,
|
|
dimension: ContentDimensions | undefined,
|
|
allowExternal: boolean = true,
|
|
): string | HTMLElement | Promise<HTMLElement | string> {
|
|
if (!client) {
|
|
return "";
|
|
}
|
|
let mimeType: string | null | undefined;
|
|
if (!isLocalURL(url) && allowExternal) {
|
|
// TODO
|
|
// Realistically we should dertermine the mine type by sending a HEAD
|
|
// request, this poses multiple problems
|
|
// 1. This makes `async` a hard requirement here
|
|
// 2. We would need to proxy the request (because of CORS)
|
|
// 3. It won't work "offline" (i.e. away from the SB instance, because it
|
|
// can't proxy the request anymore)
|
|
// 4. It can be pretty heavy. If your internet connection is bad you will
|
|
// have to wait for all HEAD request, for your `markdownToHtml` to
|
|
// complete. This could take a noticeable amount of time.
|
|
// For this reason we will stick to doing it the `dumb` way by just getting
|
|
// it from the URL extension
|
|
const extension = URL.parse(url)?.pathname.split(".").pop();
|
|
if (extension) {
|
|
mimeType = mime.getType(extension);
|
|
}
|
|
} else {
|
|
const ref = parseToRef(url);
|
|
if (!ref) {
|
|
return `Failed to parse url`;
|
|
}
|
|
|
|
mimeType = mime.getType(getPathExtension(ref.path));
|
|
}
|
|
|
|
if (!mimeType) {
|
|
return `Failed to determine mime type`;
|
|
}
|
|
|
|
const style = `max-width: 100%;` +
|
|
(dimension?.width ? `width: ${dimension.width}px;` : "") +
|
|
(dimension?.height ? `height: ${dimension.height}px;` : "");
|
|
|
|
// If the URL is a local, prefix it with /.fs and encode the : so that it's not interpreted as a protocol
|
|
const sanitizedFsUrl = isLocalURL(url)
|
|
? fsEndpoint.slice(1) + "/" + url.replace(":", "%3A")
|
|
: url;
|
|
|
|
let result: HTMLElement | string | Promise<string | HTMLElement>;
|
|
if (mimeType.startsWith("image/")) {
|
|
const img = document.createElement("img");
|
|
img.src = sanitizedFsUrl;
|
|
img.alt = alias;
|
|
img.style = style;
|
|
result = img;
|
|
} else if (mimeType.startsWith("video/")) {
|
|
const video = document.createElement("video");
|
|
video.src = sanitizedFsUrl;
|
|
video.title = alias;
|
|
video.controls = true;
|
|
video.autoplay = false;
|
|
video.style = style;
|
|
result = video;
|
|
} else if (mimeType.startsWith("audio/")) {
|
|
const audio = document.createElement("audio");
|
|
audio.src = sanitizedFsUrl;
|
|
audio.title = alias;
|
|
audio.controls = true;
|
|
audio.autoplay = false;
|
|
audio.style = style;
|
|
result = audio;
|
|
} else if (mimeType === "application/pdf") {
|
|
const embed = document.createElement("object");
|
|
embed.type = mimeType;
|
|
embed.data = sanitizedFsUrl;
|
|
embed.style.width = "100%";
|
|
embed.style.height = "20em";
|
|
embed.style = style;
|
|
result = embed;
|
|
} else if (mimeType === "text/markdown") {
|
|
if (!isLocalURL(url) && allowExternal) {
|
|
return `Transcluding markdown from external sources is not supported`;
|
|
}
|
|
|
|
const ref = parseToRef(url);
|
|
|
|
if (!ref || !isMarkdownPath(ref.path)) {
|
|
// We can be fairly sure this can't happen, but just be sure
|
|
return `Couldn't transclude markdown, invalid path`;
|
|
}
|
|
|
|
// Do a pre-check, because `readPage` is quite heavy
|
|
if (!client.clientSystem.allKnownFiles.has(ref.path)) {
|
|
return `Couldn't transclude markdown, page doesn't exist`;
|
|
}
|
|
|
|
result = (async () => {
|
|
// Don't try catch, we just checked the existence
|
|
const { text: markdown } = await client.space.readPage(
|
|
getNameFromPath(ref.path),
|
|
);
|
|
|
|
const transclusion = extractTransclusion(markdown, ref.details);
|
|
if (!transclusion) {
|
|
return `Couldn't extract transclusion from markdown, try removing any headers '\\#' or positions '@' from your link`;
|
|
}
|
|
|
|
return transclusion;
|
|
})();
|
|
} else {
|
|
result = `File has unsupported mimeType: ${mimeType}`;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Parse an alias, possibly containing dimensions into an object
|
|
* @example "alias", "alias|100", "alias|100x200", "100", "100x200"
|
|
*/
|
|
export function parseDimensionFromAlias(
|
|
text: string,
|
|
): { alias: string; dimension?: ContentDimensions } {
|
|
let alias: string;
|
|
let dim: ContentDimensions | undefined;
|
|
if (text.includes("|")) {
|
|
const [aliasPart, dimPart] = text.split("|");
|
|
alias = aliasPart;
|
|
const [width, height] = dimPart.split("x");
|
|
dim = {};
|
|
if (width) {
|
|
dim.width = parseInt(width);
|
|
}
|
|
if (height) {
|
|
dim.height = parseInt(height);
|
|
}
|
|
} else if (/^[x\d]/.test(text)) {
|
|
const [width, height] = text.split("x");
|
|
dim = {};
|
|
if (width) {
|
|
dim.width = parseInt(width);
|
|
}
|
|
if (height) {
|
|
dim.height = parseInt(height);
|
|
}
|
|
alias = "";
|
|
} else {
|
|
alias = text;
|
|
}
|
|
|
|
return { alias, dimension: dim };
|
|
}
|
|
|
|
/**
|
|
* Parses a transclusion of the type `![[]]` or `![]()`
|
|
* @param text
|
|
*/
|
|
export function parseTransclusion(
|
|
text: string,
|
|
): {
|
|
url: string;
|
|
alias: string;
|
|
dimension?: ContentDimensions;
|
|
linktype: "wikilink" | "markdown";
|
|
} | null {
|
|
let url, alias = undefined;
|
|
let linktype: "wikilink" | "markdown" = "markdown";
|
|
// TODO: Take in the tree and use tree nodes to get url and alias (Applies to all regex uses)
|
|
mdLinkRegex.lastIndex = 0;
|
|
wikiLinkRegex.lastIndex = 0;
|
|
let match: RegExpMatchArray | null = null;
|
|
if ((match = mdLinkRegex.exec(text)) && match.groups) {
|
|
({ url, title: alias } = match.groups);
|
|
|
|
if (isLocalURL(url)) {
|
|
url = resolveMarkdownLink(
|
|
client.currentName(),
|
|
decodeURI(url),
|
|
);
|
|
}
|
|
linktype = "markdown";
|
|
} else if ((match = wikiLinkRegex.exec(text)) && match.groups) {
|
|
({ stringRef: url, alias } = match.groups);
|
|
linktype = "wikilink";
|
|
} else {
|
|
// We found no match
|
|
return null;
|
|
}
|
|
|
|
let dimension: ContentDimensions | undefined;
|
|
if (alias) {
|
|
({ alias, dimension: dimension } = parseDimensionFromAlias(alias));
|
|
} else {
|
|
alias = "";
|
|
}
|
|
|
|
return {
|
|
url,
|
|
alias,
|
|
dimension,
|
|
linktype,
|
|
};
|
|
}
|