Add CLAUDE.md and the test harness

CLAUDE.md records the Nova constraints this extension is built around —
established by reading Nova 14.1's bundle and probing its preview server,
and expensive to re-derive: no previewer extension point, generated pages
must live under the preview root, dot-directories are served, and no API
reaches into a preview WebView. It also flags the one unverified
assumption, that a tab showing Preview still appears in
nova.workspace.textEditors, with its symptom and fix.

Tests/ is the harness that built the extension, kept rather than left in a
scratchpad: a stubbed Nova runtime backed by the real filesystem, node
suites for generation and the four close paths, and a headless-browser
render test that skips itself without a browser. It caught the ./ doubling
in rebased links and checkbox state not surviving serialization.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TueXpMi7eAtgWPuMr4ii52
This commit is contained in:
2026-08-20 23:02:08 -03:00
co-authored by Claude Opus 5
parent 8a597e19a8
commit aebc72b85e
8 changed files with 544 additions and 0 deletions
+1
View File
@@ -1 +1,2 @@
.DS_Store
Tests/.tmp/
+142
View File
@@ -0,0 +1,142 @@
# CLAUDE.md
Context for working on this repository.
## What this is
A Nova extension that previews Markdown as rendered HTML, with Mermaid diagrams
drawn inline. Nova previews Markdown already; the thing it cannot do is run
JavaScript in that preview, which is what diagrams need.
**The extension is not a previewer and cannot be one.** It writes an ordinary
HTML file inside the workspace and opens it, and Nova's own HTML preview — a
WebView that does run JavaScript — renders it. Every awkward part of the design
follows from that one fact.
## Hard constraints (do not re-derive these)
These were established by reading Nova 14.1's bundle and probing its running
preview server. They are facts about Nova, not preferences.
| Constraint | Consequence |
| --- | --- |
| **No preview/previewer extension point.** Manifest keys are `activationEvents`, `clips`, `commands`, `completions`, `entitlements`, `main`, `sidebars`, `syntaxes`; categories are `clips`, `commands`, `completions`, `issues`, `languages`, `sidebars`, `tasks`, `themes` | Cannot supply the contents of a preview tab. Generate a file and open it |
| No webview / HTML UI for extensions; sidebars are **TreeView only** | Rendering can only happen inside Nova's own preview WebView |
| `AssistantsRegistry` covers completions, issues, symbols, colors, tasks only | No hook to intercept how a file is displayed |
| Nova's preview server only serves files **under the preview root** | Generated pages must live inside the workspace. A page outside it fails as a blank tab, not an error |
| The preview server **does** serve dot-directories (`.git/HEAD` returns 200) | `.nova/MermaidPreview/` is a valid home for generated files |
| The preview server renders `.md` to HTML on the fly, with Nova's own stylesheet | A relative link from a generated page to another `.md` file shows Nova's rendering, not raw text |
| Nova's built-in Markdown preview is `NovaPreviewMarkdownRenderer` over discount, styled by `MarkdownAuto/Bright/Dark.css` with a custom-stylesheet preference | A stylesheet cannot run Mermaid. That preference is not a route to this feature |
| No API to reach into a preview WebView | No scroll sync, ever. Do not promise it |
| No API to close a tab, or to ask whether a tab is showing preview | Cleanup infers a close from `onDidDestroy` plus a re-check |
| `nova.workspace.openFile` options are `line` and `column` only | Cannot open a file directly into its preview state |
The runtime is JavaScriptCore, **not Node**: no npm `require`, only relative
`require("./x.js")` between `Scripts/` files. `setTimeout`/`clearTimeout` and
`fetch` exist. Vendored libraries are not required by the extension — they are
copied into the workspace and loaded by the page.
Type definitions worth having open: `npm pack @types/nova-editor-node` (a single
`index.d.ts`, more precise than the website).
## How a preview happens
1. `mermaidpreview.preview` resolves the frontmost editor to a source
(`describeSource`), refusing untitled and remote files — they have no path to
resolve images and links against.
2. `installAssets` copies `Assets/` into `<output>/assets/` if missing or stale,
stamped with `ASSET_VERSION`. **Bump `ASSET_VERSION` in `Scripts/preview.js`
whenever anything in `Assets/` changes**, or installed copies go stale.
3. `buildDocument` writes a ~2 KB shell: the Markdown embedded as JSON, plus
script tags. No rendering happens in the extension.
4. `Assets/preview.js` renders in the WebView — markdown-it, then DOM fix-ups,
then Mermaid one diagram at a time so a bad diagram fails alone.
5. Saving rewrites step 3 only. Nova reloads the preview because the file
changed. Three megabytes are not rewritten per keystroke — keep it that way.
6. Closing either tab discards the page; the last one out removes `assets/` and
the directory.
## Layout
```
extension.json manifest: commands, config, entitlements
Scripts/ runs in Nova (JavaScriptCore)
main.js Controller: lifecycle, commands, watchers, cleanup
preview.js output paths, asset install, generate/discard
render.js builds the HTML shell — no Nova APIs, so node can test it
util.js conf, mkdirp, writeFile, copyFile, removeTree, shortHash
Assets/ copied into the workspace, runs in the WebView
preview.js markdown-it wiring, DOM fix-ups, Mermaid
preview.css three themes, resolved in CSS so the page never flashes
vendor/ markdown-it 14.3.0, mermaid 11.17.0 (both UMD, MIT)
Tests/ node harness + headless-browser render test
install.sh copy into Nova's extension directory, register, restart
```
Two files are called `preview.js`. `Scripts/preview.js` decides *where files go*;
`Assets/preview.js` *renders the page*. They never see each other.
## Conventions
- 4-space indent, CommonJS, no build step, no runtime dependencies.
- Comments explain *why*, especially where something encodes an API limitation.
Several exist to stop a future reader "simplifying" a constraint back into a
bug — the cleanup grace period is the sharpest example.
- User-visible strings are sentence case and plain English.
- Config keys are namespaced `mermaidpreview.*` and read through `util.conf()`,
which prefers the workspace value.
- Vendored libraries are committed, never fetched at runtime. The extension makes
no network requests and holds no network entitlement.
## Testing
`Tests/run.sh` runs everything: syntax, manifest, and three suites.
- **`generate.test.js`** — `Scripts/*.js` against a stubbed `nova` global backed
by the real filesystem (`Tests/nova-stub.js`). Covers generation, asset reuse,
and discard/pruning.
- **`close.test.js`** — drives the whole `Controller` through the stub, including
command invocation and fake editors that can be destroyed. Covers all four
close paths.
- **`render.test.js`** — builds a page through the real `render.js` and dumps the
DOM from headless Chromium, asserting on diagrams, rebased paths, task lists,
headings and both themes. Skips itself if no browser is found; set
`MERMAID_PREVIEW_CHROME` to point at one.
The stub is the highest-value tool here and it already caught real bugs
(`./` doubling in rebased links, checkbox state not surviving serialization).
Extend it rather than testing by hand.
In Nova itself:
```sh
nova extension validate . # manifest + bundle (makes network calls)
nova extension activate . # load into Nova for development
```
Logs appear in Nova's Extension Console.
## State
- Never published. `min_runtime` is `10`; developed against Nova 14.1.
- **Unverified assumption, worth settling first.** Cleanup on tab-close assumes a
tab showing Preview still appears in `nova.workspace.textEditors`. If it does
not, flipping a page into Preview will delete it ~2.5 s later
(`CLEANUP_GRACE_MS` in `Scripts/main.js`). Symptom: the preview stops updating
and goes blank after a save. Fix is small — gate the page-tab cleanup on the
source file being closed too, leaving only the source-close and quit paths.
Recovery for the user is pressing ⌃⇧M again.
- No icon. `Images/extension/` is empty; `extension.png` at the root is what Nova
picks up (see the sibling `claude-nova` extension).
- No commits yet.
## Things deliberately not done
- **Syntax highlighting** in code blocks — another bundle to vendor, and the
page is already 3.5 MB. Add highlight.js to `Assets/vendor/` and a `highlight`
option in the markdown-it constructor if it becomes worth it.
- **Scroll sync** — impossible, see the constraints table.
- **Replacing Nova's built-in `.md` preview** — also impossible. This is a second
tab, and the README says so plainly.
- **Inlining assets into a single self-contained page** — would make every save
a 3.5 MB write.
+77
View File
@@ -0,0 +1,77 @@
const fs = require("fs");
const path = require("path");
const stub = require("./nova-stub.js");
const EXT = path.join(__dirname, "..");
const ROOT = path.join(__dirname, ".tmp", "close");
const OUT = path.join(ROOT, ".nova", "MermaidPreview");
fs.rmSync(ROOT, { recursive: true, force: true });
fs.mkdirSync(path.join(ROOT, "docs"), { recursive: true });
const MD = path.join(ROOT, "docs", "note.md");
fs.writeFileSync(MD, "# Note\n\n```mermaid\ngraph TD\nA-->B\n```\n");
const nova = stub.build({ root: ROOT, extensionPath: EXT });
global.nova = nova;
global.Disposable = stub.Disposable;
global.CompositeDisposable = stub.CompositeDisposable;
global.Range = stub.Range;
require(path.join(EXT, "Scripts", "main.js")).activate();
const results = [];
const check = (name, ok) => results.push([ok ? "PASS" : "FAIL", name]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const pages = () => (fs.existsSync(OUT) ? fs.readdirSync(OUT).filter((f) => f.endsWith(".html")) : []);
function openSource() {
const editor = new stub.FakeEditor(MD, fs.readFileSync(MD, "utf8"));
nova.workspace.textEditors.push(editor);
nova.workspace.activeTextEditor = editor;
return editor;
}
function pageEditor() {
return nova.workspace.textEditors.find((e) => e.document.path.endsWith(".html"));
}
(async () => {
// -- 1. closing the generated page removes it ------------------------
let source = openSource();
await nova.__invoke("mermaidpreview.preview");
check("preview opens a page", pages().length === 1);
const page = pageEditor();
nova.__close(page);
check("page survives the grace period", pages().length === 1);
await sleep(3000);
check("closing the page tab deletes it", pages().length === 0);
check("last page out removes the directory", !fs.existsSync(OUT));
// -- 2. a tab flipping to preview must not count as a close ----------
nova.workspace.textEditors = [];
source = openSource();
await nova.__invoke("mermaidpreview.preview");
const flipping = pageEditor();
flipping.fireDestroy(); // destroyed, but the tab is still open
await sleep(3000);
check("a rebuilt editor is not mistaken for a close", pages().length === 1);
// -- 3. closing the Markdown file cleans up too ----------------------
nova.__close(source);
check("closing the source deletes the page immediately", pages().length === 0);
check("directory removed with it", !fs.existsSync(OUT));
// -- 4. cleanup can be turned off ------------------------------------
nova.workspace.textEditors = [];
nova.config.set("mermaidpreview.cleanUpOnClose", false);
source = openSource();
await nova.__invoke("mermaidpreview.preview");
const kept = pageEditor();
nova.__close(kept);
nova.__close(source);
await sleep(3000);
check("nothing is deleted when the setting is off", pages().length === 1);
for (const [status, name] of results) console.log(`${status} ${name}`);
process.exitCode = results.some(([s]) => s === "FAIL") ? 1 : 0;
})();
+45
View File
@@ -0,0 +1,45 @@
---
title: Front matter should be hidden
---
# Sample Document
Intro paragraph with a [relative link](./other.md), `inline code`, **bold** and a
[TOC link](#a-second-heading).
- [ ] an unchecked task
- [x] a checked task
- a plain item
| Column | Another |
| --- | --- |
| one | two |
| three | four |
```js
const answer = 42;
```
```mermaid
graph TD
A[Start] --> B{Works?}
B -->|yes| C[Ship it]
B -->|no| D[Fix it]
D --> A
```
## A Second Heading
```mermaid
sequenceDiagram
Nova->>Extension: Preview Markdown
Extension->>Disk: write preview.html
Disk-->>Nova: file changed
Nova->>Preview: reload
```
```mermaid
this is not valid mermaid at all {{{
```
![an image](img/logo.png)
+61
View File
@@ -0,0 +1,61 @@
const fs = require("fs");
const path = require("path");
const stub = require("./nova-stub.js");
const EXT = path.join(__dirname, "..");
const ROOT = path.join(__dirname, ".tmp", "generate");
fs.rmSync(ROOT, { recursive: true, force: true });
fs.mkdirSync(path.join(ROOT, "docs"), { recursive: true });
fs.writeFileSync(path.join(ROOT, "docs", "one.md"), "# One\n\n```mermaid\ngraph TD\nA-->B\n```\n");
fs.writeFileSync(path.join(ROOT, "docs", "two.md"), "# Two\n");
const nova = stub.build({ root: ROOT, extensionPath: EXT });
global.nova = nova;
global.Disposable = stub.Disposable;
global.CompositeDisposable = stub.CompositeDisposable;
global.Range = stub.Range;
const preview = require(path.join(EXT, "Scripts", "preview.js"));
const OUT = path.join(ROOT, ".nova", "MermaidPreview");
const results = [];
function check(name, condition) {
results.push([condition ? "PASS" : "FAIL", name]);
}
// -- generate ------------------------------------------------------------
const one = { path: path.join(ROOT, "docs", "one.md"), mode: "markdown",
editor: new stub.FakeEditor(path.join(ROOT, "docs", "one.md"), fs.readFileSync(path.join(ROOT, "docs", "one.md"), "utf8")) };
const two = { path: path.join(ROOT, "docs", "two.md"), mode: "markdown",
editor: new stub.FakeEditor(path.join(ROOT, "docs", "two.md"), fs.readFileSync(path.join(ROOT, "docs", "two.md"), "utf8")) };
const pathOne = preview.generate(one);
check("generate writes a page", fs.existsSync(pathOne));
check("assets installed", fs.existsSync(path.join(OUT, "assets", "vendor", "mermaid.min.js")));
check("stylesheet installed", fs.existsSync(path.join(OUT, "assets", "preview.css")));
check("page references relative assets", fs.readFileSync(pathOne, "utf8").includes('src="assets/vendor/mermaid.min.js"'));
check("page embeds the markdown", fs.readFileSync(pathOne, "utf8").includes("graph TD"));
const mtime = fs.statSync(path.join(OUT, "assets", "preview.css")).mtimeMs;
const pathTwo = preview.generate(two);
check("second page has its own filename", pathOne !== pathTwo && fs.existsSync(pathTwo));
check("assets not reinstalled when current", fs.statSync(path.join(OUT, "assets", "preview.css")).mtimeMs === mtime);
// -- discard -------------------------------------------------------------
preview.discard(pathOne);
check("discard removes that page", !fs.existsSync(pathOne));
check("discard keeps the other page", fs.existsSync(pathTwo));
check("discard keeps assets while a page remains", fs.existsSync(path.join(OUT, "assets", "preview.css")));
preview.discard(pathTwo);
check("last discard removes the directory", !fs.existsSync(OUT));
check("workspace otherwise untouched", fs.existsSync(path.join(ROOT, "docs", "one.md")));
// -- discard is safe to repeat -------------------------------------------
let threw = false;
try { preview.discard(pathTwo); } catch (e) { threw = true; }
check("discarding twice does not throw", !threw);
for (const [status, name] of results) console.log(`${status} ${name}`);
process.exitCode = results.some(([s]) => s === "FAIL") ? 1 : 0;
+107
View File
@@ -0,0 +1,107 @@
// A minimal stand-in for Nova's extension runtime, backed by the real fs, so the
// extension's own Scripts/*.js can be exercised outside Nova.
const fs = require("fs");
const nodePath = require("path");
class Disposable {
constructor(fn) { this.fn = fn; this.disposed = false; }
dispose() { if (!this.disposed) { this.disposed = true; if (this.fn) this.fn(); } }
}
class CompositeDisposable {
constructor() { this.items = []; }
add(d) { this.items.push(d); }
dispose() { for (const d of this.items) d.dispose(); this.items = []; }
}
class Range {
constructor(start, end) { this.start = start; this.end = end; }
}
class FakeEditor {
constructor(path, text) {
this.document = {
path, uri: `file://${path}`, isUntitled: false, isRemote: false,
syntax: path.endsWith(".md") ? "markdown" : "html",
length: text.length,
getTextInRange: () => text,
};
this.handlers = { save: [], stop: [], destroy: [] };
}
onDidSave(cb) { this.handlers.save.push(cb); return new Disposable(); }
onDidStopChanging(cb) { this.handlers.stop.push(cb); return new Disposable(); }
onDidDestroy(cb) { this.handlers.destroy.push(cb); return new Disposable(); }
fireDestroy() { for (const cb of this.handlers.destroy.slice()) cb(this); }
}
function build({ root, extensionPath }) {
const config = new Map();
const commands = new Map();
const configApi = {
get: (k) => (config.has(k) ? config.get(k) : null),
set: (k, v) => config.set(k, v),
onDidChange: () => new Disposable(),
};
const nova = {
extension: { path: extensionPath },
config: configApi,
commands: {
register: (name, cb) => { commands.set(name, cb); return new Disposable(() => commands.delete(name)); },
},
path: {
join: (...a) => nodePath.join(...a),
dirname: (p) => nodePath.dirname(p),
basename: (p) => nodePath.basename(p),
extname: (p) => nodePath.extname(p),
isAbsolute: (p) => nodePath.isAbsolute(p),
relative: (a, b) => nodePath.relative(a, b),
splitext: (p) => { const e = nodePath.extname(p); return [p.slice(0, p.length - e.length), e]; },
},
fs: {
F_OK: fs.constants.F_OK,
access: (p) => fs.existsSync(p),
mkdir: (p) => fs.mkdirSync(p),
copy: (a, b) => fs.copyFileSync(a, b),
remove: (p) => fs.unlinkSync(p),
rmdir: (p) => fs.rmdirSync(p),
listdir: (p) => fs.readdirSync(p),
stat: (p) => { const st = fs.statSync(p); return { isDirectory: () => st.isDirectory() }; },
open: (p, mode) => {
if (mode === "w") {
const chunks = [];
return { write: (s) => chunks.push(s), close: () => fs.writeFileSync(p, chunks.join("")) };
}
const text = fs.readFileSync(p, "utf8");
return { read: () => text, close: () => {} };
},
},
workspace: {
path: root,
previewRootPath: root,
config: { get: () => null, set: () => {} },
textEditors: [],
activeTextEditor: null,
openFile: async (p) => {
let editor = nova.workspace.textEditors.find((e) => e.document.path === p);
if (!editor) {
editor = new FakeEditor(p, require('fs').readFileSync(p, 'utf8'));
nova.workspace.textEditors.push(editor);
}
return editor;
},
showInformativeMessage: (m) => messages.push(["info", m]),
showErrorMessage: (m) => messages.push(["error", m]),
showActionPanel: (m, o, cb) => cb(0),
},
};
const messages = [];
nova.__messages = messages;
nova.__invoke = (name, ...args) => commands.get(name)(...args);
nova.__close = (editor) => {
nova.workspace.textEditors = nova.workspace.textEditors.filter((e) => e !== editor);
editor.fireDestroy();
};
return nova;
}
module.exports = { build, FakeEditor, Disposable, CompositeDisposable, Range };
+96
View File
@@ -0,0 +1,96 @@
// Renders a fixture through the real render.js in a headless browser, because
// everything the page does — Markdown, Mermaid, the DOM fix-ups — only happens
// where there is a DOM. Skips itself when no browser can be found.
const fs = require("fs");
const path = require("path");
const os = require("os");
const { execFileSync } = require("child_process");
const EXT = path.join(__dirname, "..");
const { buildDocument } = require(path.join(EXT, "Scripts", "render.js"));
function findBrowser() {
if (process.env.MERMAID_PREVIEW_CHROME) return process.env.MERMAID_PREVIEW_CHROME;
const cache = path.join(os.homedir(), "Library", "Caches", "ms-playwright");
if (fs.existsSync(cache)) {
for (const entry of fs.readdirSync(cache)) {
const candidate = path.join(cache, entry, "chrome-headless-shell-mac-arm64", "chrome-headless-shell");
if (fs.existsSync(candidate)) return candidate;
}
}
const chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
return fs.existsSync(chrome) ? chrome : null;
}
const browser = findBrowser();
if (!browser) {
console.log("SKIP no headless browser found (set MERMAID_PREVIEW_CHROME to one)");
process.exit(0);
}
const work = path.join(__dirname, ".tmp", "render");
fs.rmSync(work, { recursive: true, force: true });
fs.mkdirSync(path.join(work, "out", "assets", "vendor"), { recursive: true });
fs.mkdirSync(path.join(work, "docs"), { recursive: true });
for (const name of ["preview.css", "preview.js"]) {
fs.copyFileSync(path.join(EXT, "Assets", name), path.join(work, "out", "assets", name));
}
for (const name of ["markdown-it.min.js", "mermaid.min.js"]) {
fs.copyFileSync(path.join(EXT, "Assets", "vendor", name), path.join(work, "out", "assets", "vendor", name));
}
const markdown = fs.readFileSync(path.join(__dirname, "fixtures", "sample.md"), "utf8");
fs.writeFileSync(path.join(work, "docs", "sample.md"), markdown);
const results = [];
const check = (name, ok) => results.push([ok ? "PASS" : "FAIL", name]);
function render(theme) {
const page = path.join(work, "out", `sample-${theme}.html`);
fs.writeFileSync(
page,
buildDocument({
markdown,
title: "sample.md",
sourcePath: path.join(work, "docs", "sample.md"),
sourceRelDir: "../docs/",
assetsRelDir: "assets",
mode: "markdown",
theme,
contentWidth: 860,
})
);
return execFileSync(
browser,
["--headless", "--disable-gpu", "--no-sandbox", "--virtual-time-budget=10000", "--dump-dom", `file://${page}`],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024 }
);
}
const dom = render("auto");
// Only the rendered <main>. The Markdown source is embedded further down the
// page as data, so a naive slice to end-of-document would find anything.
const body = dom.slice(dom.indexOf('id="content"'), dom.indexOf('id="preview-data"'));
check("both valid diagrams drawn", (dom.match(/<svg/g) || []).length === 2);
check("the broken diagram reports in place", dom.includes("mermaid-error-message") && dom.includes("is-error"));
check("front matter is not rendered", !body.includes("Front matter should be hidden"));
check("headings get ids", dom.includes('<h2 id="a-second-heading"'));
check("in-document anchors are left alone", dom.includes('href="#a-second-heading"'));
check("relative links are rebased once", dom.includes('href="../docs/other.md"') && !dom.includes("docs/./"));
check("relative images are rebased", dom.includes('src="../docs/img/logo.png"'));
check("task list becomes checkboxes", (dom.match(/<input type="checkbox"/g) || []).length === 2);
check("checked state survives", dom.includes('checked=""'));
check("wide tables scroll on their own", dom.includes('class="table-scroll"'));
check("content is revealed", !body.includes('aria-busy="true"'));
const dark = render("dark");
check("dark theme reaches the page", dark.includes('data-theme="dark"'));
check("dark theme still draws diagrams", (dark.match(/<svg/g) || []).length === 2);
for (const [status, name] of results) console.log(`${status} ${name}`);
process.exitCode = results.some(([s]) => s === "FAIL") ? 1 : 0;
Executable
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Runs every test. No dependencies beyond node; render.test.js additionally
# wants a headless browser and skips itself without one.
set -eu
cd "$(dirname "$0")"
echo "— syntax —"
for f in ../Scripts/*.js ../Assets/preview.js ./*.js; do node --check "$f"; done
python3 -c "import json; json.load(open('../extension.json'))"
echo "ok"
for test in generate close render; do
echo "$test"
node "$test.test.js"
done