diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 6d3ad07a29..dd02af8ef4 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -110,7 +110,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": { "fullDocumentation": "Help (full documentation is available online)", @@ -1197,7 +1198,6 @@ "restore_provider": "Restore provider to search", "similarity_threshold": "Similarity Threshold", "similarity_threshold_description": "Minimum similarity score (0-1) for notes to be included in context for LLM queries", - "reprocess_index": "Rebuild Search Index", "reprocessing_index": "Rebuilding...", "reprocess_index_started": "Search index optimization started in the background", diff --git a/apps/client/src/widgets/dialogs/export.ts b/apps/client/src/widgets/dialogs/export.ts index d9b13f4edc..edf9a80ddc 100644 --- a/apps/client/src/widgets/dialogs/export.ts +++ b/apps/client/src/widgets/dialogs/export.ts @@ -85,6 +85,13 @@ const TPL = /*html*/` + +
+ +
diff --git a/apps/edit-docs/src/edit-docs.ts b/apps/edit-docs/src/edit-docs.ts index 940f895400..b6a04969fa 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/src/becca/entities/bbranch.ts b/apps/server/src/becca/entities/bbranch.ts index 00e3ec4b75..b31cadd71c 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 419c9bdfe4..e6563f2dbc 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; + } + } export default BNote; diff --git a/apps/server/src/etapi/notes.ts b/apps/server/src/etapi/notes.ts index 973ec04afe..941d095663 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) => { @@ -147,7 +148,7 @@ 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)) { + 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) or 'markdown'.`); } @@ -157,7 +158,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 7433cd5525..b2909f2881 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"); 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/services/export/single.ts b/apps/server/src/services/export/single.ts index b626bf9193..2748c88506 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, branch: BBranch, format: "html" | "markdown", res: Response) { +function exportSingleNote(taskContext: TaskContext, 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, branch: BBranch, format: "ht taskContext.taskSucceeded(); } -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 81c67a21b0..58df003afe 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 } from "../utils.js"; +import { getContentDisposition } from "../utils.js"; import protectedSessionService from "../protected_session.js"; import sanitize from "sanitize-filename"; import fs from "fs"; @@ -18,40 +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 cssContent from "@triliumnext/ckeditor5/content.css"; - -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, branch: BBranch, format: "html" | "markdown", res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) { - if (!["html", "markdown"].includes(format)) { +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"; + +async function exportToZip(taskContext: TaskContext, branch: BBranch, format: ExportFormat, res: Response | fs.WriteStream, setHeaders = true, zipExportOptions?: AdvancedExportOptions) { + if (!["html", "markdown", "share"].includes(format)) { throw new ValidationError(`Only 'html' and 'markdown' 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(); @@ -73,7 +78,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h } } - 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(); // Crop fileName to avoid its length exceeding 30 and prevent cutting into the extension. @@ -88,36 +93,14 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h } 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); } @@ -143,7 +126,8 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h 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, @@ -153,7 +137,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h prefix: branch.prefix, dataFileName: fileName, type: "text", // export will have text description - format: format + format: (format === "markdown" ? "markdown" : "html") }; return meta; } @@ -183,7 +167,7 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h taskContext.increaseProgressCount(); if (note.type === "text") { - meta.format = format; + meta.format = (format === "markdown" ? "markdown" : "html"); } noteIdToMeta[note.noteId] = meta as NoteMeta; @@ -192,10 +176,13 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h 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); } @@ -271,8 +258,6 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h 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); @@ -314,53 +299,15 @@ async function exportToZip(taskContext: TaskContext, branch: BBranch, format: "h } } - 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}`; - } + content = provider.prepareContent(title, content, noteMeta, note, branch); - return markdownContent; - } else { - return content; - } + return content; } function saveNote(noteMeta: NoteMeta, filePathPrefix: string) { @@ -375,7 +322,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 }); @@ -391,7 +338,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, @@ -427,94 +374,6 @@ ${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 }); - } - - function saveIndex(rootMeta: NoteMeta, indexMeta: NoteMeta) { - let firstNonEmptyNote; - let curMeta = 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; - } - - archive.append(cssContent, { name: cssMeta.dataFileName }); - } - const existingFileNames: Record = format === "html" ? { navigation: 0, index: 1 } : {}; const rootMeta = createNoteMeta(branch, { notePath: [] }, existingFileNames); if (!rootMeta) { @@ -527,32 +386,7 @@ ${markdownContent}`; 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); - } + provider.prepareMeta(metaFile); for (const noteMeta of Object.values(noteIdToMeta)) { // filter out relations which are not inside this export @@ -584,15 +418,7 @@ ${markdownContent}`; saveNote(rootMeta, ""); - if (format === "html") { - if (!navigationMeta || !indexMeta || !cssMeta) { - throw new Error("Missing meta."); - } - - saveNavigation(rootMeta, navigationMeta); - saveIndex(rootMeta, indexMeta); - saveCss(rootMeta, cssMeta); - } + provider.afterDone(rootMeta); const note = branch.getNote(); const zipFileName = `${branch.prefix ? `${branch.prefix} - ` : ""}${note.getTitleOrProtected()}.zip`; @@ -608,7 +434,7 @@ ${markdownContent}`; taskContext.taskSucceeded(); } -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"); 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 0000000000..c9645a8437 --- /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 0000000000..8eb5c5d93c --- /dev/null +++ b/apps/server/src/services/export/zip/html.ts @@ -0,0 +1,171 @@ +import type NoteMeta from "../../meta/note_meta.js"; +import { escapeHtml } from "../../utils"; +import cssContent from "@triliumnext/ckeditor5/content.css"; +import html from "html"; +import { ZipExportProvider } from "./abstract_provider.js"; + +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; + } + + 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 0000000000..827f059d68 --- /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 0000000000..06609b0313 --- /dev/null +++ b/apps/server/src/services/export/zip/share_theme.ts @@ -0,0 +1,117 @@ +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 from "fs"; +import { renderNoteForExport } from "../../../share/content_renderer"; +import type BNote from "../../../becca/entities/bnote.js"; +import type BBranch from "../../../becca/entities/bbranch.js"; + +export default class ShareThemeExportProvider extends ZipExportProvider { + + private assetsMeta: NoteMeta[] = []; + private indexMeta: NoteMeta | null = null; + + prepareMeta(metaFile: NoteMetaFile): void { + const assets = [ + "style.css", + "script.js", + "boxicons.css", + "boxicons.eot", + "boxicons.woff2", + "boxicons.woff", + "boxicons.ttf", + "boxicons.svg", + "icon-color.svg" + ]; + + 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, "href=\"#root/$1\""); + 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) { + // Rename share.css to style.css. + if (nameWithExtension === "style.css") { + nameWithExtension = "share.css"; + } else if (nameWithExtension === "script.js") { + nameWithExtension = "share.js"; + } + + let path: string | undefined; + if (nameWithExtension === "icon-color.svg") { + path = join(RESOURCE_DIR, "images", nameWithExtension); + } 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 33e7a7843a..7a7a9f4b7c 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 c1c16e48d0..167bdb7163 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -1,10 +1,24 @@ import { JSDOM } from "jsdom"; 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 { getResourceDir, isDev, safeExtractMessageAndStackFromError } from "../services/utils.js"; +import app_path from "../services/app_path.js"; +import ejs from "ejs"; +import log from "../services/log.js"; +import { join } from "path"; +import { readFileSync } from "fs"; + +const shareAdjustedAssetPath = isDev ? assetPath : `../${assetPath}`; +const shareAdjustedAppPath = isDev ? app_path : `../${app_path}`; +const templateCache: Map = new Map(); /** * Represents the output of the content renderer. @@ -16,7 +30,185 @@ export interface Result { isEmpty?: boolean; } -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}style.css`, + `${basePath}boxicons.css` + ], + jsToLoad: [ + `${basePath}script.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 (!isDev && !note.isLabelTruthy("shareOmitDefaultCss")) { + cssToLoad.push(`${shareAdjustedAssetPath}/src/share.css`); + cssToLoad.push(`${shareAdjustedAssetPath}/src/boxicons.css`); + } + for (const cssRelation of note.getRelations("shareCss")) { + cssToLoad.push(`api/notes/${cssRelation.value}/download`); + } + + // Determine JS to load. + const jsToLoad: string[] = [ + `${shareAdjustedAppPath}/share.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, + appPath: shareAdjustedAppPath, + showLoginInShareTheme, + t, + isDev, + ...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 = join(getResourceDir(), "share-theme", "templates", "page.ejs"); + return ejs.render(readTemplate(templatePath), opts, { + includer: (path) => { + const templatePath = join(getResourceDir(), "share-theme", "templates", `${path}.ejs`); + return { template: readTemplate(templatePath) }; + } + }); +} + +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; +} + +function getContent(note: SNote | BNote) { if (note.isProtected) { return { header: "", @@ -65,7 +257,7 @@ function renderIndex(result: Result) { result.content += ""; } -function renderText(result: Result, note: SNote) { +function renderText(result: Result, note: SNote | BNote) { const document = new JSDOM(result.content || "").window.document; result.isEmpty = document.body.textContent?.trim().length === 0 && document.querySelectorAll("img").length === 0; @@ -158,7 +350,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; } @@ -172,11 +364,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 7e18ca505b..ceaeedb1bb 100644 --- a/apps/server/src/share/routes.ts +++ b/apps/server/src/share/routes.ts @@ -4,40 +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"; - -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 utils from "../services/utils.js"; +import { renderNoteContent } from "./content_renderer.js"; function addNoIndexHeader(note: SNote, res: Response) { if (note.isLabelTruthy("shareDisallowRobotIndexing")) { @@ -108,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(); @@ -137,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; } @@ -160,62 +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 - }; - 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) => { @@ -399,12 +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 = `../../share-theme/templates/${template}.ejs`; - res.render(shareThemePath, opts); -} - export default { register }; diff --git a/packages/share-theme/src/templates/page.ejs b/packages/share-theme/src/templates/page.ejs index 5c39051ebf..10a6c474a7 100644 --- a/packages/share-theme/src/templates/page.ejs +++ b/packages/share-theme/src/templates/page.ejs @@ -6,17 +6,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")) { %> @@ -55,8 +49,6 @@ <% -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) : ""; @@ -105,16 +97,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 b033ad2bc1..99da978fa6 100644 --- a/packages/share-theme/src/templates/tree_item.ejs +++ b/packages/share-theme/src/templates/tree_item.ejs @@ -1,7 +1,16 @@ <% 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}`; +} else { + linkHref = `#${note.getBestNotePath().join("/")}`; +} + const target = isExternalLink ? ` target="_blank" rel="noopener noreferrer"` : ""; %>