Initial Commit
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
//
|
||||
// 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,
|
||||
};
|
||||
Reference in New Issue
Block a user