Guide for writing codeblockRenderers entries in nb-web plugins. See CODEBLOCKS for the user-facing reference and DEVELOPERS for the broader plugin API.
Each entry in codeblockRenderers has three fields:
{
lang: 'myblock',
html: text => `<div class="nb-my-block" data-query="..."></div>`,
render: async container => { /* fetch, build, tick */ },
}| Field | Purpose |
|---|---|
lang |
Fence language tag — ```myblock triggers this renderer |
html |
Synchronous: raw fence text → placeholder HTML injected into the note |
render |
Async: called once per container with all blocks of this type |
Every renderer must call NbWeb.statusPill so the toolbar counter tracks its work:
render: async container => {
const blocks = [...container.querySelectorAll('.nb-my-block')];
if (!blocks.length) return;
NbWeb.statusPill?.add(blocks.length); // register N pending items
await Promise.all(blocks.map(async el => {
await _loadMyBlock(el);
NbWeb.statusPill?.tick(); // one tick per completion
}));
},?. (optional chaining) — the pill is injected by main.js; ?. keeps renderers safe in contexts where the pill isn't present.
Spread to [...] — querySelectorAll returns a live NodeList; freezing it before async work starts prevents mutations during rendering from affecting iteration.
Sequential instead of parallel — use a for...of loop when blocks share initialisation state (e.g. chart, which touches a shared canvas library):
for (const el of blocks) {
await _loadMyBlock(el);
NbWeb.statusPill?.tick();
}html: text => `<div class="nb-my-block" data-query="${text.trim().replace(/"/g, '"')}"></div>`,- Store the raw fence text in
data-*forrender()to read back - Escape
"→"— the value lands inside an HTML attribute - Add
<span class="nb-spin">⟳</span>for visually large blocks; omit for invisible ones (e.g.testForm 2 blocks that vanish on pass)
Every block with a visible header follows the same layout:
[icon] [block-name] [count] [filter/query] [actions: ? ↻]
- Left — icon: app/block-type image or glyph. Always visible — in both collapsed and expanded states. Identifies the block type at a glance. Each block type owns its own icon.
- Left — meta span: block-name label + optional count + optional filter code
- Right — actions span: action buttons, always ending with ↻ refresh.
?help popover is standard for blocks with non-obvious query syntax.
The block-name label is clickable — it opens the external app, or Settings if the app isn't configured. This gives the block a single obvious "launch" affordance without a dedicated button.
const hdr = document.createElement('div');
hdr.className = 'nb-my-header';
// Left: name (clickable) + count + filter
hdr.innerHTML =
`<span class="nb-my-meta">` +
`<span class="nb-my-name" title="Open in myapp">myblock</span>` +
`<span class="nb-my-count">${items.length}</span>` +
(q ? ` <code>${_esc(q)}</code>` : '') +
`</span>`;
hdr.querySelector('.nb-my-name').addEventListener('click', () => {
// open external app, or fall through to Settings
NbTerminal.openSettings('sec-codeblocks');
});
// Right: action buttons
const acts = document.createElement('span');
acts.className = 'nb-my-actions';
const refBtn = document.createElement('button');
refBtn.className = 'nb-tw-btn';
refBtn.title = 'Refresh'; refBtn.textContent = '↻';
refBtn.addEventListener('click', () => _loadMyBlock(el));
acts.appendChild(refBtn);
hdr.appendChild(acts);
el.appendChild(hdr);
_initCollapseToggle(el);Rules:
- Count is omitted when not meaningful (e.g. a single-result block)
- Filter/query only shown when the block has a non-empty query
- ↻ refresh is always the last action button
- Use
nb-tw-btnfor all action buttons — it's the shared codeblock button style _initCollapseToggle(el)is called once, after the header is appended
The whole header bar is the collapse toggle — not a button inside it. Clicking anywhere on the header (except action buttons, which stopPropagation) collapses or expands the block. This is essential for mobile where small tap targets fail.
Call _initCollapseToggle(el) after the header is appended. It wires the header click, persists state in localStorage, and applies the stored state on load. Requires a [class*="-header"] child inside the block — the CSS selector [class*="-header"] automatically gives all headers cursor: pointer; user-select: none.
Action buttons inside the header must call e.stopPropagation() to avoid triggering the toggle.
const refBtn = document.createElement('button');
refBtn.addEventListener('click', e => { e.stopPropagation(); _loadMyBlock(el); });
// After all DOM is built:
_initCollapseToggle(el);No ▼/▶ indicator button — the block's icon (left side of header) is always visible and identifies the type in both collapsed and expanded states. Design an icon for your block type.
Always catch fetch errors and replace the spinner with a visible message — never leave it spinning silently:
try {
const d = await fetch(`/api/my-endpoint?q=...`).then(r => r.json());
if (d.error) { el.innerHTML = `<span class="nb-my-error">⚠ ${_esc(d.error)}</span>`; return; }
_buildMyBlock(el, d);
} catch (e) {
el.innerHTML = `<span class="nb-my-error">⚠ ${_esc(e.message)}</span>`;
}_esc() is defined at the top of nbweb-codeblocks.js — copy it into your plugin:
const _esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');Every block that fetches live data should have a refresh button:
const refBtn = document.createElement('button');
refBtn.className = 'nb-tw-btn';
refBtn.title = 'Refresh';
refBtn.textContent = '↻';
refBtn.addEventListener('click', () => _loadMyBlock(el));nb-tw-btn is the shared small-button style used across all codeblocks.
| Call | Purpose |
|---|---|
NbWeb.statusPill?.add(n) |
Register n pending items in the toolbar counter |
NbWeb.statusPill?.tick() |
Mark one item complete |
NbWeb.checkWhich(bin) |
Check if a binary is on PATH; returns {found, path} |
NbWeb.renderRequirementsCard(el, mdPath) |
Replace el with a "not installed" card |
NbMain.openNote(selector) |
Navigate to a note |
NbMain.activeSelector() |
Selector of the currently open note |
NbNav.notebook |
Current notebook name |
NbTerminal.run(cmd) |
Open terminal pane and run a command |
NbTerminal.openSettings(sectionId) |
Open Settings scrolled to a section |
If your plugin registers a previewRenderer, never call marked.parse(body) directly on note body content. Use NbMain.renderMarkdown(body, note.selector) instead.
marked.parse() runs the bare markdown parser. NbMain.renderMarkdown() runs the nb-web-augmented version that:
- Converts fenced code blocks to live widget divs (
<div class="nb-chart-block">etc.) soNbWeb.renderCodeblockscan find and render them - Pre-processes
[[docs:wikilinks]]into clickable spans - Rewrites relative image paths to
/api/file?selector=…
Calling marked.parse() directly means any chart, hledger, tw, or other live blocks in the note body silently render as static code, and the StatusPill never fires for them.
// Wrong — bypasses codeblock renderers and wikilinks
const bodyHtml = marked.parse(body);
// Right
const bodyHtml = NbMain.renderMarkdown(body, note.selector || '');The only safe use of marked.parse() directly is for content you own entirely (e.g. synthesised strings with no user-authored fenced blocks or wikilinks).
A block that edits something (an add form, an edit panel) builds its buttons and keys with the shared helpers, and gets the unsaved-changes policy for free:
const { row, saveBtn, cancelBtn } = NbWeb.formActions({ onSave, onCancel });
form.appendChild(row);
NbWeb.formKeys(form, saveBtn, cancelBtn); // Ctrl/Cmd+Enter saves, Escape cancels- Save is dim until something changes.
formKeysgives itnb-cleanand lights it on anyinput/changeinside the form. If your form changes without those events (a row deleted, a chip toggled, a value set from code), callNbWeb.formDirty(form). - Unsaved work is never lost silently (djp, 2026-10-14). While a visible form's Save is lit,
switching notes asks "Discard unsaved changes?" and closing or reloading the tab gets the
browser's leave-page warning (
NbMain._hasUnsaved: the editor, the sheet view, csv grids,NbWeb.unsavedForms()). Cancelling or hiding the form drops it out on its own. After a successful save that keeps the form open, callNbWeb.formDirty(form, false). - A block with its own editing surface that isn't a form (a grid, a canvas) keeps a dirty flag
and adds it to
_hasUnsavedinmain.js, as csv does with_csvDirty. - Essential header actions (add
+, edit✎) usenb-tw-btn nb-block-act; a Save in the header isnb-tool-btn nb-btn-primary.
Tests: nb-web-tests/e2e/tests/form-actions.spec.js.
-
NbWeb.statusPill?.add(blocks.length)called before work begins -
NbWeb.statusPill?.tick()called once per block — success and error paths - Early return if
!blocks.length - Errors replace the spinner with a visible message
- Refresh button wired to re-run the load function; calls
e.stopPropagation() - All header action buttons call
e.stopPropagation() -
_initCollapseTogglecalled after header is appended (if block has a header) - Block has an icon in the header left — visible when collapsed to identify type
-
html()escapes"→"in data attributes -
querySelectorAllresult spread to[...]before async work begins -
previewRendererusesNbMain.renderMarkdown(), notmarked.parse() - Forms use
NbWeb.formActions+NbWeb.formKeys; changes that fire no input event callNbWeb.formDirty(form) - A non-form editing surface reports its dirty state to
_hasUnsaved(unsaved work never lost silently)