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
108 lines
4.2 KiB
JavaScript
108 lines
4.2 KiB
JavaScript
// 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 };
|