Skip to content
Twig docs Open Twig MenuClose

Examples

6 plugins

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.

Usesmacros.addblocks.getblocks.children

tsword-count.ts
// twig-types.ts is a copy of src/plugins/types.ts
import type { TwigApi } from './twig-types.ts'

export const 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

export default function 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 = 0
    if (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.

Usescommands.addblocks.getblocks.setMarkerblocks.setDatesnotice

tstomorrow.ts
// twig-types.ts is a copy of src/plugins/types.ts
import type { TwigApi } from './twig-types.ts'

export const 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()

export default function 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 does
    if (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.

Usespanels.addpanels.opencommands.addpages.currentonblocks.childrenblocks.getblocks.setMarker

tsopen-tasks.ts
// twig-types.ts is a copy of src/plugins/types.ts
import type { TwigApi } from './twig-types.ts'

export const name = 'Open tasks'

const OPEN = new Set(['TODO', 'DOING', 'NOW', 'LATER', 'WAITING'])

export default function 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 event
      let frame = 0
      const 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.

Usesviews.addpages.backlinkspages.list

tslinked-from.ts
// twig-types.ts is a copy of src/plugins/types.ts
import type { TwigApi } from './twig-types.ts'

export const name = 'Linked from'

export default function 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)'
        const from = 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 /.

Usescommands.addpages.currentpages.markdownnotice

tscopy-markdown.ts
// twig-types.ts is a copy of src/plugins/types.ts
import type { TwigApi } from './twig-types.ts'

export const name = 'Copy as Markdown'

export default function 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) return
    try {
      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 fixes
      const 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.

Usesmacros.addblocks.getblocks.childrenpanels.addpanels.opencommands.add

tsreading-time.ts
// twig-types.ts is a copy of src/plugins/types.ts
import type { TwigApi } from './twig-types.ts'

export const 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

export default function readingTime(twig: TwigApi): void {
  twig.macros.add('reading', (_args, blockId) => {
    const pageId = twig.blocks.get(blockId)?.pageId
    let n = 0
    if (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.