@noteblob/plugin-sdk
v0.1.6
Published
Authoring SDK for NoteBlob preview plugins.
Readme
@noteblob/plugin-sdk
Authoring SDK for NoteBlob plugins. This package is the TypeScript contract the JS-based plugins program against; the host runtime is provided by the app.
Taxonomy
A plugin declares an extension.id: the extension point it implements.
| extension.id | triggered by | JS? | interface |
| ------------------- | --------------------------------- | ------ | ------------------------------ |
| note-preview.node | markdown node target (manifest) | yes | NotePreviewNodePlugin<Token> |
| theme | user selection | no | — (JSON color/font) |
A package (installed from a URL) contains one or more plugins, possibly of
mixed extension points. extension.id is the seam for future surfaces
(editor.action, …) with no manifest-format change.
The contract
A JS plugin is a render plus two optional hooks — onPrepare and
onThemeChange:
interface PreviewContext {
theme: ThemeInfo;
assets: AssetLoader;
}
interface NotePreviewNodePlugin<Token = CodeNode> {
onPrepare?(ctx: PreviewContext): void | Promise<void>; // once: load libs/styles
render(
node: PreviewNode<Token>,
ctx: PreviewContext,
): void | Cleanup | Promise<void | Cleanup>;
onThemeChange?(
node: PreviewNode<Token>,
ctx: PreviewContext,
): void | Promise<void>;
}onPrepareruns once, before any render — load your libraries/styles.renderruns per node, after the host has placed its element in the DOM. It gets{ el, token }(the element + the parsed token for that node kind), may be sync or async, and may return aCleanupto release per-node resources.- Theme is CSS-first. The host owns the
:rootcustom properties and thedata-color-schemeattribute and swaps them on every theme change — style your output withvar(--…)and it re-themes with zero JS and no re-render. onThemeChangeis only for output CSS can't restyle (canvas/SVG). The host calls it per mounted node after swapping the variables; repaint in place — the node is not re-rendered and keeps its state.- Load deps via
ctx.assets.loadLibrary(relPath)— it returns the lib; hold it in a module variable (the host instantiates your plugin per document).
note-preview / node
The manifest selects the markdown node kind. The plugin type is generic because
the token depends on that manifest variant: code-block plugins receive
CodeNode, heading plugins receive HeadingNode, and future node variants add
their own token shape.
import type { NotePreviewNodePlugin, PreviewNode } from "@noteblob/plugin-sdk";
interface Mermaid {
initialize(c: object): void;
run(c: { nodes: ArrayLike<HTMLElement> }): Promise<void>;
}
const diagramBackground = "#ffffff";
let mermaid: Mermaid;
async function draw({ el, token }: PreviewNode) {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: "default",
fontFamily: getComputedStyle(el).fontFamily,
flowchart: { useMaxWidth: false },
themeVariables: {
background: diagramBackground,
fontFamily: getComputedStyle(el).fontFamily,
},
});
el.classList.add("mermaid");
el.removeAttribute("data-processed");
el.textContent = token.source;
await mermaid.run({ nodes: [el] });
normalizeSVG(el);
}
function normalizeSVG(el: HTMLElement) {
const svg = el.querySelector("svg");
const width = svg
?.getAttribute("viewBox")
?.split(/[\s,]+/)
.map(Number)[2];
if (!svg) return;
if (width && Number.isFinite(width))
svg.style.width = `${Math.ceil(width)}px`;
svg.style.maxWidth = "100%";
svg.style.height = "auto";
svg.style.backgroundColor = diagramBackground;
}
export default {
async onPrepare({ assets }) {
mermaid = await assets.loadLibrary<Mermaid>("vendor/mermaid.js");
},
render: draw,
} satisfies NotePreviewNodePlugin;For a heading plugin:
import type { HeadingNode, NotePreviewNodePlugin } from "@noteblob/plugin-sdk";
export default {
render({ el, token }) {
el.dataset.level = String(token.level);
},
} satisfies NotePreviewNodePlugin<HeadingNode>;Stateful plugins return a Cleanup (runs before the next render / on removal):
render({ el, token }, { theme }) {
const chart = new Chart(el.appendChild(el.ownerDocument.createElement("canvas")), parse(token, theme));
return () => chart.destroy();
}The host owns node discovery and hands you each el — you never need to query
the document to find the nodes your manifest targets.
theme
Themes are not JS — JSON files defining color/font, loaded by the app's
native theme system. A theme plugin needs no entry, no SDK type; its
source directory contains one or more theme JSON files matching the exported
noteblob-theme.schema.json schema.
Manifest
{
"apiVersion": "1",
"id": "com.acme.pack",
"name": "Acme Pack",
"version": "1.0.0",
"plugins": [
{
"id": "mermaid",
"source": "mermaid",
"extension": {
"id": "note-preview.node",
"entry": "main.js",
"node": "codeBlock",
"languages": ["mermaid"],
},
},
{
"id": "heading-tools",
"source": "heading-tools",
"extension": {
"id": "note-preview.node",
"entry": "main.js",
"node": "heading",
"levels": [1, 2],
},
},
{ "id": "themes", "source": "themes", "extension": { "id": "theme" } },
],
}The trigger lives only in the manifest (node + node-specific filters),
never in code.
noteblob-package.json is the single source of truth and is read directly by
the app. The SDK publishes noteblob-package.schema.json for editor/CI
validation; it is generated from the SDK's TypeScript manifest types.
Rules & guarantees
- One plugin = one
extension. Need two behaviors? Ship two plugins. - No network. Preview webviews block egress (CSP + navigation policy), so a
plugin can't exfiltrate content. Bundle heavy deps and pull them in via
assets.loadLibrary— keep them out of yourmain.jsbundle. - Graceful degradation. The host shows the default node output until
renderruns; ifrenderfails or a node has no plugin, it stays default markdown output. The document never breaks. - Conflicts (two plugins claiming the same node filter) are detected from manifests at install time.
Building a plugin
The SDK is imported with import type only, so it's erased at compile —
your main.js carries no dependency on @noteblob/plugin-sdk:
esbuild main.ts --bundle --format=esm --target=es2020 --outfile=main.jsShip the package as noteblob-package.json + each plugin's source directory.
Validating a plugin package
After building your plugin entrypoints, validate the installable package:
npx noteblob-plugin validate .The validator checks noteblob-package.json against the SDK schema, verifies
declared source directories and source-relative entry files, validates theme
JSON files against noteblob-theme.schema.json, imports each JS plugin
entrypoint, checks the plugin export shape, and runs onPrepare with a mock
AssetLoader so loadLibrary, loadStyle, and assetURL references point at
real files inside the plugin's source directory. Because onPrepare declares
runtime libraries, styles, and asset URLs, it is part of the package validation
pass.
SDK build (this repo)
npm install
npm test
npm run build # tsc -> dist/ (.d.ts, types only)
npm run generate:schema # updates noteblob-package.schema.json and noteblob-theme.schema.jsonOfficial plugins live in ../noteblob-plugins. They are a separate npm project and
depend on the published @noteblob/plugin-sdk package.
