// // 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 `
Rendering diagram…
\n`; } return defaultFence(tokens, idx, options, env, self); }; function escapeAttribute(text) { return String(text) .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; }); })();