Initial Commit
This commit is contained in:
@@ -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
Reference in New Issue
Block a user