diff --git a/.gitignore b/.gitignore index e43b0f9..121f205 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .DS_Store +Tests/.tmp/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6bc4e05 --- /dev/null +++ b/CLAUDE.md @@ -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 `/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. diff --git a/Tests/close.test.js b/Tests/close.test.js new file mode 100644 index 0000000..03cde77 --- /dev/null +++ b/Tests/close.test.js @@ -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; +})(); diff --git a/Tests/fixtures/sample.md b/Tests/fixtures/sample.md new file mode 100644 index 0000000..f0f2d0a --- /dev/null +++ b/Tests/fixtures/sample.md @@ -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) diff --git a/Tests/generate.test.js b/Tests/generate.test.js new file mode 100644 index 0000000..5966628 --- /dev/null +++ b/Tests/generate.test.js @@ -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; diff --git a/Tests/nova-stub.js b/Tests/nova-stub.js new file mode 100644 index 0000000..9d3ddf7 --- /dev/null +++ b/Tests/nova-stub.js @@ -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 }; diff --git a/Tests/render.test.js b/Tests/render.test.js new file mode 100644 index 0000000..6d023c9 --- /dev/null +++ b/Tests/render.test.js @@ -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
. 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(/ s === "FAIL") ? 1 : 0; diff --git a/Tests/run.sh b/Tests/run.sh new file mode 100755 index 0000000..dffb972 --- /dev/null +++ b/Tests/run.sh @@ -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