Files
Mermaid-Preview-Nova/Scripts/preview.js
T
2026-08-20 22:51:22 -03:00

214 lines
7.2 KiB
JavaScript

//
// 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,
};