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
+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;
});
})();