// // preview.js — where the generated HTML lives and how it gets in front of you. // // Nova has no API for contributing a previewer, so this extension does not try // to be one. It writes an ordinary HTML file inside the workspace's preview root // and opens it; from there Nova's own preview tab does the work, including // reloading the page when the file underneath it changes. // const { conf, log, warn, exists, writeFile, copyFile, removeTree, shortHash } = require("./util.js"); const { buildDocument } = require("./render.js"); const MARKDOWN_EXTENSIONS = ["md", "markdown", "mdown", "mkd", "mdx"]; const MERMAID_EXTENSIONS = ["mmd", "mermaid"]; const VENDOR_FILES = ["markdown-it.min.js", "mermaid.min.js"]; const ASSET_FILES = ["preview.css", "preview.js"]; // Bumped whenever anything in Assets/ changes, so an installed copy from an // older version of the extension gets replaced instead of quietly persisting. const ASSET_VERSION = "1"; class PreviewError extends Error {} /** The directory Nova serves previews from, or null when previewing is unavailable. */ function previewRoot() { return nova.workspace.previewRootPath || nova.workspace.path || null; } /** Absolute path of the directory generated files are written to. */ function outputDirectory() { const root = previewRoot(); if (!root) { throw new PreviewError( "This workspace has no preview root, so there is nowhere to put the generated page. " + "Open a folder as a project, or set a preview root in Project Settings → Preview." ); } const configured = String(conf("mermaidpreview.outputDirectory", ".nova/MermaidPreview")).trim(); const directory = nova.path.isAbsolute(configured) ? configured : nova.path.join(root, configured); // A page outside the preview root is not reachable by the preview server, // which fails as a blank tab rather than an error worth reading. if (!nova.path.join(directory, "/").startsWith(nova.path.join(root, "/"))) { throw new PreviewError( `The generated files directory (${configured}) is outside the preview root, so Nova cannot serve it.` ); } return directory; } /** Describes the file in `editor`, or null when it is not something we preview. */ function describeSource(editor) { if (!editor || !editor.document) return null; const document = editor.document; if (document.isUntitled || document.isRemote || !document.path) return null; const syntax = document.syntax; const extension = (nova.path.extname(document.path) || "").replace(/^\./, "").toLowerCase(); let mode = null; if (syntax === "markdown" || MARKDOWN_EXTENSIONS.includes(extension)) { mode = "markdown"; } else if (syntax === "mermaid" || MERMAID_EXTENSIONS.includes(extension)) { mode = "mermaid"; } if (!mode) return null; return { path: document.path, mode, editor }; } /** Reads the editor's current text, including edits that have not been saved. */ function currentText(editor) { const document = editor.document; return document.getTextInRange(new Range(0, document.length)); } /** Absolute path of the HTML page generated for `sourcePath`. */ function outputPathFor(sourcePath) { const directory = outputDirectory(); const base = nova.path.splitext(nova.path.basename(sourcePath))[0] || "preview"; const slug = base.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "preview"; return nova.path.join(directory, `${slug}-${shortHash(sourcePath)}.html`); } /** * Copies the stylesheet, the page script and the two vendored bundles into the * output directory. They are served alongside the page rather than inlined, so * regenerating on every keystroke stays a few-kilobyte write. */ function installAssets(directory) { const assets = nova.path.join(directory, "assets"); const stamp = nova.path.join(assets, ".version"); let installed = null; if (exists(stamp)) { const file = nova.fs.open(stamp, "r"); try { installed = (file.read() || "").trim(); } finally { file.close(); } } const complete = installed === ASSET_VERSION && ASSET_FILES.every((name) => exists(nova.path.join(assets, name))) && VENDOR_FILES.every((name) => exists(nova.path.join(assets, "vendor", name))); if (complete) return "assets"; log(`Installing preview assets into ${assets}`); for (const name of ASSET_FILES) { copyFile(nova.path.join(nova.extension.path, "Assets", name), nova.path.join(assets, name)); } for (const name of VENDOR_FILES) { copyFile( nova.path.join(nova.extension.path, "Assets", "vendor", name), nova.path.join(assets, "vendor", name) ); } writeFile(stamp, ASSET_VERSION); return "assets"; } /** * Regenerates the page for `source`. Returns the path of the generated file. */ function generate(source) { const directory = outputDirectory(); const assetsRelDir = installAssets(directory); const outputPath = outputPathFor(source.path); const sourceRelDir = nova.path.relative(nova.path.dirname(outputPath), nova.path.dirname(source.path)); const html = buildDocument({ markdown: currentText(source.editor), title: nova.path.basename(source.path), sourcePath: source.path, sourceRelDir: sourceRelDir ? `${sourceRelDir}/` : "./", assetsRelDir, mode: source.mode, theme: String(conf("mermaidpreview.theme", "auto")), contentWidth: Number(conf("mermaidpreview.contentWidth", 860)), }); writeFile(outputPath, html); return outputPath; } /** * Regenerates the page for `source` and opens it, ready to switch to Preview. * Resolves to the generated path and the editor Nova opened it in, which is * null when Nova opened the file straight into its preview state. */ async function open(source) { const outputPath = generate(source); const editor = await nova.workspace.openFile(outputPath); return { outputPath, editor: editor || null }; } /** The editor `path` is open in, or null. */ function editorFor(path) { return nova.workspace.textEditors.find((editor) => editor.document && editor.document.path === path) || null; } /** True while `path` is still open somewhere in the workspace. */ function isOpen(path) { return editorFor(path) !== null; } /** * Deletes one generated page, and the assets with it once it was the last page * left — closing every preview should leave the project as it was found. */ function discard(outputPath) { if (exists(outputPath)) nova.fs.remove(outputPath); let directory; try { directory = outputDirectory(); } catch (err) { return; } if (!exists(directory)) return; const pages = nova.fs.listdir(directory).filter((name) => name.endsWith(".html")); if (pages.length === 0) removeTree(directory); } /** Deletes every generated file, assets included. */ function clean() { const directory = outputDirectory(); if (!exists(directory)) return false; removeTree(directory); return true; } module.exports = { PreviewError, describeSource, generate, open, editorFor, isOpen, discard, clean, outputDirectory, outputPathFor, };