Six small plugins, each complete in one file. They are TypeScript, checked against the API by every build; without the types they are plain JavaScript. The five that ship with Twig are the larger worked examples, each in src/plugins/catalogue/<id>/index.ts.
Word count in a block
word-count.ts
A {{words}} macro: type it in any block and it renders the page’s word count. The smallest useful plugin: one registration and nothing to clean up, because Twig undoes every registration when the plugin is switched off.
// twig-types.ts is a copy of src/plugins/types.tsimporttype { TwigApi } from'./twig-types.ts'exportconst name = 'Words on this page'/** Every block under a page or a block, depth first. */function* under(twig: TwigApi, key: string): Generator<string> {
for (const id of twig.blocks.children(key)) {
yield id
yield* under(twig, id)
}
}
/** Words in a block's text, leaving out any {{macro}}. */const words = (text: string): number =>
text.replace(/\{\{[^}]*\}\}/g, ' ').split(/\s+/).filter(Boolean).length
exportdefaultfunction wordCount(twig: TwigApi): void {
// {{words}} in any block; a macro's name is matched without regard to case
twig.macros.add('words', (_args, blockId) => {
const pageId = twig.blocks.get(blockId)?.pageId
let n = 0if (pageId) {
for (const id of under(twig, pageId)) {
n += words(twig.blocks.get(id)?.content ?? '')
}
}
const chip = document.createElement('span')
chip.className = 'chip'// plugin output: information, not a link
chip.textContent = `${n} ${n === 1 ? 'word' : 'words'}`return chip
})
}
What you’ll see
A quiet chip, “212 words”, where you typed {{words}}. It is counted when the block draws, so it changes when you edit that block or leave it, not while you type elsewhere. For a live figure, use a panel.
To try it, paste /docs/examples/word-count.js under From a URL on the Plugins screen.
A slash command
tomorrow.ts
/Do tomorrow makes the block a task, if it is not one yet, and schedules it for tomorrow. It leaves a finished task alone, and one with a deadline, whose day the deadline sets.
// twig-types.ts is a copy of src/plugins/types.tsimporttype { TwigApi } from'./twig-types.ts'exportconst name = 'Do tomorrow'/** 20260924: the day key SCHEDULED: and DEADLINE: are written with. */const dayKey = (d: Date): number =>
d.getFullYear() * 10_000 + (d.getMonth() + 1) * 100 + d.getDate()
exportdefaultfunction doTomorrow(twig: TwigApi): void {
twig.commands.add('Do tomorrow', ({ blockId }) => {
const block = twig.blocks.get(blockId)
if (!block) return// nothing on screen would say why nothing happened, so a notice doesif (block.marker === 'DONE' || block.marker === 'CANCELED') {
twig.notice('That task is finished; it keeps its dates')
return
}
if (block.deadline !== undefined) {
twig.notice('That task has a deadline, which sets its day')
return
}
const day = new Date()
day.setDate(day.getDate() + 1)
if (!block.marker) twig.blocks.setMarker(blockId, 'TODO')
twig.blocks.setDates(blockId, { scheduled: dayKey(day) })
// no notice here: the date line appears in the block itself
})
}
What you’ll see
Type /do in a block and pick Do tomorrow. The block gains TODO and a SCHEDULED: line with tomorrow’s date, and tomorrow it is on Today in Tasks. Done, it says nothing: the change is right there in the block. Refused, it says why in a notice, since nothing on screen would.
To try it, paste /docs/examples/tomorrow.js under From a URL on the Plugins screen.
A panel
open-tasks.ts
A sidebar panel listing the open tasks on the page you are on, each with a checkbox that marks it done. It follows you from page to page, keeps up as you type, and stops listening when it closes.
// twig-types.ts is a copy of src/plugins/types.tsimporttype { TwigApi } from'./twig-types.ts'exportconst name = 'Open tasks'const OPEN = new Set(['TODO', 'DOING', 'NOW', 'LATER', 'WAITING'])
exportdefaultfunction openTasks(twig: TwigApi): void {
twig.panels.add('open-tasks', {
title: 'Open tasks',
render(host) {
const list = document.createElement('ul')
list.style.cssText =
'list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s2)'
host.append(list)
const row = (id: string, text: string): HTMLElement => {
const li = document.createElement('li')
const label = document.createElement('label')
label.className = 'check-label'const box = document.createElement('input')
box.type = 'checkbox'
box.className = 'check'
box.addEventListener('change', () => twig.blocks.setMarker(id, 'DONE'))
label.append(box, text)
li.append(label)
return li
}
const draw = (): void => {
const page = twig.pages.current()
const rows: HTMLElement[] = []
const walk = (key: string): void => {
for (const id of twig.blocks.children(key)) {
const b = twig.blocks.get(id)
if (b?.marker && OPEN.has(b.marker)) {
const first = b.content.split('\n')[0] ?? ''
rows.push(row(id, first.replace(/^[A-Z]+\s+/, '')))
}
walk(id)
}
}
if (page) walk(page.id)
if (rows.length === 0) {
const empty = document.createElement('li')
empty.className = 'muted'
empty.textContent = page
? 'Nothing open on this page.'
: 'Open a page to see its tasks.'
rows.push(empty)
}
list.replaceChildren(...rows)
}
// every keystroke fires 'block': draw once a frame, not once an eventlet frame = 0const soon = (): void => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(draw)
}
draw()
const offBlock = twig.on('block', soon)
const offRoute = twig.on('route', soon)
return () => {
cancelAnimationFrame(frame)
offBlock()
offRoute()
}
},
})
// registering a panel does not show it; a command is how a person asks
twig.commands.add('Open tasks', () => twig.panels.open('open-tasks'))
}
What you’ll see
/Open tasks opens the panel beside the page, or over it on a narrow window. Tick one and it is marked DONE where it was written, and leaves the list.
To try it, paste /docs/examples/open-tasks.js under From a URL on the Plugins screen.
A view
linked-from.ts
A view of a page: every block that links to it, with the page each is on. Because it says page: true, a page’s own screen offers it as a tab beside Outline.
// twig-types.ts is a copy of src/plugins/types.tsimporttype { TwigApi } from'./twig-types.ts'exportconst name = 'Linked from'exportdefaultfunction linkedFrom(twig: TwigApi): void {
// page: true promises to read params[0] as a page name, so a page's own// screen offers this as a tab beside Outline
twig.views.add('linked', {
label: 'Linked from',
page: true,
render(host, [pageName = '']) {
const names = new Map(twig.pages.list().map((p) => [p.id, p.name]))
const blocks = twig.pages.backlinks(pageName)
const head = document.createElement('p')
head.className = 'eyebrow'const links = blocks.length === 1 ? 'block links' : 'blocks link'
head.textContent = `${blocks.length} ${links} here`
host.append(head)
for (const b of blocks) {
const row = document.createElement('a')
row.href = `#/block/${b.id}`
row.style.cssText = 'display: grid; gap: 2px; padding: var(--s2) 0; ' +
'border-bottom: 1px solid var(--line); color: var(--ink)'constfrom = document.createElement('span')
from.className = 'meta'from.textContent = names.get(b.pageId) ?? ''const text = document.createElement('span')
text.style.fontFamily = 'var(--text)'
text.textContent = b.content.split('\n')[0] ?? ''
row.append(from, text)
host.append(row)
}
},
})
}
What you’ll see
A Linked from tab in the row under each page’s title, opening #/x/linked/<page name>. Each row opens its block.
To try it, paste /docs/examples/linked-from.js under From a URL on the Plugins screen.
An action
copy-markdown.ts
Copies the page you are on as Markdown. Filed under Actions in the marketplace, which is a listing’s kind and not something the API sets, its command is in the Actions menu on a page’s own screen as well as under /.
// twig-types.ts is a copy of src/plugins/types.tsimporttype { TwigApi } from'./twig-types.ts'exportconst name = 'Copy as Markdown'exportdefaultfunction copyAsMarkdown(twig: TwigApi): void {
// Listed under Actions, this also runs from a page's Actions menu, where// blockId is the page's first block, or '' on an empty page. So it asks// for the page instead.
twig.commands.add('Copy as Markdown', async () => {
const page = twig.pages.current()
if (!page) returntry {
await navigator.clipboard.writeText(twig.pages.markdown(page.name))
twig.notice(`Copied “${page.name}” as Markdown`)
} catch (e) {
// say what the browser said: a blocked permission and an insecure// address need different fixesconst why = e instanceof Error ? e.message : String(e)
twig.notice(`Could not copy: ${why}`)
}
})
}
What you’ll see
Actions ▾, Copy as Markdown, then a notice: Copied “Groceries” as Markdown. The copy happens out of view, which is what a notice is for. Added by URL with no listing to file it under, the command stays under / alone.
To try it, paste /docs/examples/copy-markdown.js under From a URL on the Plugins screen.
A setting
reading-time.ts
A setting kept the simplest way a plugin can: in localStorage under a key of its own, with its field in the plugin’s own panel. A {{reading}} macro reads it.
// twig-types.ts is a copy of src/plugins/types.tsimporttype { TwigApi } from'./twig-types.ts'exportconst name = 'Reading time'// There is no settings API: a plugin keeps its own, in localStorage, under a// key no one else will use.const KEY = 'reading-time:words-per-minute'const FALLBACK = 230/** localStorage can throw (storage turned off, a private window): fall back. */function speed(): number {
let n = Number.NaN
try { n = Number(localStorage.getItem(KEY)) } catch { /* the fallback */ }
return Number.isFinite(n) && n >= 50 && n <= 1000 ? n : FALLBACK
}
function save(value: string): void {
try {
if (value === '') localStorage.removeItem(KEY)
else localStorage.setItem(KEY, value)
} catch { /* not kept: the field shows the speed in use */ }
}
function* under(twig: TwigApi, key: string): Generator<string> {
for (const id of twig.blocks.children(key)) {
yield id
yield* under(twig, id)
}
}
/** Words in a block's text, leaving out any {{macro}}, as Word count does. */const words = (text: string): number =>
text.replace(/\{\{[^}]*\}\}/g, ' ').split(/\s+/).filter(Boolean).length
exportdefaultfunction readingTime(twig: TwigApi): void {
twig.macros.add('reading', (_args, blockId) => {
const pageId = twig.blocks.get(blockId)?.pageId
let n = 0if (pageId) {
for (const id of under(twig, pageId)) {
n += words(twig.blocks.get(id)?.content ?? '')
}
}
const chip = document.createElement('span')
chip.className = 'chip'
chip.textContent = `${Math.max(1, Math.round(n / speed()))} min read`return chip
})
// a plugin cannot add a section to Settings, so its field is in its own panel
twig.panels.add('reading-time', {
title: 'Reading time',
render(host) {
const field = document.createElement('label')
field.className = 'field'const input = document.createElement('input')
input.type = 'number'
input.min = '50'
input.max = '1000'
input.step = '10'
input.value = String(speed())
input.setAttribute('aria-label', 'Words a minute')
const unit = document.createElement('span')
unit.textContent = 'words a minute'
field.append(input, unit)
const note = document.createElement('p')
note.className = 'muted'
note.textContent = `Between 50 and 1000. Empty goes back to ${FALLBACK}.`
input.addEventListener('change', () => {
const n = Number(input.value)
if (input.value === '' || (n >= 50 && n <= 1000)) save(input.value)
input.value = String(speed())
})
host.append(field, note)
},
})
twig.commands.add('Reading speed', () => twig.panels.open('reading-time'))
}
What you’ll see
{{reading}} renders “4 min read”. /Reading speed opens a panel with one field, and a new speed shows the next time a block draws the macro. The setting stays on this device: it is not synced and not in a backup.
To try it, paste /docs/examples/reading-time.js under From a URL on the Plugins screen.