outl docs
Bar Chart
Capability:
content-transformer:rich· Source on GitHub
Turns a ```bars fence of label: number lines into a mini bar chart.
Each bar’s width is proportional to its value, normalized by the largest.
What it demonstrates
A rich content-transformer.
The plugin calls ctx.content.register("bars", fn) during activate.
When a client renders a ```bars fence, the host runs fn with the fence body and renders the HTML it returns.
The transformer is a pure function: fn(body) parses the lines and returns { kind: "rich", content } (HTML).
Because the descriptor is kind: "rich", the host runs the HTML in a sandboxed iframe — <iframe sandbox="allow-scripts">, no network, no imports, no access to the app DOM.
So everything (CSS, the resize script) is inline.
This is a GUI-only surface: it shows on desktop and mobile, and is dropped on the TUI/CLI (no webview to draw in).
For a chart that works everywhere, you’d return kind: "text" instead (see the box example, where the descriptor is plain text rendered on every client).
To size the iframe on desktop, the HTML posts its height back with parent.postMessage({ outlHeight: <px> }, '*') (optional; default ~240px).
Invalid lines (no colon, blank, or a non-numeric value) are silently dropped, and labels are HTML-escaped because they’re user input.
Example
```bars
Rust: 92
TypeScript: 64
Clojure: 41
Go: 78
```
renders into a chart with one bar per line, each bar colored and sized proportionally to its value, with the label and value alongside.
The code
/**
* Bar Chart — a rich content-transformer for outl.
*
* Registers the `bars` code-fence language. Each line of the body is parsed as
* `label: number`; we emit a self-contained HTML bar chart (one row per line,
* each bar's width proportional to its value, normalized by the largest).
*
* Because we return `kind: "rich"`, the host runs the HTML in a sandboxed
* iframe — `<iframe sandbox="allow-scripts">`, no network, no imports, no
* access to the app DOM. So everything (CSS, the tiny resize script) is inline.
* This is a GUI-only surface: it shows on desktop and mobile, and is dropped on
* the TUI/CLI (no webview to draw in). For a chart that works everywhere, you'd
* use `kind: "text"` instead (see the `box` example).
*
* Needs the `content-transformer:rich` capability. No permissions —
* transformers are pure and never mutate the workspace.
*/
import { definePlugin, type PluginContext } from "@outl/plugin-sdk";
interface Row {
label: string;
value: number;
}
/**
* Parse `label: number` lines into rows. Invalid lines (no colon, blank, or a
* non-finite value) are silently dropped — the chart only shows what it can
* actually plot.
*/
function parseRows(body: string): Row[] {
const rows: Row[] = [];
for (const raw of body.split("\n")) {
const line = raw.trim();
if (line === "") continue;
const colon = line.lastIndexOf(":");
if (colon === -1) continue;
const label = line.slice(0, colon).trim();
const value = Number(line.slice(colon + 1).trim());
if (label === "" || !Number.isFinite(value)) continue;
rows.push({ label, value });
}
return rows;
}
/** Escape text so it is safe to drop into HTML (labels are user input). */
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
/** Pick a deterministic accent color per row so bars are easy to tell apart. */
const COLORS = [
"#ff595e",
"#ffca3a",
"#8ac926",
"#1982c4",
"#6a4c93",
"#ff6ec7",
];
/** Build the self-contained bar-chart HTML for `rows`. */
function renderChart(rows: Row[]): string {
const max = rows.reduce((m, r) => Math.max(m, r.value), 0);
const bars = rows
.map((r, i) => {
// Normalize by the largest value; clamp negatives to a hairline so a
// row is always visible. Width is a percentage of the track.
const pct = max > 0 ? Math.max(0, (r.value / max) * 100) : 0;
const color = COLORS[i % COLORS.length];
return `<div class="row">
<div class="label" title="${escapeHtml(r.label)}">${escapeHtml(r.label)}</div>
<div class="track">
<div class="bar" style="width:${pct.toFixed(1)}%;background:${color}"></div>
</div>
<div class="value">${escapeHtml(String(r.value))}</div>
</div>`;
})
.join("");
const body = rows.length
? `<div class="chart">${bars}</div>`
: `<div class="empty">No <code>label: number</code> lines to plot.</div>`;
return `<!doctype html><html><head><meta charset="utf-8"><style>
:root{color-scheme:light dark}
html,body{margin:0;background:transparent;
font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
.chart{display:flex;flex-direction:column;gap:8px;padding:10px 4px}
.row{display:grid;grid-template-columns:minmax(60px,22%) 1fr auto;
align-items:center;gap:10px}
.label{text-align:right;font-weight:600;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis;opacity:.85}
.track{position:relative;height:20px;border-radius:5px;
background:rgba(128,128,128,.18)}
.bar{height:100%;border-radius:5px;min-width:2px;
transition:width .35s ease;box-shadow:inset 0 -2px 4px rgba(0,0,0,.12)}
.value{font-variant-numeric:tabular-nums;opacity:.7;white-space:nowrap}
.empty{padding:14px;opacity:.6}
</style></head><body>${body}<script>
// Tell the desktop host how tall to make the iframe (optional; default ~240px).
requestAnimationFrame(function(){
var h=Math.ceil(document.body.getBoundingClientRect().height);
parent.postMessage({outlHeight:h},'*');
});
</script></body></html>`;
}
export default definePlugin({
activate(ctx: PluginContext) {
ctx.content.register("bars", (body) => ({
kind: "rich",
content: renderChart(parseRows(body)),
}));
},
});
Try it
outl -w <workspace> plugin install ./examples/bars --yes
# put a ```bars fence in a page and open it on desktop/mobile (rich is GUI-only, sandboxed iframe)