214 lines
7.2 KiB
JavaScript
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,
|
|
};
|