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:
@@ -1 +1,2 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
Tests/.tmp/
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
})();
|
||||||
Vendored
+45
@@ -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 {{{
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -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
|
||||||
Reference in New Issue
Block a user