diff --git a/apps/client/src/share.ts b/apps/client/src/share.ts index b438f0c0c..579b9e422 100644 --- a/apps/client/src/share.ts +++ b/apps/client/src/share.ts @@ -9,16 +9,6 @@ async function ensureJQuery() { (window as any).$ = $; } -async function applyMath() { - const anyMathBlock = document.querySelector("#content .math-tex"); - if (!anyMathBlock) { - return; - } - - const renderMathInElement = (await import("./services/math.js")).renderMathInElement; - renderMathInElement(document.getElementById("content")); -} - async function formatCodeBlocks() { const anyCodeBlock = document.querySelector("#content pre"); if (!anyCodeBlock) { @@ -31,54 +21,4 @@ async function formatCodeBlocks() { async function setupTextNote() { formatCodeBlocks(); - applyMath(); - - const setupMermaid = (await import("./share/mermaid.js")).default; - setupMermaid(); } - -/** - * Fetch note with given ID from backend - * - * @param noteId of the given note to be fetched. If false, fetches current note. - */ -async function fetchNote(noteId: string | null = null) { - if (!noteId) { - noteId = document.body.getAttribute("data-note-id"); - } - - const resp = await fetch(`api/notes/${noteId}`); - - return await resp.json(); -} - -document.addEventListener( - "DOMContentLoaded", - () => { - const noteType = determineNoteType(); - - if (noteType === "text") { - setupTextNote(); - } - - const toggleMenuButton = document.getElementById("toggleMenuButton"); - const layout = document.getElementById("layout"); - - if (toggleMenuButton && layout) { - toggleMenuButton.addEventListener("click", () => layout.classList.toggle("showMenu")); - } - }, - false -); - -function determineNoteType() { - const bodyClass = document.body.className; - const match = bodyClass.match(/type-([^\s]+)/); - return match ? match[1] : null; -} - -// workaround to prevent webpack from removing "fetchNote" as dead code: -// add fetchNote as property to the window object -Object.defineProperty(window, "fetchNote", { - value: fetchNote -}); diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 1949d3357..6af1a18c4 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -104,7 +104,8 @@ "export_status": "Export status", "export_in_progress": "Export in progress: {{progressCount}}", "export_finished_successfully": "Export finished successfully.", - "format_pdf": "PDF - for printing or sharing purposes." + "format_pdf": "PDF - for printing or sharing purposes.", + "share-format": "HTML for web publishing - uses the same theme that is used shared notes, but can be published as a static website." }, "help": { "title": "Cheatsheet", diff --git a/apps/client/src/widgets/dialogs/export.tsx b/apps/client/src/widgets/dialogs/export.tsx index dded32624..068a5cbd2 100644 --- a/apps/client/src/widgets/dialogs/export.tsx +++ b/apps/client/src/widgets/dialogs/export.tsx @@ -79,7 +79,8 @@ export default function ExportDialog() { values={[ { value: "html", label: t("export.format_html_zip") }, { value: "markdown", label: t("export.format_markdown") }, - { value: "opml", label: t("export.format_opml") } + { value: "opml", label: t("export.format_opml") }, + { value: "share", label: t("export.share-format") } ]} /> diff --git a/apps/desktop/scripts/build.ts b/apps/desktop/scripts/build.ts index 945a4cfeb..0b76d8b25 100644 --- a/apps/desktop/scripts/build.ts +++ b/apps/desktop/scripts/build.ts @@ -11,6 +11,7 @@ async function main() { // Copy assets. build.copy("src/assets", "assets/"); build.copy("/apps/server/src/assets", "assets/"); + build.triggerBuildAndCopyTo("packages/share-theme", "share-theme/assets/"); build.copy("/packages/share-theme/src/templates", "share-theme/templates/"); // Copy node modules dependencies diff --git a/apps/edit-docs/src/edit-docs.ts b/apps/edit-docs/src/edit-docs.ts index 940f89540..b6a04969f 100644 --- a/apps/edit-docs/src/edit-docs.ts +++ b/apps/edit-docs/src/edit-docs.ts @@ -6,7 +6,7 @@ import { initializeTranslations } from "@triliumnext/server/src/services/i18n.js import debounce from "@triliumnext/client/src/services/debounce.js"; import { extractZip, importData, initializeDatabase, startElectron } from "./utils.js"; import cls from "@triliumnext/server/src/services/cls.js"; -import type { AdvancedExportOptions } from "@triliumnext/server/src/services/export/zip.js"; +import type { AdvancedExportOptions, ExportFormat } from "@triliumnext/server/src/services/export/zip/abstract_provider.js"; import { parseNoteMetaFile } from "@triliumnext/server/src/services/in_app_help.js"; import type NoteMeta from "@triliumnext/server/src/services/meta/note_meta.js"; @@ -75,7 +75,7 @@ async function setOptions() { optionsService.setOption("compressImages", "false"); } -async function exportData(noteId: string, format: "html" | "markdown", outputPath: string, ignoredFiles?: Set) { +async function exportData(noteId: string, format: ExportFormat, outputPath: string, ignoredFiles?: Set) { const zipFilePath = "output.zip"; try { diff --git a/apps/server/package.json b/apps/server/package.json index 93bf2819f..e2f0ef82e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -36,6 +36,7 @@ "@triliumnext/commons": "workspace:*", "@triliumnext/express-partial-content": "workspace:*", "@triliumnext/turndown-plugin-gfm": "workspace:*", + "@triliumnext/highlightjs": "workspace:*", "@types/archiver": "7.0.0", "@types/better-sqlite3": "7.6.13", "@types/cls-hooked": "4.3.9", diff --git a/apps/server/scripts/build.ts b/apps/server/scripts/build.ts index d2ed99ee2..6e0c53adf 100644 --- a/apps/server/scripts/build.ts +++ b/apps/server/scripts/build.ts @@ -7,6 +7,7 @@ async function main() { // Copy assets build.copy("src/assets", "assets/"); + build.triggerBuildAndCopyTo("packages/share-theme", "share-theme/assets/"); build.copy("/packages/share-theme/src/templates", "share-theme/templates/"); // Copy node modules dependencies diff --git a/apps/server/src/becca/entities/bbranch.ts b/apps/server/src/becca/entities/bbranch.ts index cd50fe09b..e07443024 100644 --- a/apps/server/src/becca/entities/bbranch.ts +++ b/apps/server/src/becca/entities/bbranch.ts @@ -278,6 +278,11 @@ class BBranch extends AbstractBeccaEntity { }); } } + + getParentNote() { + return this.parentNote; + } + } export default BBranch; diff --git a/apps/server/src/becca/entities/bnote.ts b/apps/server/src/becca/entities/bnote.ts index 1a724b1b0..dd05fd974 100644 --- a/apps/server/src/becca/entities/bnote.ts +++ b/apps/server/src/becca/entities/bnote.ts @@ -1758,6 +1758,26 @@ class BNote extends AbstractBeccaEntity { return childBranches; } + get encodedTitle() { + return encodeURIComponent(this.title); + } + + getVisibleChildBranches() { + return this.getChildBranches().filter((branch) => !branch.getNote().isLabelTruthy("shareHiddenFromTree")); + } + + getVisibleChildNotes() { + return this.getVisibleChildBranches().map((branch) => branch.getNote()); + } + + hasVisibleChildren() { + return this.getVisibleChildNotes().length > 0; + } + + get shareId() { + return this.noteId; + } + /** * Return an attribute by it's attributeId. Requires the attribute cache to be available. * @param attributeId - the id of the attribute owned by this note diff --git a/apps/server/src/etapi/notes.ts b/apps/server/src/etapi/notes.ts index 07d6a3e68..2a556dd40 100644 --- a/apps/server/src/etapi/notes.ts +++ b/apps/server/src/etapi/notes.ts @@ -14,6 +14,7 @@ import type { ParsedQs } from "qs"; import type { NoteParams } from "../services/note-interface.js"; import type { SearchParams } from "../services/search/services/types.js"; import type { ValidatorMap } from "./etapi-interface.js"; +import type { ExportFormat } from "../services/export/zip/abstract_provider.js"; function register(router: Router) { eu.route(router, "get", "/etapi/notes", (req, res, next) => { @@ -149,8 +150,8 @@ function register(router: Router) { const note = eu.getAndCheckNote(req.params.noteId); const format = req.query.format || "html"; - if (typeof format !== "string" || !["html", "markdown"].includes(format)) { - throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default) or 'markdown'.`); + if (typeof format !== "string" || !["html", "markdown", "share"].includes(format)) { + throw new eu.EtapiError(400, "UNRECOGNIZED_EXPORT_FORMAT", `Unrecognized export format '${format}', supported values are 'html' (default), 'markdown' or 'share'.`); } const taskContext = new TaskContext("no-progress-reporting", "export", null); @@ -159,7 +160,7 @@ function register(router: Router) { // (e.g. branchIds are not seen in UI), that we export "note export" instead. const branch = note.getParentBranches()[0]; - zipExportService.exportToZip(taskContext, branch, format as "html" | "markdown", res); + zipExportService.exportToZip(taskContext, branch, format as ExportFormat, res); }); eu.route(router, "post", "/etapi/notes/:noteId/import", (req, res, next) => { diff --git a/apps/server/src/routes/api/export.ts b/apps/server/src/routes/api/export.ts index 4bc0c2177..944eee841 100644 --- a/apps/server/src/routes/api/export.ts +++ b/apps/server/src/routes/api/export.ts @@ -26,7 +26,7 @@ function exportBranch(req: Request, res: Response) { const taskContext = new TaskContext(taskId, "export", null); try { - if (type === "subtree" && (format === "html" || format === "markdown")) { + if (type === "subtree" && (format === "html" || format === "markdown" || format === "share")) { zipExportService.exportToZip(taskContext, branch, format, res); } else if (type === "single") { if (format !== "html" && format !== "markdown") { diff --git a/apps/server/src/routes/assets.ts b/apps/server/src/routes/assets.ts index a1a2bfb63..1e2ff30b1 100644 --- a/apps/server/src/routes/assets.ts +++ b/apps/server/src/routes/assets.ts @@ -44,6 +44,7 @@ async function register(app: express.Application) { app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(publicDir, "translations"))); app.use(`/node_modules/`, persistentCacheStatic(path.join(publicDir, "node_modules"))); } + app.use(`/share/assets/`, express.static(getShareThemeAssetDir())); app.use(`/${assetUrlFragment}/images`, persistentCacheStatic(path.join(resourceDir, "assets", "images"))); app.use(`/${assetUrlFragment}/doc_notes`, persistentCacheStatic(path.join(resourceDir, "assets", "doc_notes"))); app.use(`/assets/vX/fonts`, express.static(path.join(srcRoot, "public/fonts"))); @@ -51,6 +52,16 @@ async function register(app: express.Application) { app.use(`/assets/vX/stylesheets`, express.static(path.join(srcRoot, "public/stylesheets"))); } +export function getShareThemeAssetDir() { + if (process.env.NODE_ENV === "development") { + const srcRoot = path.join(__dirname, "..", ".."); + return path.join(srcRoot, "../../packages/share-theme/dist"); + } else { + const resourceDir = getResourceDir(); + return path.join(resourceDir, "share-theme/assets"); + } +} + export default { register }; diff --git a/apps/server/src/services/export/single.ts b/apps/server/src/services/export/single.ts index 678fb39e5..16d36807e 100644 --- a/apps/server/src/services/export/single.ts +++ b/apps/server/src/services/export/single.ts @@ -9,8 +9,9 @@ import type TaskContext from "../task_context.js"; import type BBranch from "../../becca/entities/bbranch.js"; import type { Response } from "express"; import type BNote from "../../becca/entities/bnote.js"; +import type { ExportFormat } from "./zip/abstract_provider.js"; -function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response) { +function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response) { const note = branch.getNote(); if (note.type === "image" || note.type === "file") { @@ -33,7 +34,7 @@ function exportSingleNote(taskContext: TaskContext<"export">, branch: BBranch, f taskContext.taskSucceeded(null); } -export function mapByNoteType(note: BNote, content: string | Buffer, format: "html" | "markdown") { +export function mapByNoteType(note: BNote, content: string | Buffer, format: ExportFormat) { let payload, extension, mime; if (typeof content !== "string") { diff --git a/apps/server/src/services/export/zip.ts b/apps/server/src/services/export/zip.ts index 116a841b2..f7ef70bc8 100644 --- a/apps/server/src/services/export/zip.ts +++ b/apps/server/src/services/export/zip.ts @@ -1,12 +1,9 @@ "use strict"; -import html from "html"; import dateUtils from "../date_utils.js"; import path from "path"; -import mimeTypes from "mime-types"; -import mdService from "./markdown.js"; import packageInfo from "../../../package.json" with { type: "json" }; -import { getContentDisposition, escapeHtml, getResourceDir, isDev } from "../utils.js"; +import { getContentDisposition } from "../utils.js"; import protectedSessionService from "../protected_session.js"; import sanitize from "sanitize-filename"; import fs from "fs"; @@ -18,39 +15,48 @@ import ValidationError from "../../errors/validation_error.js"; import type NoteMeta from "../meta/note_meta.js"; import type AttachmentMeta from "../meta/attachment_meta.js"; import type AttributeMeta from "../meta/attribute_meta.js"; -import type BBranch from "../../becca/entities/bbranch.js"; +import BBranch from "../../becca/entities/bbranch.js"; import type { Response } from "express"; import type { NoteMetaFile } from "../meta/note_meta.js"; +import HtmlExportProvider from "./zip/html.js"; +import { AdvancedExportOptions, type ExportFormat, ZipExportProviderData } from "./zip/abstract_provider.js"; +import MarkdownExportProvider from "./zip/markdown.js"; +import ShareThemeExportProvider from "./zip/share_theme.js"; +import type BNote from "../../becca/entities/bnote.js"; +import { NoteType } from "@triliumnext/commons"; -type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string; - -export interface AdvancedExportOptions { - /** - * If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own template. - */ - skipHtmlTemplate?: boolean; - - /** - * Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type. - * - * @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it. - * @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well. - * @returns a function to rewrite the links in HTML or Markdown notes. - */ - customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn; -} - -async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: "html" | "markdown", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) { - if (!["html", "markdown"].includes(format)) { - throw new ValidationError(`Only 'html' and 'markdown' allowed as export format, '${format}' given`); +async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, format: ExportFormat, res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) { + if (!["html", "markdown", "share"].includes(format)) { + throw new ValidationError(`Only 'html', 'markdown' and 'share' allowed as export format, '${format}' given`); } const archive = archiver("zip", { zlib: { level: 9 } // Sets the compression level. }); + const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks); + const provider = buildProvider(); const noteIdToMeta: Record = {}; + function buildProvider() { + const providerData: ZipExportProviderData = { + getNoteTargetUrl, + archive, + branch, + rewriteFn + }; + switch (format) { + case "html": + return new HtmlExportProvider(providerData); + case "markdown": + return new MarkdownExportProvider(providerData); + case "share": + return new ShareThemeExportProvider(providerData); + default: + throw new Error(); + } + } + function getUniqueFilename(existingFileNames: Record, fileName: string) { const lcFileName = fileName.toLowerCase(); @@ -72,7 +78,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, } } - function getDataFileName(type: string | null, mime: string, baseFileName: string, existingFileNames: Record): string { + function getDataFileName(type: NoteType | null, mime: string, baseFileName: string, existingFileNames: Record): string { let fileName = baseFileName.trim(); if (!fileName) { fileName = "note"; @@ -90,36 +96,14 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, } let existingExtension = path.extname(fileName).toLowerCase(); - let newExtension; - - // the following two are handled specifically since we always want to have these extensions no matter the automatic detection - // and/or existing detected extensions in the note name - if (type === "text" && format === "markdown") { - newExtension = "md"; - } else if (type === "text" && format === "html") { - newExtension = "html"; - } else if (mime === "application/x-javascript" || mime === "text/javascript") { - newExtension = "js"; - } else if (type === "canvas" || mime === "application/json") { - newExtension = "json"; - } else if (existingExtension.length > 0) { - // if the page already has an extension, then we'll just keep it - newExtension = null; - } else { - if (mime?.toLowerCase()?.trim() === "image/jpg") { - newExtension = "jpg"; - } else if (mime?.toLowerCase()?.trim() === "text/mermaid") { - newExtension = "txt"; - } else { - newExtension = mimeTypes.extension(mime) || "dat"; - } - } + const newExtension = provider.mapExtension(type, mime, existingExtension, format); // if the note is already named with the extension (e.g. "image.jpg"), then it's silly to append the exact same extension again if (newExtension && existingExtension !== `.${newExtension.toLowerCase()}`) { fileName += `.${newExtension}`; } + return getUniqueFilename(existingFileNames, fileName); } @@ -145,7 +129,8 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, const notePath = parentMeta.notePath.concat([note.noteId]); if (note.noteId in noteIdToMeta) { - const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${format === "html" ? "html" : "md"}`); + const extension = provider.mapExtension("text", "text/html", "", format); + const fileName = getUniqueFilename(existingFileNames, `${baseFileName}.clone.${extension}`); const meta: NoteMeta = { isClone: true, @@ -155,7 +140,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, prefix: branch.prefix, dataFileName: fileName, type: "text", // export will have text description - format: format + format: (format === "markdown" ? "markdown" : "html") }; return meta; } @@ -185,7 +170,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, taskContext.increaseProgressCount(); if (note.type === "text") { - meta.format = format; + meta.format = (format === "markdown" ? "markdown" : "html"); } noteIdToMeta[note.noteId] = meta as NoteMeta; @@ -194,10 +179,13 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, note.sortChildren(); const childBranches = note.getChildBranches().filter((branch) => branch?.noteId !== "_hidden"); - const available = !note.isProtected || protectedSessionService.isProtectedSessionAvailable(); + let shouldIncludeFile = (!note.isProtected || protectedSessionService.isProtectedSessionAvailable()); + if (format !== "share") { + shouldIncludeFile = shouldIncludeFile && (note.getContent().length > 0 || childBranches.length === 0); + } // if it's a leaf, then we'll export it even if it's empty - if (available && (note.getContent().length > 0 || childBranches.length === 0)) { + if (shouldIncludeFile) { meta.dataFileName = getDataFileName(note.type, note.mime, baseFileName, existingFileNames); } @@ -273,8 +261,6 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, return url; } - const rewriteFn = (zipExportOptions?.customRewriteLinks ? zipExportOptions?.customRewriteLinks(rewriteLinks, getNoteTargetUrl) : rewriteLinks); - function rewriteLinks(content: string, noteMeta: NoteMeta): string { content = content.replace(/src="[^"]*api\/images\/([a-zA-Z0-9_]+)\/[^"]*"/g, (match, targetNoteId) => { const url = getNoteTargetUrl(targetNoteId, noteMeta); @@ -316,53 +302,15 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch, } } - function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer { - if (["html", "markdown"].includes(noteMeta?.format || "")) { + function prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note?: BNote): string | Buffer { + const isText = ["html", "markdown"].includes(noteMeta?.format || ""); + if (isText) { content = content.toString(); - content = rewriteFn(content, noteMeta); } - if (noteMeta.format === "html" && typeof content === "string") { - if (!content.substr(0, 100).toLowerCase().includes(" element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809 - content = ` - - - - - - ${htmlTitle} - - -
-

${htmlTitle}

- -
${content}
-
- -`; - } - - return content.length < 100_000 ? html.prettyPrint(content, { indent_size: 2 }) : content; - } else if (noteMeta.format === "markdown" && typeof content === "string") { - let markdownContent = mdService.toMarkdown(content); - - if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) { - markdownContent = `# ${title}\r -${markdownContent}`; - } - - return markdownContent; - } else { - return content; - } + return content; } function saveNote(noteMeta: NoteMeta, filePathPrefix: string) { @@ -377,7 +325,7 @@ ${markdownContent}`; let content: string | Buffer = `

This is a clone of a note. Go to its primary location.

`; - content = prepareContent(noteMeta.title, content, noteMeta); + content = prepareContent(noteMeta.title, content, noteMeta, undefined); archive.append(content, { name: filePathPrefix + noteMeta.dataFileName }); @@ -393,7 +341,7 @@ ${markdownContent}`; } if (noteMeta.dataFileName) { - const content = prepareContent(noteMeta.title, note.getContent(), noteMeta); + const content = prepareContent(noteMeta.title, note.getContent(), noteMeta, note); archive.append(content, { name: filePathPrefix + noteMeta.dataFileName, @@ -429,138 +377,21 @@ ${markdownContent}`; } } - function saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) { - if (!navigationMeta.dataFileName) { - return; - } - - function saveNavigationInner(meta: NoteMeta) { - let html = "
  • "; - - const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`); - - if (meta.dataFileName && meta.noteId) { - const targetUrl = getNoteTargetUrl(meta.noteId, rootMeta); - - html += `${escapedTitle}`; - } else { - html += escapedTitle; - } - - if (meta.children && meta.children.length > 0) { - html += "
      "; - - for (const child of meta.children) { - html += saveNavigationInner(child); - } - - html += "
    "; - } - - return `${html}
  • `; - } - - const fullHtml = ` - - - - - -
      ${saveNavigationInner(rootMeta)}
    - -`; - const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml; - - archive.append(prettyHtml, { name: navigationMeta.dataFileName }); + const existingFileNames: Record = format === "html" ? { navigation: 0, index: 1 } : {}; + const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames); + if (!rootMeta) { + throw new Error("Unable to create root meta."); } - function saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) { - let firstNonEmptyNote; - let curMeta = rootMeta; + const metaFile: NoteMetaFile = { + formatVersion: 2, + appVersion: packageInfo.version, + files: [rootMeta] + }; - if (!indexMeta.dataFileName) { - return; - } - - while (!firstNonEmptyNote) { - if (curMeta.dataFileName && curMeta.noteId) { - firstNonEmptyNote = getNoteTargetUrl(curMeta.noteId, rootMeta); - } - - if (curMeta.children && curMeta.children.length > 0) { - curMeta = curMeta.children[0]; - } else { - break; - } - } - - const fullHtml = ` - - - - - - - - - -`; - - archive.append(fullHtml, { name: indexMeta.dataFileName }); - } - - function saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) { - if (!cssMeta.dataFileName) { - return; - } - - const cssFile = isDev - ? path.join(__dirname, "../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css") - : path.join(getResourceDir(), "ckeditor5-content.css"); - - archive.append(fs.readFileSync(cssFile, "utf-8"), { name: cssMeta.dataFileName }); - } + provider.prepareMeta(metaFile); try { - const existingFileNames: Record = format === "html" ? { navigation: 0, index: 1 } : {}; - const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames); - if (!rootMeta) { - throw new Error("Unable to create root meta."); - } - - const metaFile: NoteMetaFile = { - formatVersion: 2, - appVersion: packageInfo.version, - files: [rootMeta] - }; - - let navigationMeta: NoteMeta | null = null; - let indexMeta: NoteMeta | null = null; - let cssMeta: NoteMeta | null = null; - - if (format === "html") { - navigationMeta = { - noImport: true, - dataFileName: "navigation.html" - }; - - metaFile.files.push(navigationMeta); - - indexMeta = { - noImport: true, - dataFileName: "index.html" - }; - - metaFile.files.push(indexMeta); - - cssMeta = { - noImport: true, - dataFileName: "style.css" - }; - - metaFile.files.push(cssMeta); - } - for (const noteMeta of Object.values(noteIdToMeta)) { // filter out relations which are not inside this export noteMeta.attributes = (noteMeta.attributes || []).filter((attr) => { @@ -584,34 +415,6 @@ ${markdownContent}`; } return; } - - const metaFileJson = JSON.stringify(metaFile, null, "\t"); - - archive.append(metaFileJson, { name: "!!!meta.json" }); - - saveNote(rootMeta, ""); - - if (format === "html") { - if (!navigationMeta || !indexMeta || !cssMeta) { - throw new Error("Missing meta."); - } - - saveNavigation(rootMeta, navigationMeta); - saveIndex(rootMeta, indexMeta); - saveCss(rootMeta, cssMeta); - } - - const note = branch.getNote(); - const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected() || "note"}.zip`; - - if (setHeaders && "setHeader" in res) { - res.setHeader("Content-Disposition", getContentDisposition(zipFileName)); - res.setHeader("Content-Type", "application/zip"); - } - - archive.pipe(res); - await archive.finalize(); - taskContext.taskSucceeded(null); } catch (e: unknown) { const message = `Export failed with error: ${e instanceof Error ? e.message : String(e)}`; log.error(message); @@ -623,9 +426,30 @@ ${markdownContent}`; res.status(500).send(message); } } + + const metaFileJson = JSON.stringify(metaFile, null, "\t"); + + archive.append(metaFileJson, { name: "!!!meta.json" }); + + saveNote(rootMeta, ""); + + provider.afterDone(rootMeta); + + const note = branch.getNote(); + const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected()}.zip`; + + if (setHeaders && "setHeader" in res) { + res.setHeader("Content-Disposition", getContentDisposition(zipFileName)); + res.setHeader("Content-Type", "application/zip"); + } + + archive.pipe(res); + await archive.finalize(); + + taskContext.taskSucceeded(null); } -async function exportToZipFile(noteId: string, format: "markdown" | "html", zipFilePath: string, zipExportOptions?: AdvancedExportOptions) { +async function exportToZipFile(noteId: string, format: ExportFormat, zipFilePath: string, zipExportOptions?: AdvancedExportOptions) { const fileOutputStream = fs.createWriteStream(zipFilePath); const taskContext = new TaskContext("no-progress-reporting", "export", null); diff --git a/apps/server/src/services/export/zip/abstract_provider.ts b/apps/server/src/services/export/zip/abstract_provider.ts new file mode 100644 index 000000000..c9645a843 --- /dev/null +++ b/apps/server/src/services/export/zip/abstract_provider.ts @@ -0,0 +1,89 @@ +import { Archiver } from "archiver"; +import type { default as NoteMeta, NoteMetaFile } from "../../meta/note_meta.js"; +import type BNote from "../../../becca/entities/bnote.js"; +import type BBranch from "../../../becca/entities/bbranch.js"; +import mimeTypes from "mime-types"; +import { NoteType } from "@triliumnext/commons"; + +type RewriteLinksFn = (content: string, noteMeta: NoteMeta) => string; + +export type ExportFormat = "html" | "markdown" | "share"; + +export interface AdvancedExportOptions { + /** + * If `true`, then only the note's content will be kept. If `false` (default), then each page will have its own template. + */ + skipHtmlTemplate?: boolean; + + /** + * Provides a custom function to rewrite the links found in HTML or Markdown notes. This method is called for every note imported, if it's of the right type. + * + * @param originalRewriteLinks the original rewrite links function. Can be used to access the default behaviour without having to reimplement it. + * @param getNoteTargetUrl the method to obtain a note's target URL, used internally by `originalRewriteLinks` but can be used here as well. + * @returns a function to rewrite the links in HTML or Markdown notes. + */ + customRewriteLinks?: (originalRewriteLinks: RewriteLinksFn, getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null) => RewriteLinksFn; +} + +export interface ZipExportProviderData { + branch: BBranch; + getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null; + archive: Archiver; + zipExportOptions?: AdvancedExportOptions; + rewriteFn: RewriteLinksFn; +} + +export abstract class ZipExportProvider { + branch: BBranch; + getNoteTargetUrl: (targetNoteId: string, sourceMeta: NoteMeta) => string | null; + archive: Archiver; + zipExportOptions?: AdvancedExportOptions; + rewriteFn: RewriteLinksFn; + + constructor(data: ZipExportProviderData) { + this.branch = data.branch; + this.getNoteTargetUrl = data.getNoteTargetUrl; + this.archive = data.archive; + this.zipExportOptions = data.zipExportOptions; + this.rewriteFn = data.rewriteFn; + } + + abstract prepareMeta(metaFile: NoteMetaFile): void; + abstract prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer; + abstract afterDone(rootMeta: NoteMeta): void; + + /** + * Determines the extension of the resulting file for a specific note type. + * + * @param type the type of the note. + * @param mime the mime type of the note. + * @param existingExtension the existing extension, including the leading period character. + * @param format the format requested for export (e.g. HTML, Markdown). + * @returns an extension *without* the leading period character, or `null` to preserve the existing extension instead. + */ + mapExtension(type: NoteType | null, mime: string, existingExtension: string, format: ExportFormat) { + // the following two are handled specifically since we always want to have these extensions no matter the automatic detection + // and/or existing detected extensions in the note name + if (type === "text" && format === "markdown") { + return "md"; + } else if (type === "text" && format === "html") { + return "html"; + } else if (mime === "application/x-javascript" || mime === "text/javascript") { + return "js"; + } else if (type === "canvas" || mime === "application/json") { + return "json"; + } else if (existingExtension.length > 0) { + // if the page already has an extension, then we'll just keep it + return null; + } else { + if (mime?.toLowerCase()?.trim() === "image/jpg") { + return "jpg"; + } else if (mime?.toLowerCase()?.trim() === "text/mermaid") { + return "txt"; + } else { + return mimeTypes.extension(mime) || "dat"; + } + } + } + +} diff --git a/apps/server/src/services/export/zip/html.ts b/apps/server/src/services/export/zip/html.ts new file mode 100644 index 000000000..0cab8193c --- /dev/null +++ b/apps/server/src/services/export/zip/html.ts @@ -0,0 +1,176 @@ +import type NoteMeta from "../../meta/note_meta.js"; +import { escapeHtml, getResourceDir, isDev } from "../../utils"; +import html from "html"; +import { ZipExportProvider } from "./abstract_provider.js"; +import path from "path"; +import fs from "fs"; + +export default class HtmlExportProvider extends ZipExportProvider { + + private navigationMeta: NoteMeta | null = null; + private indexMeta: NoteMeta | null = null; + private cssMeta: NoteMeta | null = null; + + prepareMeta(metaFile) { + this.navigationMeta = { + noImport: true, + dataFileName: "navigation.html" + }; + metaFile.files.push(this.navigationMeta); + + this.indexMeta = { + noImport: true, + dataFileName: "index.html" + }; + metaFile.files.push(this.indexMeta); + + this.cssMeta = { + noImport: true, + dataFileName: "style.css" + }; + metaFile.files.push(this.cssMeta); + } + + prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer { + if (noteMeta.format === "html" && typeof content === "string") { + if (!content.substr(0, 100).toLowerCase().includes(" element will make sure external links are openable - https://github.com/zadam/trilium/issues/1289#issuecomment-704066809 + content = ` + + + + + + ${htmlTitle} + + +
    +

    ${htmlTitle}

    + +
    ${content}
    +
    + +`; + } + + if (content.length < 100_000) { + content = html.prettyPrint(content, { indent_size: 2 }) + } + content = this.rewriteFn(content as string, noteMeta); + return content; + } else { + return content; + } + } + + afterDone(rootMeta: NoteMeta) { + if (!this.navigationMeta || !this.indexMeta || !this.cssMeta) { + throw new Error("Missing meta."); + } + + this.#saveNavigation(rootMeta, this.navigationMeta); + this.#saveIndex(rootMeta, this.indexMeta); + this.#saveCss(rootMeta, this.cssMeta); + } + + #saveNavigationInner(rootMeta: NoteMeta, meta: NoteMeta) { + let html = "
  • "; + + const escapedTitle = escapeHtml(`${meta.prefix ? `${meta.prefix} - ` : ""}${meta.title}`); + + if (meta.dataFileName && meta.noteId) { + const targetUrl = this.getNoteTargetUrl(meta.noteId, rootMeta); + + html += `${escapedTitle}`; + } else { + html += escapedTitle; + } + + if (meta.children && meta.children.length > 0) { + html += "
      "; + + for (const child of meta.children) { + html += this.#saveNavigationInner(rootMeta, child); + } + + html += "
    "; + } + + return `${html}
  • `; + } + + #saveNavigation(rootMeta: NoteMeta, navigationMeta: NoteMeta) { + if (!navigationMeta.dataFileName) { + return; + } + + const fullHtml = ` + + + + + +
      ${this.#saveNavigationInner(rootMeta, rootMeta)}
    + + `; + const prettyHtml = fullHtml.length < 100_000 ? html.prettyPrint(fullHtml, { indent_size: 2 }) : fullHtml; + + this.archive.append(prettyHtml, { name: navigationMeta.dataFileName }); + } + + #saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) { + let firstNonEmptyNote; + let curMeta = rootMeta; + + if (!indexMeta.dataFileName) { + return; + } + + while (!firstNonEmptyNote) { + if (curMeta.dataFileName && curMeta.noteId) { + firstNonEmptyNote = this.getNoteTargetUrl(curMeta.noteId, rootMeta); + } + + if (curMeta.children && curMeta.children.length > 0) { + curMeta = curMeta.children[0]; + } else { + break; + } + } + + const fullHtml = ` + + + + + + + + + +`; + + this.archive.append(fullHtml, { name: indexMeta.dataFileName }); + } + + #saveCss(rootMeta: NoteMeta, cssMeta: NoteMeta) { + if (!cssMeta.dataFileName) { + return; + } + + const cssFile = isDev + ? path.join(__dirname, "../../../../../../node_modules/ckeditor5/dist/ckeditor5-content.css") + : path.join(getResourceDir(), "ckeditor5-content.css"); + const cssContent = fs.readFileSync(cssFile, "utf-8"); + this.archive.append(cssContent, { name: cssMeta.dataFileName }); + } + +} + diff --git a/apps/server/src/services/export/zip/markdown.ts b/apps/server/src/services/export/zip/markdown.ts new file mode 100644 index 000000000..827f059d6 --- /dev/null +++ b/apps/server/src/services/export/zip/markdown.ts @@ -0,0 +1,27 @@ +import NoteMeta from "../../meta/note_meta" +import { ZipExportProvider } from "./abstract_provider.js" +import mdService from "../markdown.js"; + +export default class MarkdownExportProvider extends ZipExportProvider { + + prepareMeta() { } + + prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta): string | Buffer { + if (noteMeta.format === "markdown" && typeof content === "string") { + let markdownContent = mdService.toMarkdown(content); + + if (markdownContent.trim().length > 0 && !markdownContent.startsWith("# ")) { + markdownContent = `# ${title}\r +${markdownContent}`; + } + + markdownContent = this.rewriteFn(markdownContent, noteMeta); + return markdownContent; + } else { + return content; + } + } + + afterDone() { } + +} diff --git a/apps/server/src/services/export/zip/share_theme.ts b/apps/server/src/services/export/zip/share_theme.ts new file mode 100644 index 000000000..bf989b06c --- /dev/null +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -0,0 +1,115 @@ +import { join } from "path"; +import NoteMeta, { NoteMetaFile } from "../../meta/note_meta"; +import { ExportFormat, ZipExportProvider } from "./abstract_provider.js"; +import { RESOURCE_DIR } from "../../resource_dir"; +import { getResourceDir, isDev } from "../../utils"; +import fs, { readdirSync } from "fs"; +import { renderNoteForExport } from "../../../share/content_renderer"; +import type BNote from "../../../becca/entities/bnote.js"; +import type BBranch from "../../../becca/entities/bbranch.js"; +import { getShareThemeAssetDir } from "../../../routes/assets"; + +const shareThemeAssetDir = getShareThemeAssetDir(); + +export default class ShareThemeExportProvider extends ZipExportProvider { + + private assetsMeta: NoteMeta[] = []; + private indexMeta: NoteMeta | null = null; + + prepareMeta(metaFile: NoteMetaFile): void { + + const assets = [ + "icon-color.svg" + ]; + + for (const file of readdirSync(shareThemeAssetDir)) { + assets.push(`assets/${file}`); + } + + for (const asset of assets) { + const assetMeta = { + noImport: true, + dataFileName: asset + }; + this.assetsMeta.push(assetMeta); + metaFile.files.push(assetMeta); + } + + this.indexMeta = { + noImport: true, + dataFileName: "index.html" + }; + + metaFile.files.push(this.indexMeta); + } + + prepareContent(title: string, content: string | Buffer, noteMeta: NoteMeta, note: BNote | undefined, branch: BBranch): string | Buffer { + if (!noteMeta?.notePath?.length) { + throw new Error("Missing note path."); + } + const basePath = "../".repeat(noteMeta.notePath.length - 1); + + if (note) { + content = renderNoteForExport(note, branch, basePath, noteMeta.notePath.slice(0, -1)); + if (typeof content === "string") { + content = content.replace(/href="[^"]*\.\/([a-zA-Z0-9_\/]{12})[^"]*"/g, (match, id) => { + if (match.includes("/assets/")) return match; + return `href="#root/${id}"`; + }); + content = this.rewriteFn(content, noteMeta); + } + } + + return content; + } + + afterDone(rootMeta: NoteMeta): void { + this.#saveAssets(rootMeta, this.assetsMeta); + this.#saveIndex(rootMeta); + } + + mapExtension(type: string | null, mime: string, existingExtension: string, format: ExportFormat): string | null { + if (mime.startsWith("image/")) { + return null; + } + + return "html"; + } + + #saveIndex(rootMeta: NoteMeta) { + if (!this.indexMeta?.dataFileName) { + return; + } + + const note = this.branch.getNote(); + const fullHtml = this.prepareContent(rootMeta.title ?? "", note.getContent(), rootMeta, note, this.branch); + this.archive.append(fullHtml, { name: this.indexMeta.dataFileName }); + } + + #saveAssets(rootMeta: NoteMeta, assetsMeta: NoteMeta[]) { + for (const assetMeta of assetsMeta) { + if (!assetMeta.dataFileName) { + continue; + } + + let cssContent = getShareThemeAssets(assetMeta.dataFileName); + this.archive.append(cssContent, { name: assetMeta.dataFileName }); + } + } + +} + +function getShareThemeAssets(nameWithExtension: string) { + let path: string | undefined; + if (nameWithExtension === "icon-color.svg") { + path = join(RESOURCE_DIR, "images", nameWithExtension); + } else if (nameWithExtension.startsWith("assets")) { + path = join(shareThemeAssetDir, nameWithExtension.replace(/^assets\//, "")); + } else if (isDev) { + path = join(getResourceDir(), "..", "..", "client", "dist", "src", nameWithExtension); + } else { + path = join(getResourceDir(), "public", "src", nameWithExtension); + } + + return fs.readFileSync(path); +} diff --git a/apps/server/src/services/meta/note_meta.ts b/apps/server/src/services/meta/note_meta.ts index 33e7a7843..7a7a9f4b7 100644 --- a/apps/server/src/services/meta/note_meta.ts +++ b/apps/server/src/services/meta/note_meta.ts @@ -1,6 +1,7 @@ import type { NoteType } from "@triliumnext/commons"; import type AttachmentMeta from "./attachment_meta.js"; import type AttributeMeta from "./attribute_meta.js"; +import type { ExportFormat } from "../export/zip/abstract_provider.js"; export interface NoteMetaFile { formatVersion: number; @@ -19,7 +20,7 @@ export default interface NoteMeta { type?: NoteType; mime?: string; /** 'html' or 'markdown', applicable to text notes only */ - format?: "html" | "markdown"; + format?: ExportFormat; dataFileName?: string; dirFileName?: string; /** this file should not be imported (e.g., HTML navigation) */ diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 83ddfde2e..3ab219f26 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -1,10 +1,23 @@ -import { parse, HTMLElement, TextNode } from "node-html-parser"; +import { parse, HTMLElement, TextNode, Options } from "node-html-parser"; import shaca from "./shaca/shaca.js"; -import assetPath from "../services/asset_path.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. @@ -16,7 +29,192 @@ export interface Result { isEmpty?: boolean; } -export function getContent(note: SNote) { +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: "", @@ -65,9 +263,12 @@ function renderIndex(result: Result) { result.content += ""; } -function renderText(result: Result, note: SNote) { +function renderText(result: Result, note: SNote | BNote) { if (typeof result.content !== "string") return; - const document = parse(result.content || ""); + const parseOpts: Partial = { + blockTextElements: {} + } + const document = parse(result.content || "", parseOpts); // Process include notes. for (const includeNoteEl of document.querySelectorAll("section.include-note")) { @@ -80,7 +281,7 @@ function renderText(result: Result, note: SNote) { const includedResult = getContent(note); if (typeof includedResult.content !== "string") continue; - const includedDocument = parse(includedResult.content).childNodes; + const includedDocument = parse(includedResult.content, parseOpts).childNodes; if (includedDocument) { includeNoteEl.replaceWith(...includedDocument); } @@ -89,6 +290,7 @@ function renderText(result: Result, note: SNote) { 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"); @@ -102,21 +304,15 @@ function renderText(result: Result, note: SNote) { } } - result.content = document.innerHTML ?? ""; - - if (result.content.includes(``)) { - result.header += ` - - - - -`; + // 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); } @@ -174,7 +370,7 @@ export function renderCode(result: Result) { } } -function renderMermaid(result: Result, note: SNote) { +function renderMermaid(result: Result, note: SNote | BNote) { if (typeof result.content !== "string") { return; } @@ -188,11 +384,11 @@ function renderMermaid(result: Result, note: SNote) { `; } -function renderImage(result: Result, note: SNote) { +function renderImage(result: Result, note: SNote | BNote) { result.content = ``; } -function renderFile(note: SNote, result: Result) { +function renderFile(note: SNote | BNote, result: Result) { if (note.mime === "application/pdf") { result.content = ``; } else { diff --git a/apps/server/src/share/routes.ts b/apps/server/src/share/routes.ts index 77f542ba2..275474c2d 100644 --- a/apps/server/src/share/routes.ts +++ b/apps/server/src/share/routes.ts @@ -4,41 +4,12 @@ import type { Request, Response, Router } from "express"; import shaca from "./shaca/shaca.js"; import shacaLoader from "./shaca/shaca_loader.js"; -import shareRoot from "./share_root.js"; -import contentRenderer from "./content_renderer.js"; -import assetPath, { assetUrlFragment } from "../services/asset_path.js"; -import appPath from "../services/app_path.js"; import searchService from "../services/search/services/search.js"; import SearchContext from "../services/search/search_context.js"; -import log from "../services/log.js"; import type SNote from "./shaca/entities/snote.js"; -import type SBranch from "./shaca/entities/sbranch.js"; import type SAttachment from "./shaca/entities/sattachment.js"; -import utils, { isDev, safeExtractMessageAndStackFromError } from "../services/utils.js"; -import options from "../services/options.js"; -import { t } from "i18next"; -import ejs from "ejs"; -import { join } from "path"; - -function getSharedSubTreeRoot(note: SNote): { note?: SNote; branch?: SBranch } { - if (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 (parentBranch.parentNoteId === shareRoot.SHARE_ROOT_NOTE_ID) { - return { - note, - branch: parentBranch - }; - } - - return getSharedSubTreeRoot(parentBranch.getParentNote()); -} +import { renderNoteContent } from "./content_renderer.js"; +import utils from "../services/utils.js"; function addNoIndexHeader(note: SNote, res: Response) { if (note.isLabelTruthy("shareDisallowRobotIndexing")) { @@ -109,8 +80,7 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri let svgString = ""; const attachment = image.getAttachmentByTitle(attachmentName); if (!attachment) { - res.status(404); - renderDefault(res, "404"); + return; } const content = attachment.getContent(); @@ -138,12 +108,19 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri res.send(svg); } +function render404(res: Response) { + res.status(404); + const shareThemePath = `../../share-theme/templates/404.ejs`; + res.render(shareThemePath); +} + function register(router: Router) { + function renderNote(note: SNote, req: Request, res: Response) { if (!note) { console.log("Unable to find note ", note); res.status(404); - renderDefault(res, "404"); + render404(res); return; } @@ -161,63 +138,7 @@ function register(router: Router) { return; } - const { header, content, isEmpty } = contentRenderer.getContent(note); - const subRoot = getSharedSubTreeRoot(note); - const showLoginInShareTheme = options.getOption("showLoginInShareTheme"); - const opts = { - note, - header, - content, - isEmpty, - subRoot, - assetPath: isDev ? assetPath : `../${assetPath}`, - assetUrlFragment, - appPath: isDev ? appPath : `../${appPath}`, - showLoginInShareTheme, - t, - isDev, - utils - }; - let useDefaultView = true; - - // 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") { - const ejsResult = ejs.render(content, opts, { includer }); - res.send(ejsResult); - useDefaultView = false; // Rendering went okay, don't use default view - } - } catch (e: unknown) { - const [errMessage, errStack] = safeExtractMessageAndStackFromError(e); - log.error(`Rendering user provided share template (${templateId}) threw exception ${errMessage} with stacktrace: ${errStack}`); - } - } - } - - if (useDefaultView) { - renderDefault(res, "page", opts); - } + res.send(renderNoteContent(note)); } router.get("/share/", (req, res) => { @@ -401,14 +322,6 @@ function register(router: Router) { }); } -function renderDefault(res: Response>, template: "page" | "404", opts: any = {}) { - // Path is relative to apps/server/dist/assets/views - const shareThemePath = process.env.NODE_ENV === "development" - ? join(__dirname, `../../../../packages/share-theme/src/templates/${template}.ejs`) - : `../../share-theme/templates/${template}.ejs`; - res.render(shareThemePath, opts); -} - export default { register }; diff --git a/apps/server/src/types.d.ts b/apps/server/src/types.d.ts index c302e27d8..8d6048a76 100644 --- a/apps/server/src/types.d.ts +++ b/apps/server/src/types.d.ts @@ -38,3 +38,9 @@ declare module "@triliumnext/share-theme/styles.css" { const content: string; export default content; } + +declare module '*.css' {} +declare module '*?raw' { + const src: string + export default src +} diff --git a/packages/share-theme/package.json b/packages/share-theme/package.json index a521b66aa..68916cc3e 100644 --- a/packages/share-theme/package.json +++ b/packages/share-theme/package.json @@ -21,6 +21,11 @@ "Zerebos " ], "license": "Apache-2.0", + "dependencies": { + "katex": "0.16.25", + "mermaid": "11.12.0", + "boxicons": "2.1.4" + }, "devDependencies": { "@digitak/esrun": "3.2.26", "@types/swagger-ui": "5.21.1", diff --git a/packages/share-theme/scripts/build.ts b/packages/share-theme/scripts/build.ts index 586005ea5..13b2ac493 100644 --- a/packages/share-theme/scripts/build.ts +++ b/packages/share-theme/scripts/build.ts @@ -1,4 +1,3 @@ -import fs from "node:fs"; import path from "node:path"; // import {fileURLToPath} from "node:url"; @@ -51,15 +50,18 @@ async function runBuild() { await esbuild.build({ entryPoints: entryPoints, bundle: true, + splitting: true, outdir: path.join(rootDir, "dist"), - format: "cjs", + format: "esm", target: ["chrome96"], loader: { ".png": "dataurl", ".gif": "dataurl", - ".woff": "dataurl", - ".woff2": "dataurl", - ".ttf": "dataurl", + ".woff": "file", + ".woff2": "file", + ".ttf": "file", + ".eot": "empty", + ".svg": "empty", ".html": "text", ".css": "css" }, diff --git a/packages/share-theme/src/scripts/index.ts b/packages/share-theme/src/scripts/index.ts index 7b71fcb8f..a809962aa 100644 --- a/packages/share-theme/src/scripts/index.ts +++ b/packages/share-theme/src/scripts/index.ts @@ -3,6 +3,11 @@ import setupExpanders from "./modules/expanders"; import setupMobileMenu from "./modules/mobile"; import setupSearch from "./modules/search"; import setupThemeSelector from "./modules/theme"; +import setupMermaid from "./modules/mermaid"; +import setupMath from "./modules/math"; +import api from "./modules/api"; +import "boxicons/css/boxicons.min.css"; +import "highlight.js/styles/default.css"; function $try unknown>(func: T, ...args: Parameters) { try { @@ -13,8 +18,39 @@ function $try unknown>(func: T, ...args: Paramete } } +Object.assign(window, api); $try(setupThemeSelector); $try(setupToC); $try(setupExpanders); $try(setupMobileMenu); $try(setupSearch); + +function setupTextNote() { + $try(setupMermaid); + $try(setupMath); +} + +document.addEventListener( + "DOMContentLoaded", + () => { + const noteType = determineNoteType(); + + if (noteType === "text") { + setupTextNote(); + } + + const toggleMenuButton = document.getElementById("toggleMenuButton"); + const layout = document.getElementById("layout"); + + if (toggleMenuButton && layout) { + toggleMenuButton.addEventListener("click", () => layout.classList.toggle("showMenu")); + } + }, + false +); + +function determineNoteType() { + const bodyClass = document.body.className; + const match = bodyClass.match(/type-([^\s]+)/); + return match ? match[1] : null; +} diff --git a/packages/share-theme/src/scripts/modules/api.ts b/packages/share-theme/src/scripts/modules/api.ts new file mode 100644 index 000000000..adaca77d1 --- /dev/null +++ b/packages/share-theme/src/scripts/modules/api.ts @@ -0,0 +1,18 @@ +/** + * Fetch note with given ID from backend + * + * @param noteId of the given note to be fetched. If false, fetches current note. + */ +async function fetchNote(noteId: string | null = null) { + if (!noteId) { + noteId = document.body.getAttribute("data-note-id"); + } + + const resp = await fetch(`api/notes/${noteId}`); + + return await resp.json(); +} + +export default { + fetchNote +}; diff --git a/packages/share-theme/src/scripts/modules/math.ts b/packages/share-theme/src/scripts/modules/math.ts new file mode 100644 index 000000000..3893dbeed --- /dev/null +++ b/packages/share-theme/src/scripts/modules/math.ts @@ -0,0 +1,16 @@ +import "katex/dist/katex.min.css"; + +export default async function setupMath() { + const anyMathBlock = document.querySelector("#content .math-tex"); + if (!anyMathBlock) { + return; + } + + const renderMathInElement = (await import("katex/contrib/auto-render")).default; + await import("katex/contrib/mhchem"); + + const contentEl = document.getElementById("content"); + if (!contentEl) return; + renderMathInElement(contentEl); + document.body.classList.add("math-loaded"); +} diff --git a/apps/client/src/share/mermaid.ts b/packages/share-theme/src/scripts/modules/mermaid.ts similarity index 53% rename from apps/client/src/share/mermaid.ts rename to packages/share-theme/src/scripts/modules/mermaid.ts index 123f3816c..78ae5a573 100644 --- a/apps/client/src/share/mermaid.ts +++ b/packages/share-theme/src/scripts/modules/mermaid.ts @@ -1,7 +1,12 @@ -import mermaid from "mermaid"; +export default async function setupMermaid() { + const mermaidEls = document.querySelectorAll("#content pre code.language-mermaid"); + if (mermaidEls.length === 0) { + return; + } -export default function setupMermaid() { - for (const codeBlock of document.querySelectorAll("#content pre code.language-mermaid")) { + const mermaid = (await import("mermaid")).default; + + for (const codeBlock of mermaidEls) { const parentPre = codeBlock.parentElement; if (!parentPre) { continue; diff --git a/packages/share-theme/src/styles/content.css b/packages/share-theme/src/styles/content.css index d500888a6..e67be725a 100644 --- a/packages/share-theme/src/styles/content.css +++ b/packages/share-theme/src/styles/content.css @@ -46,4 +46,8 @@ #content img { max-width: 100%; +} + +body:not(.math-loaded) .math-tex { + visibility: hidden; } \ No newline at end of file diff --git a/packages/share-theme/src/templates/page.ejs b/packages/share-theme/src/templates/page.ejs index 2fd07c8a7..0196bfddc 100644 --- a/packages/share-theme/src/templates/page.ejs +++ b/packages/share-theme/src/templates/page.ejs @@ -30,17 +30,11 @@ api/notes/<%= note.getRelation("shareFavicon").value %>/download<% } else { %>../favicon.ico<% } %>"> - - <% if (!isDev && !note.isLabelTruthy("shareOmitDefaultCss")) { %> - - + <% for (const url of cssToLoad) { %> + <% } %> - - <% for (const cssRelation of note.getRelations("shareCss")) { %> - - <% } %> - <% for (const jsRelation of note.getRelations("shareJs")) { %> - + <% for (const url of jsToLoad) { %> + <% } %> <% if (note.hasLabel("shareDisallowRobotIndexing")) { %> @@ -80,8 +74,6 @@ <%- renderSnippets("head:end") %> <% -const customLogoId = subRoot.note.getRelation("shareLogo")?.value; -const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `../${assetUrlFragment}/images/icon-color.svg`; const logoWidth = subRoot.note.getLabelValue("shareLogoWidth") ?? 53; const logoHeight = subRoot.note.getLabelValue("shareLogoHeight") ?? 40; const mobileLogoHeight = logoHeight && logoWidth ? 32 / (logoWidth / logoHeight) : ""; @@ -131,16 +123,7 @@ content = content.replaceAll(headingRe, (...match) => { <% if (hasTree) { %> <% } %> diff --git a/packages/share-theme/src/templates/tree_item.ejs b/packages/share-theme/src/templates/tree_item.ejs index b033ad2bc..58d04a07b 100644 --- a/packages/share-theme/src/templates/tree_item.ejs +++ b/packages/share-theme/src/templates/tree_item.ejs @@ -1,7 +1,14 @@ <% const linkClass = `type-${note.type}` + (activeNote.noteId === note.noteId ? " active" : ""); const isExternalLink = note.hasLabel("shareExternal"); -const linkHref = isExternalLink ? note.getLabelValue("shareExternal") : `./${note.shareId}`; +let linkHref; + +if (isExternalLink) { + linkHref = note.getLabelValue("shareExternal"); +} else if (note.shareId) { + linkHref = `./${note.shareId}`; +} + const target = isExternalLink ? ` target="_blank" rel="noopener noreferrer"` : ""; %> diff --git a/packages/share-theme/src/types.d.ts b/packages/share-theme/src/types.d.ts new file mode 100644 index 000000000..3fa19bd49 --- /dev/null +++ b/packages/share-theme/src/types.d.ts @@ -0,0 +1,5 @@ +declare module "katex/contrib/auto-render" { + export default function renderMathInElement(elem: HTMLElement, options?: {}) +} + +declare module "katex/contrib/mhchem" {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33a2ec681..3d2016b20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -480,6 +480,9 @@ importers: '@triliumnext/express-partial-content': specifier: workspace:* version: link:../../packages/express-partial-content + '@triliumnext/highlightjs': + specifier: workspace:* + version: link:../../packages/highlightjs '@triliumnext/turndown-plugin-gfm': specifier: workspace:* version: link:../../packages/turndown-plugin-gfm @@ -1325,6 +1328,16 @@ importers: version: 1.2.0 packages/share-theme: + dependencies: + boxicons: + specifier: 2.1.4 + version: 2.1.4 + katex: + specifier: 0.16.25 + version: 0.16.25 + mermaid: + specifier: 11.12.1 + version: 11.12.1 devDependencies: '@digitak/esrun': specifier: 3.2.26 @@ -1381,6 +1394,18 @@ importers: specifier: 0.0.3 version: 0.0.3(bufferutil@4.0.9)(utf-8-validate@6.0.5) + packages/uikit: + dependencies: + '@triliumnext/commons': + specifier: workspace:* + version: link:../commons + '@triliumnext/highlightjs': + specifier: workspace:* + version: link:../highlightjs + jquery: + specifier: 3.7.1 + version: 3.7.1 + packages: '@ampproject/remapping@2.3.0': @@ -14856,8 +14881,6 @@ snapshots: '@ckeditor/ckeditor5-core': 47.1.0 '@ckeditor/ckeditor5-upload': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-ai@47.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)': dependencies: @@ -15067,8 +15090,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.1.0 '@ckeditor/ckeditor5-watchdog': 47.1.0 es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-dev-build-tools@43.1.0(@swc/helpers@0.5.17)(tslib@2.8.1)(typescript@5.9.3)': dependencies: @@ -15251,8 +15272,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) es-toolkit: 1.39.5 - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-editor-inline@47.1.0': dependencies: @@ -15433,8 +15452,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.1.0 '@ckeditor/ckeditor5-widget': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-html-embed@47.1.0': dependencies: @@ -15725,8 +15742,6 @@ snapshots: '@ckeditor/ckeditor5-paste-from-office': 47.1.0 '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-paste-from-office@47.1.0': dependencies: @@ -15734,8 +15749,6 @@ snapshots: '@ckeditor/ckeditor5-core': 47.1.0 '@ckeditor/ckeditor5-engine': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-real-time-collaboration@47.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)': dependencies: @@ -15777,8 +15790,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.1.0 '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-revision-history@47.1.0': dependencies: @@ -19520,7 +19531,7 @@ snapshots: dependencies: '@types/body-parser': 1.19.6 '@types/express-serve-static-core': 5.1.0 - '@types/serve-static': 2.2.0 + '@types/serve-static': 1.15.10 '@types/fs-extra@11.0.4': dependencies: @@ -19721,7 +19732,7 @@ snapshots: '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.4 - '@types/node': 22.18.12 + '@types/node': 24.9.1 '@types/send': 0.17.5 '@types/serve-static@2.2.0':