Initial Commit
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
.DS_Store
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
});
|
||||||
|
})();
|
||||||
Vendored
+3
File diff suppressed because one or more lines are too long
Vendored
+3636
File diff suppressed because one or more lines are too long
@@ -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.
|
||||||
@@ -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
@@ -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();
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -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."
|
||||||
Reference in New Issue
Block a user