Skip to content

Fence Renderers ​

A fence renderer turns a fenced code block of a given language into a picture or a live view:

markdown
```plantuml
Alice -> Bob: Hello
```

Extension fences get the same treatment as Typora's built-in diagrams (Mermaid, flowchart, sequence): zoom, full screen, side-by-side editing, error display, and export to HTML, PDF and images. Editing the fence source stays in Typora's code editor; your renderer only produces the preview.

There are four ways to render:

KindManifestTierGood for
Kroki"kroki": "<type>"L0 + networkPlantUML, Graphviz, D2, … with no code
Static"entry", "mode": "static" (default)L1Anything you can draw to SVG offline
Interactive"entry", "mode": "interactive"L1Live views: boards, charts with hover, maps
Resolver"resolver": trueL2main picks the page and talks to it

Fields ​

FieldDescription
langA language or a list of them (lowercase). The first one is the default id.
idLocal id of the renderer. Defaults to the first language.
krokiKroki diagram type (lowercase letters and digits), e.g. plantuml, graphviz.
entryPath to the renderer page in your extension.
mode"static" (default) or "interactive". Ignored for Kroki.
resolvertrue to let main choose the page. Requires main.

If another installed extension renders the same language, the user chooses which one draws it in Settings ▸ Markdown ▸ Syntax by Extension. Until they do, the one asking for the fewest permissions draws it — an offline renderer before one that sends the source to a server — and among equals the first by name. flow and sequence are always Typora's own. mermaid is drawn by Typora's built-in renderer (Pele) unless an extension draws it: the built-in renderer counts as one asking for no permissions, so an offline renderer such as the Mermaid.js extension takes over, one that sends the source to a server doesn't until the user chooses it.

Every renderer needs one of kroki, entry or resolver.

Kroki ​

Kroki draws most text-based diagram languages behind one HTTP API. A Kroki renderer needs no code:

json
{
  "id": "someone.diagrams",
  "name": "Diagrams",
  "version": "1.0.0",
  "permissions": { "network": ["https://kroki.io"] },
  "contributes": {
    "fenceRenderers": [
      { "lang": ["plantuml", "puml"], "kroki": "plantuml" },
      { "lang": ["graphviz", "dot"], "kroki": "graphviz" },
      { "lang": ["d2"], "kroki": "d2" }
    ],
    "configuration": {
      "title": "Diagrams",
      "properties": {
        "server": {
          "type": "string",
          "default": "https://kroki.io",
          "title": "Server",
          "description": "Diagram source is sent here to be drawn."
        }
      }
    }
  }
}
  • Typora sends the fence source to <server>/<type>/svg, sanitizes the SVG it gets back, caches it by content, and reuses it for export.
  • The server is your extension's server setting, or https://kroki.io when it is empty. A self-hosted server must also be covered by your network permission — use a wildcard such as https://*.example.com if needed.
  • A Kroki renderer must declare a network permission. The consent dialog tells the user that diagram source is sent to that server.

Static renderers ​

A static renderer is one hidden page per extension, shared by every fence in the window. Typora asks it to render a source and gets SVG or HTML back. The output is sanitized and inlined into the document like a built-in diagram, so it is cheap, scrolls smoothly, and exports as-is.

json
"fenceRenderers": [
  { "id": "chart", "lang": ["chart"], "mode": "static", "entry": "renderer.html" }
]

renderer.html:

html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <script src="__typora/view.js"></script>
    <script src="chart.js"></script>
  </head>
  <body></body>
</html>

chart.js:

js
window.typora.view.onRender(({ source, info, lang, theme, purpose }) => {
  const rows = source.trim().split("\n").map((line) => line.split(","));
  // Throwing shows the message in place of the diagram.
  if (!rows.length) throw new Error("No data rows.");
  return { svg: drawBarChart(rows, theme) };
});

The handler receives:

FieldDescription
sourceThe fence body.
infoThe full info string, e.g. chart {height=300}.
langThe language (first word of the info string).
theme{ dark, background, foreground, panel, border } — colours of the current theme.
purpose"view" in the editor, "export" for export and print.

Return { svg: "<svg …>" } or { html: "…" }, or a promise of one. The handler may be async.

Sanitized output

Scripts, event handlers, <iframe>, <object>, media elements, forms, SMIL animation and every external reference are removed from the returned markup. Only #fragment references and inline data:image/… URLs survive in href, src and CSS url(). Draw everything inline.

The output also gets its own ids and styles, since it sits in the document next to other diagrams: every id is given a prefix unique to the render (with #id and url(#id) references updated to match), and rules in a <style> element only apply inside the output — text { fill: white } can't recolor the rest of the window. { html } output is wrapped in a <div> to scope its styles.

The page can use the rest of the view SDK: view.settings() for your configuration, view.fetch with a network permission, and view.process with a process permission — a renderer can draw with a server or a program on the user's computer (see the PlantUML sample below). When the user saves new settings, every fence the extension drew is drawn again, so read the settings in each onRender. The page lives as long as the extension runs in the window: a program it keeps running between renders is stopped when the extension stops.

Interactive renderers ​

An interactive renderer gets its own sandboxed frame for each fence, mounted while the fence is on screen. Use it when the result needs to respond to the mouse.

json
"fenceRenderers": [
  { "id": "kanban", "lang": ["kanban"], "mode": "interactive", "entry": "board.html" }
]
js
const view = window.typora.view;

view.onOpen(({ source, info, lang, purpose }) => {
  render(parse(source));
  view.autoHeight(); // keep the frame as tall as the content
});

// Export and print need a static picture.
view.onSnapshot(() => ({ svg: toSvg(columns) }));
  • The frame starts 320 px tall. Call view.setHeight(px) or view.autoHeight() to size it.
  • Frames are created when a fence scrolls into view and destroyed when it scrolls far away, so keep state in the fence source, or in view.setState.
  • For export and print, Typora mounts the page off-screen with purpose: "export" and calls your onSnapshot handler. Return { svg }. Without a snapshot handler the fence can't be exported.

Resolvers ​

With "resolver": true, main decides which page to show for each fence and can exchange messages with it:

json
"fenceRenderers": [{ "id": "map", "lang": ["map"], "mode": "interactive", "resolver": true }],
"main": "main.js"
js
export function activate(ctx) {
  ctx.subscriptions.push(
    typora.views.registerFenceResolver("map", {
      async resolve(fence, panel) {
        // fence: { source, info, purpose }
        panel.entry = fence.source.length > 10_000 ? "map/large.html" : "map/index.html";
        panel.height = 400;
        panel.postMessage({ type: "config", style: typora.settings.get("style") });
        panel.onMessage((message) => console.log("from page", message));
      },
    }),
  );
}

main is activated the first time such a fence renders (onFence:<id>). Because a document may contain many fences, prefer plain entry pages unless you need main.

In command-line export ​

typora export loads extensions: Kroki and static renderers render as usual, interactive renderers are asked for a snapshot. Pass --no-extensions to ignore installed extensions. When no renderer is available (the export core on Linux without a display), extension fences stay as source, like built-in diagrams.

See the Charts, Kanban and Kroki samples, Offline Diagrams (libraries and WebAssembly in a static renderer), and PlantUML (a local program or a Kroki server, chosen in the extension's options).

Theme adapted from Nimbus (MIT).