06. Scripting

VMPrint documents can participate in their own layout lifecycle. Elements can react to events, query the settled document, and change each other’s content through messages — all without a second rendering pass.

This guide covers the practical authoring model. For the full API contract, see Scripting API.


The authoring format

Script methods live in a YAML front matter block. The document body stays ordinary JSON.

---
methods:
  onLoad(): |
    setContent("greeting", "Hello, VMPrint.")
---
{
  "documentVersion": "1.1",
  "layout": {
    "pageSize": "LETTER",
    "margins": { "top": 72, "right": 72, "bottom": 72, "left": 72 },
    "fontFamily": "Times New Roman",
    "fontSize": 12,
    "lineHeight": 1.45
  },
  "styles": {},
  "elements": [
    {
      "type": "p",
      "name": "greeting",
      "content": "Waiting..."
    }
  ]
}

Three things to notice:

The engine parses the front matter itself. No CLI flags or extra files needed.


Naming elements

Scripts address elements by name. Give any element you want to script a top-level name field.

{
  "type": "p",
  "name": "summary",
  "content": "Calculating..."
}

Use name consistently — it is the only identity the scripting layer needs.


Document event handlers

Top-level methods are document handlers by default. No prefix needed.

The four document lifecycle events:

Handler When it fires
onLoad() Once, before layout begins
onReady() Once, when the document first becomes fully settled
onChanged() When document content or structure changed after ready
onRefresh() When the realized document refreshed after a change

Use onLoad() to set initial content from variables. Use onReady() to read facts that only exist after layout has settled — page count, discovered elements, and so on.

Setting initial content on load

---
AUTHOR: "Ada Lovelace"
SUBTITLE: "Notes on the Analytical Engine"

methods:
  onLoad(): |
    setContent("byline", AUTHOR)
    setContent("subtitle", SUBTITLE)
---

Variables declared in the front matter outside any method are document-scoped. They are available by name inside any handler.

Reading settled facts on ready

onReady fires after layout has fully settled. By then you can ask the document things you couldn’t know before layout ran.

---
methods:
  onReady(): |
    const pages = doc.getPageCount()
    const chapters = elementsByType("h1")
    setContent("colophon",
      `${chapters.length} chapters · ${pages} pages`)
---

doc.getPageCount() returns the real settled page count — not an estimate. elementsByType(type) returns all elements of that type as they exist in the settled document.


Element event handlers

An element can handle its own lifecycle events. Name the method using the element’s name as a prefix.

---
methods:
  banner_onCreate(): |
    setContent("banner", "Document is loading.")

  banner_onChanged(): |
    setContent("banner", "Document content has changed.")
---

Inside an element handler, self refers to that element. You can read self.name, self.type, and self.content, and call helpers like self.setContent(...) or self.append(...) directly on it.


Messaging between elements

The scripting model is built around two things: events (raised by the engine) and messages (sent between elements). Messages are how elements coordinate without needing a central script to orchestrate everything.

Send a message from one element, handle it in another:

---
methods:
  sender_onCreate(): |
    sendMessage("display", {
      subject: "update",
      payload: { text: "Sent from the sender element." }
    })

  display_onMessage(from, msg): |
    if (from.name !== "sender") return
    if (msg.subject !== "update") return
    setContent(self, msg.payload.text)
---

sendMessage(recipient, msg) takes a target name and a message object. The message object has a subject and an optional payload.

The receiving handler gets from (the sender) and msg (the full message). Check both before acting — an element can receive messages from multiple senders.


Querying elements

Two generic queries are available inside any handler:

element("summary")          // returns the element named "summary", or null
elementsByType("h1")        // returns all elements of type "h1"

These are useful in onReady() when you want to inspect what the settled document actually contains.

---
methods:
  onReady(): |
    const headings = elementsByType("h1")
    if (headings.length === 0) return
    sendMessage("toc", {
      subject: "populate",
      payload: { titles: headings.map(h => h.content) }
    })

  toc_onMessage(from, msg): |
    if (msg.subject !== "populate") return
    msg.payload.titles.forEach(title => {
      append({ type: "toc-entry", content: title })
    })
---

The toc element starts empty. After the document settles, it receives the real heading list and appends an entry for each one.


Structural mutation

Beyond setContent, scripts can change the structure of the document.

append(element) / prepend(element) — add content to the current receiver (or to any named element using element("name").append(...)):

---
methods:
  notes_onMessage(from, msg): |
    if (msg.subject !== "add-note") return
    append({
      type: "note-item",
      content: msg.payload.text
    })
---

replace(elements) — replace the current receiver with entirely new content. The old element is removed from the live document; the replacement takes its place and the document resettles from that point:

---
methods:
  onReady(): |
    sendMessage("placeholder", { subject: "build" })

  placeholder_onMessage(from, msg): |
    if (msg.subject !== "build") return
    replace([
      { type: "h2", content: "Generated Section" },
      { type: "p", content: "This replaced the placeholder after layout settled." }
    ])
---

deleteElement(target) — remove a named element entirely:

---
methods:
  onReady(): |
    if (doc.getPageCount() === 1) {
      deleteElement("overflow-note")
    }
---

All structural helpers operate on live layout participants. The document does not restart from scratch — settlement resumes from the earliest affected point.


Receiver orientation

Most helpers are receiver-oriented: they act on whoever self is at the time.

Inside a document handler (onLoad, onReady, etc.), self is the document. Inside an element handler (summary_onMessage, etc.), self is that element.

---
methods:
  onLoad(): |
    append({ type: "p", content: "Added to the document." })

  footer_onMessage(from, msg): |
    append({ type: "p", content: "Added to the footer element." })
---

The same append() call does different things depending on context. When in doubt, use the explicit form to be unambiguous:

element("footer").append({ type: "p", content: "Explicit target." })

A complete example

A document that sets a byline on load, then updates a summary block after settling with real page and chapter counts:

---
AUTHOR: "Ada Lovelace"

methods:
  onLoad(): |
    setContent("byline", AUTHOR)

  onReady(): |
    const chapters = elementsByType("h1")
    sendMessage("summary", {
      subject: "settle",
      payload: {
        chapters: chapters.length,
        pages: doc.getPageCount()
      }
    })

  summary_onMessage(from, msg): |
    if (msg.subject !== "settle") return
    const p = msg.payload
    setContent(self, `${p.chapters} chapters · ${p.pages} pages`)
---
{
  "documentVersion": "1.1",
  "layout": {
    "pageSize": "LETTER",
    "margins": { "top": 72, "right": 72, "bottom": 72, "left": 72 },
    "fontFamily": "Times New Roman",
    "fontSize": 12,
    "lineHeight": 1.45
  },
  "styles": {
    "h1": { "fontSize": 18, "fontWeight": "bold", "marginBottom": 10 },
    "p": { "marginBottom": 8 }
  },
  "elements": [
    { "type": "p", "name": "byline", "content": "" },
    { "type": "p", "name": "summary", "content": "Calculating..." },
    { "type": "h1", "content": "Chapter One" },
    { "type": "p", "content": "Opening paragraph..." },
    { "type": "h1", "content": "Chapter Two" },
    { "type": "p", "content": "Second chapter..." }
  ]
}

For the full API — all handlers, all helpers, all objects, variable scope rules, and what this version does not include: