Initial Commit

This commit is contained in:
2026-08-20 22:51:22 -03:00
commit 8a597e19a8
13 changed files with 5055 additions and 0 deletions
+254
View File
@@ -0,0 +1,254 @@
//
// main.js — extension entry point.
//
// The commands are thin: work out which file the preview is for, regenerate its
// page, and keep regenerating it while you edit. Everything about rendering
// lives in render.js and the page assets; everything about where files go lives
// in preview.js.
//
// A preview is tracked from the moment it is opened until either tab involved
// closes, at which point the generated page is thrown away again.
//
const { conf, log, warn } = require("./util.js");
const preview = require("./preview.js");
const EXPLAINED_KEY = "mermaidpreview.didExplainPreviewTab";
// Toggling a tab between source and preview may close and reopen the editor
// behind it. Cleanup waits this long and looks again, so that switching a page
// into Preview is not mistaken for closing it.
const CLEANUP_GRACE_MS = 2500;
class Controller {
constructor() {
// sourcePath -> { source, outputPath, disposables, cleanupTimer }
this.previews = new Map();
this.disposables = new CompositeDisposable();
}
// -- lifecycle ---------------------------------------------------------
activate() {
this.disposables.add(
nova.commands.register("mermaidpreview.preview", () => this.previewCommand())
);
this.disposables.add(
nova.commands.register("mermaidpreview.refresh", () => this.refreshCommand())
);
this.disposables.add(nova.commands.register("mermaidpreview.clean", () => this.cleanCommand()));
// Switching between "on save" and "as you type" changes which editor
// event we listen to, so the watchers have to be rebuilt.
this.disposables.add(
nova.config.onDidChange("mermaidpreview.updateMode", () => this.rewireWatchers())
);
// A regenerated page has to be rewritten for the new theme or width;
// the preview tab reloads itself once the file changes.
for (const key of ["mermaidpreview.theme", "mermaidpreview.contentWidth"]) {
this.disposables.add(nova.config.onDidChange(key, () => this.regenerateAll()));
}
}
deactivate() {
// Quitting Nova closes the previews too, so leave nothing behind.
for (const entry of Array.from(this.previews.values())) {
this.forget(entry, this.cleanUpOnClose());
}
this.disposables.dispose();
}
cleanUpOnClose() {
return conf("mermaidpreview.cleanUpOnClose", true) !== false;
}
// -- commands ----------------------------------------------------------
async previewCommand() {
const source = preview.describeSource(nova.workspace.activeTextEditor);
if (!source) {
nova.workspace.showErrorMessage(
"Open a Markdown (.md) or Mermaid (.mmd) file and try again. Unsaved files have no " +
"location on disk, so there is nothing to resolve their images and links against."
);
return;
}
let opened;
try {
opened = await preview.open(source);
} catch (err) {
this.report(err);
return;
}
this.watch(source, opened.outputPath, opened.editor);
this.explainPreviewTabOnce();
}
refreshCommand() {
const source = preview.describeSource(nova.workspace.activeTextEditor);
if (source && this.previews.has(source.path)) {
this.regenerate(this.previews.get(source.path).source);
return;
}
if (source) {
this.previewCommand();
return;
}
this.regenerateAll();
}
async cleanCommand() {
let directory;
try {
directory = preview.outputDirectory();
} catch (err) {
this.report(err);
return;
}
const confirmed = await new Promise((resolve) => {
nova.workspace.showActionPanel(
`Remove every page generated by Mermaid Preview, along with its assets?\n\n${directory}`,
{ buttons: ["Remove", "Cancel"] },
(index) => resolve(index === 0)
);
});
if (!confirmed) return;
try {
const removed = preview.clean();
for (const entry of Array.from(this.previews.values())) this.forget(entry, false);
nova.workspace.showInformativeMessage(
removed ? "Generated preview files removed." : "There was nothing to remove."
);
} catch (err) {
this.report(err);
}
}
// -- keeping the page current -----------------------------------------
/**
* Watches both ends of a preview: the Markdown file, so the page follows
* your edits, and the generated page, so closing it cleans up.
*/
watch(source, outputPath, previewEditor) {
const existing = this.previews.get(source.path);
if (existing) this.forget(existing, false);
const disposables = new CompositeDisposable();
const entry = { source, outputPath, disposables, cleanupTimer: null };
this.previews.set(source.path, entry);
const mode = String(conf("mermaidpreview.updateMode", "save"));
if (mode === "live") {
disposables.add(source.editor.onDidStopChanging(() => this.regenerate(source)));
} else if (mode === "save") {
disposables.add(source.editor.onDidSave(() => this.regenerate(source)));
}
// Closing the Markdown file leaves a page nothing will update again.
disposables.add(
source.editor.onDidDestroy(() => this.forget(entry, this.cleanUpOnClose()))
);
// openFile hands back the editor it opened, except when Nova restored the
// tab straight into its preview state; then look it up instead.
const pageEditor = previewEditor || preview.editorFor(outputPath);
if (pageEditor) {
disposables.add(pageEditor.onDidDestroy(() => this.scheduleCleanup(entry)));
}
}
/**
* Deletes the generated page once it has stayed closed. Nova may destroy and
* rebuild the editor when a tab flips between source and preview, so a
* destroyed editor on its own is not proof the tab is gone.
*/
scheduleCleanup(entry) {
if (!this.cleanUpOnClose()) return;
if (entry.cleanupTimer) clearTimeout(entry.cleanupTimer);
entry.cleanupTimer = setTimeout(() => {
entry.cleanupTimer = null;
if (!this.previews.has(entry.source.path)) return;
if (preview.isOpen(entry.outputPath)) return;
this.forget(entry, true);
}, CLEANUP_GRACE_MS);
}
/** Stops tracking a preview, optionally deleting the page it generated. */
forget(entry, deletePage) {
if (entry.cleanupTimer) {
clearTimeout(entry.cleanupTimer);
entry.cleanupTimer = null;
}
entry.disposables.dispose();
this.previews.delete(entry.source.path);
if (!deletePage) return;
try {
preview.discard(entry.outputPath);
} catch (err) {
warn(`Could not remove ${entry.outputPath}: ${err.message || err}`);
}
}
/** Rebuilds every watcher, used when the update mode changes. */
rewireWatchers() {
for (const entry of Array.from(this.previews.values())) {
this.watch(entry.source, entry.outputPath, null);
}
}
regenerate(source) {
try {
preview.generate(source);
} catch (err) {
// A failure here is a background one — the user asked for nothing —
// so it belongs in the console, not in an alert on every keystroke.
warn(`Could not regenerate the preview for ${source.path}: ${err.message || err}`);
}
}
regenerateAll() {
for (const entry of this.previews.values()) this.regenerate(entry.source);
}
// -- messaging ---------------------------------------------------------
/**
* The generated page opens as an editor tab. Nova remembers per file whether
* it was last shown as source or preview, so this only needs saying once.
*/
explainPreviewTabOnce() {
if (nova.config.get(EXPLAINED_KEY, "boolean")) return;
nova.config.set(EXPLAINED_KEY, true);
nova.workspace.showInformativeMessage(
"The generated page is open. Switch it to Preview once (Editor → Preview, or the preview " +
"button in the tab bar) and Nova will keep showing it that way, refreshing as you edit."
);
}
report(err) {
if (err instanceof preview.PreviewError) {
nova.workspace.showErrorMessage(err.message);
} else {
warn(err);
nova.workspace.showErrorMessage(`Could not build the preview: ${err.message || err}`);
}
}
}
const controller = new Controller();
exports.activate = function () {
controller.activate();
};
exports.deactivate = function () {
controller.deactivate();
};
+213
View File
@@ -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,
};
+79
View File
@@ -0,0 +1,79 @@
//
// render.js — assembles the HTML document Nova's preview tab will load.
//
// Nothing is rendered here. Markdown-to-HTML and Mermaid both need to run in a
// browser, and the preview tab is a real WebView, so this file only builds a
// shell: the Markdown source as data, plus the scripts that turn it into a page.
// That keeps the diagram code where a DOM exists, and keeps regeneration cheap
// — a save rewrites a few kilobytes, not a three-megabyte bundle.
//
/** Escapes a string for embedding in a <script type="application/json"> block. */
function embedJSON(value) {
return JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
function escapeHTML(text) {
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* Builds the preview document.
*
* @param {object} options
* @param {string} options.markdown Source text of the file being previewed.
* @param {string} options.title Tab and document title.
* @param {string} options.sourcePath Absolute path of the source file.
* @param {string} options.sourceRelDir Directory of the source file, relative to
* the generated HTML, so links and images
* in the Markdown still resolve.
* @param {string} options.assetsRelDir Assets directory, relative to the HTML.
* @param {string} options.mode "markdown" or "mermaid".
* @param {string} options.theme "auto", "light" or "dark".
* @param {number} options.contentWidth Max width of the text column, in pixels.
*/
function buildDocument(options) {
const data = {
markdown: options.markdown,
title: options.title,
sourcePath: options.sourcePath,
sourceRelDir: options.sourceRelDir,
mode: options.mode,
theme: options.theme,
generatedAt: Date.now(),
};
const assets = options.assetsRelDir;
return `<!DOCTYPE html>
<html lang="en" data-theme="${escapeHTML(options.theme)}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="generator" content="Mermaid Preview for Nova">
<title>${escapeHTML(options.title)}</title>
<link rel="stylesheet" href="${escapeHTML(assets)}/preview.css">
<style>:root { --content-width: ${Number(options.contentWidth) || 860}px; }</style>
</head>
<body>
<main id="content" class="markdown-body" aria-busy="true"></main>
<div id="error" hidden></div>
<script type="application/json" id="preview-data">${embedJSON(data)}</script>
<script src="${escapeHTML(assets)}/vendor/markdown-it.min.js"></script>
<script src="${escapeHTML(assets)}/vendor/mermaid.min.js"></script>
<script src="${escapeHTML(assets)}/preview.js"></script>
</body>
</html>
`;
}
module.exports = { buildDocument };
+82
View File
@@ -0,0 +1,82 @@
//
// util.js — configuration, logging and the small filesystem helpers the rest
// of the extension leans on.
//
// Nova's fs API is deliberately thin: mkdir is not recursive, copy refuses to
// overwrite, and stat returns null rather than throwing. Everything here exists
// to paper over one of those.
//
/** Reads an extension preference, falling back to `fallback` when unset. */
function conf(key, fallback) {
const value = nova.workspace.config.get(key) ?? nova.config.get(key);
return value === null || value === undefined || value === "" ? fallback : value;
}
function log(...args) {
console.log(...args);
}
function warn(...args) {
console.warn(...args);
}
/** True when a file or directory exists at `path`. */
function exists(path) {
return nova.fs.access(path, nova.fs.F_OK);
}
/** Creates `path` and any missing parents. */
function mkdirp(path) {
if (exists(path)) return;
const parent = nova.path.dirname(path);
if (parent && parent !== path) mkdirp(parent);
nova.fs.mkdir(path);
}
/** Writes `contents` to `path`, replacing whatever was there. */
function writeFile(path, contents) {
mkdirp(nova.path.dirname(path));
const file = nova.fs.open(path, "w");
try {
file.write(contents);
} finally {
file.close();
}
}
/** Copies `src` over `dest`, which nova.fs.copy on its own refuses to do. */
function copyFile(src, dest) {
mkdirp(nova.path.dirname(dest));
if (exists(dest)) nova.fs.remove(dest);
nova.fs.copy(src, dest);
}
/** Removes a directory and everything in it. */
function removeTree(path) {
if (!exists(path)) return;
const stats = nova.fs.stat(path);
if (stats && stats.isDirectory()) {
for (const entry of nova.fs.listdir(path)) {
removeTree(nova.path.join(path, entry));
}
nova.fs.rmdir(path);
} else {
nova.fs.remove(path);
}
}
/**
* FNV-1a, as an 8-character hex string. Used to keep the generated filename for
* two same-named Markdown files in different folders from colliding.
*/
function shortHash(text) {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, "0");
}
module.exports = { conf, log, warn, exists, mkdirp, writeFile, copyFile, removeTree, shortHash };