import { parse, HTMLElement, TextNode, Options } from "node-html-parser"; import shaca from "./shaca/shaca.js"; import assetPath, { assetUrlFragment } from "../services/asset_path.js"; import shareRoot from "./share_root.js"; import escapeHtml from "escape-html"; import type SNote from "./shaca/entities/snote.js"; import BNote from "../becca/entities/bnote.js"; import type BBranch from "../becca/entities/bbranch.js"; import { t } from "i18next"; import SBranch from "./shaca/entities/sbranch.js"; import options from "../services/options.js"; import utils, { getResourceDir, isDev, safeExtractMessageAndStackFromError } from "../services/utils.js"; import ejs from "ejs"; import log from "../services/log.js"; import { join } from "path"; import { readFileSync } from "fs"; import { highlightAuto } from "@triliumnext/highlightjs"; const shareAdjustedAssetPath = isDev ? assetPath : `../${assetPath}`; const templateCache: Map = new Map(); /** * Represents the output of the content renderer. */ export interface Result { header: string; content: string | Buffer | undefined; /** Set to `true` if the provided content should be rendered as empty. */ isEmpty?: boolean; } interface Subroot { note?: SNote | BNote; branch?: SBranch | BBranch } function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot { if (!note || note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) { // share root itself is not shared return {}; } // every path leads to share root, but which one to choose? // for the sake of simplicity, URLs are not note paths const parentBranch = note.getParentBranches()[0]; if (note instanceof BNote) { return { note, branch: parentBranch } } if (parentBranch.parentNoteId === shareRoot.SHARE_ROOT_NOTE_ID) { return { note, branch: parentBranch }; } return getSharedSubTreeRoot(parentBranch.getParentNote()); } export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string, ancestors: string[]) { const subRoot: Subroot = { branch: parentBranch, note: parentBranch.getNote() }; return renderNoteContentInternal(note, { subRoot, rootNoteId: parentBranch.noteId, cssToLoad: [ `${basePath}assets/styles.css`, `${basePath}assets/scripts.css`, ], jsToLoad: [ `${basePath}assets/scripts.js` ], logoUrl: `${basePath}icon-color.svg`, ancestors }); } export function renderNoteContent(note: SNote) { const subRoot = getSharedSubTreeRoot(note); const ancestors: string[] = []; let notePointer = note; while (notePointer.parents[0]?.noteId !== subRoot.note?.noteId) { const pointerParent = notePointer.parents[0]; if (!pointerParent) { break; } ancestors.push(pointerParent.noteId); notePointer = pointerParent; } // Determine CSS to load. const cssToLoad: string[] = []; if (!note.isLabelTruthy("shareOmitDefaultCss")) { cssToLoad.push(`assets/styles.css`); cssToLoad.push(`assets/scripts.css`); } for (const cssRelation of note.getRelations("shareCss")) { cssToLoad.push(`api/notes/${cssRelation.value}/download`); } // Determine JS to load. const jsToLoad: string[] = [ "assets/scripts.js" ]; for (const jsRelation of note.getRelations("shareJs")) { jsToLoad.push(`api/notes/${jsRelation.value}/download`); } const customLogoId = note.getRelation("shareLogo")?.value; const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `../${assetUrlFragment}/images/icon-color.svg`; return renderNoteContentInternal(note, { subRoot, rootNoteId: "_share", cssToLoad, jsToLoad, logoUrl, ancestors }); } interface RenderArgs { subRoot: Subroot; rootNoteId: string; cssToLoad: string[]; jsToLoad: string[]; logoUrl: string; ancestors: string[]; } function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) { const { header, content, isEmpty } = getContent(note); const showLoginInShareTheme = options.getOption("showLoginInShareTheme"); const opts = { note, header, content, isEmpty, assetPath: shareAdjustedAssetPath, assetUrlFragment, showLoginInShareTheme, t, isDev, utils, ...renderArgs }; // Check if the user has their own template. if (note.hasRelation("shareTemplate")) { // Get the template note and content const templateId = note.getRelation("shareTemplate")?.value; const templateNote = templateId && shaca.getNote(templateId); // Make sure the note type is correct if (templateNote && templateNote.type === "code" && templateNote.mime === "application/x-ejs") { // EJS caches the result of this so we don't need to pre-cache const includer = (path: string) => { const childNote = templateNote.children.find((n) => path === n.title); if (!childNote) throw new Error(`Unable to find child note: ${path}.`); if (childNote.type !== "code" || childNote.mime !== "application/x-ejs") throw new Error("Incorrect child note type."); const template = childNote.getContent(); if (typeof template !== "string") throw new Error("Invalid template content type."); return { template }; }; // Try to render user's template, w/ fallback to default view try { const content = templateNote.getContent(); if (typeof content === "string") { return ejs.render(content, opts, { includer }); } } catch (e: unknown) { const [errMessage, errStack] = safeExtractMessageAndStackFromError(e); log.error(`Rendering user provided share template (${templateId}) threw exception ${errMessage} with stacktrace: ${errStack}`); } } } // Render with the default view otherwise. const templatePath = getDefaultTemplatePath("page"); return ejs.render(readTemplate(templatePath), opts, { includer: (path) => { // Path is relative to apps/server/dist/assets/views return { template: readTemplate(getDefaultTemplatePath(path)) }; } }); } function getDefaultTemplatePath(template: string) { // Path is relative to apps/server/dist/assets/views return process.env.NODE_ENV === "development" ? join(__dirname, `../../../../packages/share-theme/src/templates/${template}.ejs`) : join(getResourceDir(), `share-theme/templates/${template}.ejs`); } function readTemplate(path: string) { const cachedTemplate = templateCache.get(path); if (cachedTemplate) { return cachedTemplate; } const templateString = readFileSync(path, "utf-8"); templateCache.set(path, templateString); return templateString; } export function getContent(note: SNote | BNote) { if (note.isProtected) { return { header: "", content: "

Protected note cannot be displayed

", isEmpty: false }; } const result: Result = { content: note.getContent(), header: "", isEmpty: false }; if (note.type === "text") { renderText(result, note); } else if (note.type === "code") { renderCode(result); } else if (note.type === "mermaid") { renderMermaid(result, note); } else if (["image", "canvas", "mindMap"].includes(note.type)) { renderImage(result, note); } else if (note.type === "file") { renderFile(note, result); } else if (note.type === "book") { result.isEmpty = true; } else { result.content = `

${t("content_renderer.note-cannot-be-displayed")}

`; } return result; } function renderIndex(result: Result) { result.content += '"; } function renderText(result: Result, note: SNote | BNote) { if (typeof result.content !== "string") return; const parseOpts: Partial = { blockTextElements: {} } const document = parse(result.content || "", parseOpts); // Process include notes. for (const includeNoteEl of document.querySelectorAll("section.include-note")) { const noteId = includeNoteEl.getAttribute("data-note-id"); if (!noteId) continue; const note = shaca.getNote(noteId); if (!note) continue; const includedResult = getContent(note); if (typeof includedResult.content !== "string") continue; const includedDocument = parse(includedResult.content, parseOpts).childNodes; if (includedDocument) { includeNoteEl.replaceWith(...includedDocument); } } result.isEmpty = document.textContent?.trim().length === 0 && document.querySelectorAll("img").length === 0; if (!result.isEmpty) { // Process attachment links. for (const linkEl of document.querySelectorAll("a")) { const href = linkEl.getAttribute("href"); // Preserve footnotes. if (href?.startsWith("#fn")) { continue; } if (href?.startsWith("#")) { handleAttachmentLink(linkEl, href); } } // Apply syntax highlight. for (const codeEl of document.querySelectorAll("pre code")) { const highlightResult = highlightAuto(codeEl.innerText); codeEl.innerHTML = highlightResult.value; codeEl.classList.add("hljs"); } result.content = document.innerHTML ?? ""; if (note.hasLabel("shareIndex")) { renderIndex(result); } } } function handleAttachmentLink(linkEl: HTMLElement, href: string) { const linkRegExp = /attachmentId=([a-zA-Z0-9_]+)/g; let attachmentMatch; if ((attachmentMatch = linkRegExp.exec(href))) { const attachmentId = attachmentMatch[1]; const attachment = shaca.getAttachment(attachmentId); if (attachment) { linkEl.setAttribute("href", `api/attachments/${attachmentId}/download`); linkEl.classList.add(`attachment-link`); linkEl.classList.add(`role-${attachment.role}`); linkEl.childNodes.length = 0; linkEl.appendChild(new TextNode(attachment.title)); } else { linkEl.removeAttribute("href"); } } else { const [notePath] = href.split("?"); const notePathSegments = notePath.split("/"); const noteId = notePathSegments[notePathSegments.length - 1]; const linkedNote = shaca.getNote(noteId); if (linkedNote) { const isExternalLink = linkedNote.hasLabel("shareExternalLink"); const href = isExternalLink ? linkedNote.getLabelValue("shareExternalLink") : `./${linkedNote.shareId}`; if (href) { linkEl.setAttribute("href", href); } if (isExternalLink) { linkEl.setAttribute("target", "_blank"); linkEl.setAttribute("rel", "noopener noreferrer"); } linkEl.classList.add(`type-${linkedNote.type}`); } else { linkEl.removeAttribute("href"); } } } /** * Renders a code note. */ export function renderCode(result: Result) { if (typeof result.content !== "string" || !result.content?.trim()) { result.isEmpty = true; } else { const preEl = new HTMLElement("pre", {}); preEl.appendChild(new TextNode(result.content)); result.content = preEl.outerHTML; } } function renderMermaid(result: Result, note: SNote | BNote) { if (typeof result.content !== "string") { return; } result.content = `
Chart source
${escapeHtml(result.content)}
`; } function renderImage(result: Result, note: SNote | BNote) { result.content = ``; } function renderFile(note: SNote | BNote, result: Result) { if (note.mime === "application/pdf") { result.content = ``; } else { result.content = ``; } } export default { getContent };