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
+1
View File
@@ -0,0 +1 @@
.DS_Store
+272
View File
@@ -0,0 +1,272 @@
/*
* preview.css — styling for the generated page.
*
* Three themes, resolved entirely in CSS so the page never flashes the wrong
* one while the scripts load: "auto" follows the system appearance the way
* Nova's own Markdown preview does, "light" and "dark" are fixed.
*/
:root {
--bg: #ffffff;
--fg: #1f2328;
--fg-muted: #59636e;
--border: #d1d9e0;
--border-subtle: #e4e8ec;
--link: #0969da;
--code-bg: #f3f5f7;
--code-fg: #1f2328;
--quote-border: #d1d9e0;
--table-stripe: #f8fafc;
--mark: #fff8c5;
--diagram-bg: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root[data-theme="auto"] {
--bg: #1c1c1e;
--fg: #e6edf3;
--fg-muted: #9198a1;
--border: #3d444d;
--border-subtle: #2c3138;
--link: #67aaff;
--code-bg: #26262a;
--code-fg: #e6edf3;
--quote-border: #3d444d;
--table-stripe: #222226;
--mark: #4a3d00;
--diagram-bg: #1c1c1e;
}
}
:root[data-theme="dark"] {
--bg: #1c1c1e;
--fg: #e6edf3;
--fg-muted: #9198a1;
--border: #3d444d;
--border-subtle: #2c3138;
--link: #67aaff;
--code-bg: #26262a;
--code-fg: #e6edf3;
--quote-border: #3d444d;
--table-stripe: #222226;
--mark: #4a3d00;
--diagram-bg: #1c1c1e;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
padding: 2.5rem 1.75rem 6rem;
background: var(--bg);
color: var(--fg);
font: 16px/1.65 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: 100%;
}
.markdown-body {
max-width: var(--content-width, 860px);
margin: 0 auto;
}
.markdown-body[aria-busy="true"] {
opacity: 0;
}
.markdown-body > *:first-child {
margin-top: 0;
}
/* -- text ---------------------------------------------------------------- */
h1, h2, h3, h4, h5, h6 {
margin: 2rem 0 1rem;
line-height: 1.25;
font-weight: 600;
}
h1 { font-size: 2rem; padding-bottom: .3em; border-bottom: 1px solid var(--border-subtle); }
h2 { font-size: 1.5rem; padding-bottom: .3em; border-bottom: 1px solid var(--border-subtle); }
h3 { font-size: 1.25rem; }
h4 { font-size: 1rem; }
h5 { font-size: .9rem; }
h6 { font-size: .9rem; color: var(--fg-muted); }
p, ul, ol, blockquote, table, pre, figure {
margin: 0 0 1rem;
}
a {
color: var(--link);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* The anchor link that appears beside a heading on hover. */
.heading-anchor {
margin-left: .4rem;
color: var(--fg-muted);
opacity: 0;
font-weight: 400;
text-decoration: none;
}
h1:hover .heading-anchor,
h2:hover .heading-anchor,
h3:hover .heading-anchor,
h4:hover .heading-anchor,
h5:hover .heading-anchor,
h6:hover .heading-anchor {
opacity: 1;
}
strong { font-weight: 600; }
mark { background: var(--mark); color: inherit; }
hr {
height: 1px;
margin: 2rem 0;
border: 0;
background: var(--border);
}
blockquote {
padding: 0 1rem;
border-left: .25rem solid var(--quote-border);
color: var(--fg-muted);
}
blockquote > :last-child { margin-bottom: 0; }
ul, ol { padding-left: 1.75rem; }
li + li { margin-top: .25rem; }
li > ul, li > ol { margin: .25rem 0; }
li.task-item {
list-style: none;
margin-left: -1.35rem;
}
li.task-item input {
margin-right: .45rem;
vertical-align: middle;
}
/* -- code ---------------------------------------------------------------- */
code, kbd, pre, samp {
font-family: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Monaco, "Courier New", monospace;
font-size: .875em;
}
code {
padding: .2em .4em;
border-radius: 6px;
background: var(--code-bg);
color: var(--code-fg);
}
pre {
padding: 1rem;
overflow-x: auto;
border-radius: 8px;
background: var(--code-bg);
color: var(--code-fg);
line-height: 1.5;
}
pre code {
padding: 0;
background: none;
border-radius: 0;
}
/* -- tables and media ---------------------------------------------------- */
.table-scroll {
margin: 0 0 1rem;
overflow-x: auto;
}
table {
margin: 0;
border-collapse: collapse;
border-spacing: 0;
}
th, td {
padding: .5rem .85rem;
border: 1px solid var(--border);
text-align: left;
}
th { font-weight: 600; }
tbody tr:nth-child(2n) { background: var(--table-stripe); }
img {
max-width: 100%;
height: auto;
}
/* -- diagrams ------------------------------------------------------------ */
.mermaid-block {
margin: 0 0 1.25rem;
padding: 1rem;
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: var(--diagram-bg);
text-align: center;
}
.mermaid-block svg {
max-width: 100%;
height: auto;
}
.mermaid-block.is-pending {
min-height: 4rem;
color: var(--fg-muted);
text-align: left;
}
.mermaid-block.is-error {
border-color: #cf222e;
text-align: left;
}
.mermaid-block.is-error .mermaid-error-message {
margin: 0 0 .75rem;
color: #cf222e;
font-weight: 600;
}
.mermaid-block.is-error pre {
margin: 0;
}
/* -- failure ------------------------------------------------------------- */
#error {
max-width: var(--content-width, 860px);
margin: 0 auto;
padding: 1rem 1.25rem;
border: 1px solid #cf222e;
border-radius: 8px;
color: #cf222e;
}
#error pre {
color: var(--fg);
}
+268
View File
@@ -0,0 +1,268 @@
//
// preview.js — runs inside Nova's preview tab.
//
// The extension hands this page the Markdown source and a couple of paths; the
// rendering happens here, where there is a DOM for Mermaid to draw into. Each
// diagram is rendered on its own so that one bad diagram shows its error in
// place instead of taking the document with it.
//
(function () {
"use strict";
const data = JSON.parse(document.getElementById("preview-data").textContent);
const content = document.getElementById("content");
const scrollKey = `mermaid-preview:${location.pathname}`;
// -- theme ------------------------------------------------------------
function resolvedTheme() {
if (data.theme === "light" || data.theme === "dark") return data.theme;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
// -- markdown ---------------------------------------------------------
const md = window.markdownit({
html: true,
linkify: true,
breaks: false,
langPrefix: "language-",
});
// Mermaid blocks are held back as source and rendered after the document is
// in the DOM, because Mermaid measures text to lay a diagram out.
const defaultFence =
md.renderer.rules.fence ||
function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.fence = function (tokens, idx, options, env, self) {
const token = tokens[idx];
const language = (token.info || "").trim().split(/\s+/)[0].toLowerCase();
if (language === "mermaid") {
return `<div class="mermaid-block is-pending" data-source="${escapeAttribute(token.content)}">Rendering diagram…</div>\n`;
}
return defaultFence(tokens, idx, options, env, self);
};
function escapeAttribute(text) {
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** Drops a YAML front matter block, which is metadata rather than content. */
function stripFrontMatter(text) {
const match = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(text);
return match ? text.slice(match[0].length) : text;
}
function sourceToMarkdown() {
if (data.mode === "mermaid") {
// A .mmd file is a diagram, not a document.
return "```mermaid\n" + data.markdown.replace(/```/g, "\\`\\`\\`") + "\n```\n";
}
return stripFrontMatter(data.markdown);
}
// -- post-processing --------------------------------------------------
const ABSOLUTE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i;
/** Rebases one relative reference onto the Markdown file's own folder. */
function rebase(value) {
return data.sourceRelDir + value.replace(/^(?:\.\/)+/, "");
}
/**
* Rewrites relative images and links so they resolve against the Markdown
* file's own folder rather than the folder the generated page sits in.
*/
function resolveRelativePaths(root) {
for (const image of root.querySelectorAll("img[src]")) {
const src = image.getAttribute("src");
if (src && !ABSOLUTE.test(src)) image.setAttribute("src", rebase(src));
}
for (const link of root.querySelectorAll("a[href]")) {
const href = link.getAttribute("href");
if (href && !ABSOLUTE.test(href)) link.setAttribute("href", rebase(href));
}
}
/** Gives headings ids so a table of contents in the document can link to them. */
function addHeadingAnchors(root) {
const used = new Set();
for (const heading of root.querySelectorAll("h1, h2, h3, h4, h5, h6")) {
if (!heading.id) {
const base =
heading.textContent
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-") || "section";
let id = base;
let n = 1;
while (used.has(id)) id = `${base}-${n++}`;
heading.id = id;
}
used.add(heading.id);
const anchor = document.createElement("a");
anchor.className = "heading-anchor";
anchor.href = `#${heading.id}`;
anchor.textContent = "#";
anchor.setAttribute("aria-hidden", "true");
heading.appendChild(anchor);
}
}
/** Turns `- [ ]` and `- [x]` list items into real checkboxes. */
function renderTaskLists(root) {
for (const item of root.querySelectorAll("li")) {
const first = item.firstElementChild && item.firstElementChild.tagName === "P"
? item.firstElementChild
: item;
const match = /^\[( |x|X)\]\s+/.exec(first.textContent || "");
if (!match) continue;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.disabled = true;
// defaultChecked, so the state survives into the serialised DOM.
checkbox.defaultChecked = match[1].toLowerCase() === "x";
first.textContent = first.textContent.slice(match[0].length);
first.insertBefore(checkbox, first.firstChild);
item.classList.add("task-item");
}
}
/** Wraps tables so a wide one scrolls on its own instead of the page. */
function wrapTables(root) {
for (const table of root.querySelectorAll("table")) {
const wrapper = document.createElement("div");
wrapper.className = "table-scroll";
table.parentNode.insertBefore(wrapper, table);
wrapper.appendChild(table);
}
}
// -- diagrams ---------------------------------------------------------
async function renderDiagrams(root) {
const blocks = Array.from(root.querySelectorAll(".mermaid-block"));
if (blocks.length === 0) return;
// Mermaid's own dark theme still draws edge labels on a pale chip, which
// reads as a light box floating on a dark page; point it at the card
// colour the stylesheet is already using instead.
const styles = getComputedStyle(document.documentElement);
const diagramBackground = styles.getPropertyValue("--diagram-bg").trim() || "transparent";
window.mermaid.initialize({
startOnLoad: false,
theme: resolvedTheme() === "dark" ? "dark" : "default",
securityLevel: "strict",
fontFamily: getComputedStyle(document.body).fontFamily,
themeVariables: {
background: diagramBackground,
edgeLabelBackground: diagramBackground,
},
});
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
const source = block.dataset.source || "";
try {
const { svg, bindFunctions } = await window.mermaid.render(
`mermaid-diagram-${i}-${data.generatedAt}`,
source
);
block.innerHTML = svg;
block.classList.remove("is-pending");
if (bindFunctions) bindFunctions(block);
} catch (err) {
// Mermaid appends an error graphic to the body on failure.
const orphan = document.getElementById(`dmermaid-diagram-${i}-${data.generatedAt}`);
if (orphan) orphan.remove();
block.classList.remove("is-pending");
block.classList.add("is-error");
block.innerHTML = "";
const message = document.createElement("p");
message.className = "mermaid-error-message";
message.textContent = `Diagram could not be drawn: ${(err && err.message) || err}`;
const pre = document.createElement("pre");
const code = document.createElement("code");
code.textContent = source;
pre.appendChild(code);
block.append(message, pre);
}
}
}
// -- scroll position --------------------------------------------------
// The preview reloads whenever the extension rewrites this file, which would
// otherwise throw away where you were reading.
function restoreScroll() {
try {
const saved = Number(sessionStorage.getItem(scrollKey));
if (saved > 0) window.scrollTo(0, saved);
} catch (err) {
/* private browsing, or storage disabled */
}
}
function trackScroll() {
let queued = false;
window.addEventListener(
"scroll",
() => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
try {
sessionStorage.setItem(scrollKey, String(window.scrollY));
} catch (err) {
/* ignored */
}
});
},
{ passive: true }
);
}
// -- go ---------------------------------------------------------------
async function main() {
content.innerHTML = md.render(sourceToMarkdown());
resolveRelativePaths(content);
addHeadingAnchors(content);
renderTaskLists(content);
wrapTables(content);
content.removeAttribute("aria-busy");
restoreScroll();
trackScroll();
await renderDiagrams(content);
restoreScroll();
}
main().catch((err) => {
content.removeAttribute("aria-busy");
const box = document.getElementById("error");
box.hidden = false;
box.textContent = `Mermaid Preview could not render this file: ${(err && err.message) || err}`;
throw err;
});
})();
File diff suppressed because one or more lines are too long
+3636
View File
File diff suppressed because one or more lines are too long
+22
View File
@@ -0,0 +1,22 @@
## Version 1.0
Initial release.
Markdown opens as a rendered page in a preview tab, with Mermaid diagrams drawn
inline.
- **Preview Markdown** — renders the frontmost `.md` file and opens the result,
ready to switch to Preview. `.mmd` and `.mermaid` files are previewed as a
single diagram.
- **Mermaid** — every ```` ```mermaid ```` block is drawn where it sits. A
diagram that fails to parse shows its error and its source in place, rather
than taking the rest of the document down with it.
- **Cleans up after itself** — closing the preview tab, or the Markdown file,
deletes the generated page; the last one out takes the shared assets and the
directory with it.
- **Follows your edits** — on save by default, or as you type; the preview tab
reloads itself.
- **Themes** — light, dark, or automatic, matching the system appearance the way
Nova's own Markdown preview does. Diagrams are themed to match.
- **Also rendered** — tables, task lists, front matter (hidden), heading anchors,
and images and links relative to the Markdown file.
+92
View File
@@ -0,0 +1,92 @@
# Mermaid Preview
Preview Markdown as rendered HTML in a Nova preview tab, with Mermaid diagrams
drawn inline.
Nova already previews Markdown, but its renderer is static HTML and CSS — there
is no way to run a diagram library inside it. This extension takes the other
route: it renders your Markdown into an ordinary HTML page inside the workspace
and lets Nova preview *that*, which means anything a web page can do, the
preview can do.
## Using it
Open a Markdown file and choose **Extensions → Mermaid Preview → Preview
Markdown** (⌃⇧M), or right-click in the editor.
The generated page opens as a tab. Switch it to Preview once — **Editor →
Preview**, or the preview button in the tab bar — and Nova remembers that choice
for the file. From then on the tab stays a preview and refreshes itself as the
extension rewrites the page underneath it.
Split the two tabs side by side for the VS Code arrangement: source on the left,
preview on the right.
`.mmd` and `.mermaid` files are previewed as a single diagram.
## What gets rendered
CommonMark plus tables, strikethrough and autolinking, and:
- ```` ```mermaid ```` blocks, drawn as diagrams. Each one renders on its own, so
a diagram that fails to parse shows its error and its source in place instead
of blanking the document.
- Task lists, as real checkboxes.
- Headings, with ids, so a table of contents in the document links correctly.
- Images and links relative to the Markdown file, rewritten so they still
resolve from wherever the generated page lives.
- YAML front matter, hidden.
- Inline HTML, as written.
## Settings
| Setting | Default | |
| --- | --- | --- |
| Update preview | On save | On save, as you type, or only when asked. |
| Remove the generated page when its tab closes | On | Closing the preview, or the Markdown file, deletes the page. The shared assets go with the last one. |
| Theme | Auto | Auto follows the system appearance. Diagrams follow the same choice. |
| Content width | 860 | Maximum width of the text column, in pixels. Diagrams and tables may exceed it. |
| Generated files | `.nova/MermaidPreview` | Where generated pages and assets are written, relative to the preview root. |
**Extensions → Mermaid Preview → Remove Generated Files** deletes the lot.
## How it works, and what that costs
Nova has no API for contributing a previewer — an extension cannot supply the
contents of a preview tab. What it can do is write a file and open it, and Nova's
HTML preview is a real WebView that runs JavaScript. So:
1. The command writes `<preview root>/.nova/MermaidPreview/<name>-<hash>.html`,
containing your Markdown as data plus a few script tags.
2. Alongside it, once per project, go `markdown-it` and `mermaid` and this
extension's own stylesheet and page script (about 3.7 MB, copied only when
missing or out of date).
3. Nova previews that page. Rendering happens in the WebView, where Mermaid has
the DOM it needs to measure text and lay a diagram out.
4. Saving rewrites step 1 only — a couple of kilobytes — and the preview reloads.
5. Closing either tab deletes the page, and closing the last preview takes the
assets and the directory with it.
The consequences worth knowing about:
- **Generated files live in your project.** They have to: Nova's preview server
only serves what is under the preview root. They are deleted again when the
preview closes, but add `.nova/MermaidPreview/` to `.gitignore` anyway — a
crash or a force quit has no chance to tidy up.
- **The preview is a second tab**, not an alternate view of the Markdown file.
Nova's own Markdown preview still exists and is unchanged.
- **No scroll sync.** There is no way to reach into the preview WebView from an
extension. Scroll position does survive a reload.
- **Unsaved and remote files are not previewed** — there is no path to resolve
their images and links against.
## Installing
Run `./install.sh` with Nova quit; it copies the extension into Nova's user
extension directory and registers it. During development, **Extensions →
Activate Project as Extension** is quicker.
## Credits
Bundles [markdown-it](https://github.com/markdown-it/markdown-it) and
[Mermaid](https://mermaid.js.org), both MIT licensed.
+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 };
+83
View File
@@ -0,0 +1,83 @@
{
"identifier": "unsupervised.MermaidPreview",
"name": "Mermaid Preview",
"organization": "Unsupervised",
"description": "Preview Markdown as rendered HTML in a preview tab, with Mermaid diagrams drawn inline.",
"version": "1.0",
"min_runtime": "10",
"categories": ["commands"],
"license": "MIT",
"repository": "https://git.unsupervised.ca/",
"main": "main.js",
"entitlements": {
"filesystem": "readwrite"
},
"commands": {
"extensions": [
{"title": "Preview Markdown", "command": "mermaidpreview.preview", "shortcut": "ctrl-shift-m"},
{"title": "Refresh Preview", "command": "mermaidpreview.refresh"},
{"separator": true},
{"title": "Remove Generated Files", "command": "mermaidpreview.clean"}
],
"editor": [
{
"title": "Preview Markdown",
"command": "mermaidpreview.preview",
"filters": {"syntaxes": ["markdown", "mermaid"]}
}
]
},
"config": [
{
"key": "mermaidpreview.updateMode",
"title": "Update preview",
"description": "When to regenerate the preview after the first time you open it.",
"type": "enum",
"values": [
["save", "On save"],
["live", "As you type"],
["manual", "Only when I ask"]
],
"default": "save"
},
{
"key": "mermaidpreview.cleanUpOnClose",
"title": "Remove the generated page when its tab closes",
"description": "Also removes it when the Markdown file closes, and takes the shared assets with it once the last preview is gone.",
"type": "boolean",
"default": true
},
{
"key": "mermaidpreview.theme",
"title": "Theme",
"description": "Auto follows the system appearance, the same way Nova's own Markdown preview does.",
"type": "enum",
"values": [
["auto", "Auto"],
["light", "Light"],
["dark", "Dark"]
],
"default": "auto"
},
{
"key": "mermaidpreview.contentWidth",
"title": "Content width",
"description": "Maximum width of the rendered text column, in pixels. Diagrams and tables may exceed it.",
"type": "number",
"default": 860
},
{
"key": "mermaidpreview.outputDirectory",
"title": "Generated files",
"description": "Where the generated HTML and its assets are written, relative to the preview root. Must stay inside the preview root so Nova can serve it.",
"type": "string",
"default": ".nova/MermaidPreview",
"placeholder": ".nova/MermaidPreview"
}
]
}
Executable
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
# Install this extension into Nova's user extension directory, so it loads for
# every project without "Activate Project as Extension".
#
# Nova reads installed extensions from
# ~/Library/Application Support/Nova/Extensions/<identifier>/
# and only loads the ones registered in Extensions.plist next to it. Both are
# read at launch, so Nova must be quit while this runs.
#
# Re-run after changing the extension, then restart Nova.
set -eu
ID="unsupervised.MermaidPreview"
SRC="$(cd "$(dirname "$0")" && pwd)"
EXT_DIR="$HOME/Library/Application Support/Nova/Extensions"
DEST="$EXT_DIR/$ID"
PLIST="$EXT_DIR/Extensions.plist"
if pgrep -x Nova >/dev/null 2>&1; then
echo "Quit Nova first — it rereads Extensions.plist only at launch." >&2
exit 1
fi
mkdir -p "$DEST"
rsync -a --delete \
--exclude '.git' --exclude '.DS_Store' --exclude 'install.sh' \
"$SRC/" "$DEST/"
echo "Copied to $DEST"
python3 - "$PLIST" "$ID" <<'PY'
import os, plistlib, sys
path, ident = sys.argv[1], sys.argv[2]
data = {"extensions": []}
if os.path.exists(path):
with open(path, "rb") as f:
data = plistlib.load(f)
extensions = data.setdefault("extensions", [])
if any(e.get("packageName") == ident for e in extensions):
print("Already registered in Extensions.plist")
else:
extensions.append({"enabled": True, "packageName": ident})
with open(path, "wb") as f:
plistlib.dump(data, f, fmt=plistlib.FMT_BINARY)
print("Registered in Extensions.plist")
PY
echo "Done. Start Nova; the extension appears under Extensions > Extension Library > Installed."