Blockly — block editors whose blocks write real code
prompts/pkg/llms/blockly.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
When someone asks for "Scratch-like" blocks, a drag-and-drop block editor, a visual programming playground, a page builder for a kid, or any surface where people snap pieces together instead of typing code, this is the recipe. It is equally the recipe when the words are business words: a rules engine the analyst edits (credit scoring, fraud flags, approval thresholds, compliance checks, discount rules, eligibility, routing), a CRM or ERP automation ("when an order arrives, if it matches, tag it and send it to billing"), a low-code form or screen builder, a step sequence for a robot or a conveyor, a test script built from steps and expectations. The person who owns the policy edits it; the connection checks keep every stack one the app can run. Blockly is a library for building a block editor that is yours: your app decides which blocks exist, which ones snap into which slots, and what a finished stack compiles to — a web page, a JSON rule, JavaScript, a list of steps. The person drags; the generator writes; the app runs the result.
The worked example is jchris/block-builder, a page builder a kid can use:
structure blocks, style blocks and action blocks compile to an HTML document
shown live in an iframe, with a Code Peek toggle that shows the real HTML, CSS
and JavaScript the blocks produced. The two modules below distil it — one that
compiles blocks to a page, one that compiles blocks to a rule — and the sections
after them cover each decision the example makes.
Importing Blockly — one pinned bundle
Import Blockly from one version-pinned esm.sh URL with ?bundle:
jsimport * as Blockly from "https://esm.sh/[email protected]?bundle";
That one module carries the core, the standard blocks (logic, loops, math,
text, lists, variables) and the English messages, all bound to one Blockly
object. The ?bundle is load-bearing: the package is a set of compiled files
that find each other through a Node-style require of the core, and a per-file
CDN build hands each file its own copy of that core, so the standard blocks
register into a Blockly.Blocks nobody renders (measured 2026-10-07: the
plain index import throws at load on every version, and jsDelivr's +esm
loads quietly with an empty block registry). The bundle keeps them together.
The JavaScript generator for the standard blocks is a second pinned bundle:
jsimport { javascriptGenerator, Order } from "https://esm.sh/[email protected]/javascript?bundle";
Another language's messages are a plain object, so a locale loads from its own
subpath and Blockly.setLocale switches the standard blocks' labels:
import * as Fr from "https://esm.sh/[email protected]/msg/fr"; Blockly.setLocale(Fr);.
Your own blocks carry their labels in their definitions, so translate those
in your own data.
Import Blockly in exactly one module and re-export it (export { Blockly }
from lib/blocks.js, imported by App.jsx). Blocks register on the object the
module imported, and a second import site is a second registry.
Plugins such as @blockly/field-colour are compiled against their own copy of
the core, so a colour choice is a field_dropdown of named swatches (["plum", "#8b5cf6"]) — which also gives a kid a readable name per colour.
Project shape: a React shell around a blocks module
App.jsx the React shell: project list, the workspace host, the preview, Code Peek
lib/blocks.js every Blockly import, the block definitions, the toolbox, the generator, STARTER
access.js who may edit which project
seed.json two or three finished projects so the first visit has something to click
Relative imports include the extension (./lib/blocks.js). App.jsx is the
only client file with export default; lib/blocks.js uses named exports and
holds the Blockly definitions beside plain generator functions, so the
evaluator a rules module exports runs as well from backend.js as from the
browser.
Complete starter — a page builder
The blocks module. Two statement families, told apart by connection checks: a
piece block holds other pieces, a piece's "looks" slot holds look blocks,
and a button's "when pressed" slot holds action blocks. Blockly refuses every
other snap, which is the whole safety story: the person can only build pages
the generator knows how to write.
lib/blocks.js
jsimport * as Blockly from "https://esm.sh/[email protected]?bundle";
export { Blockly };
const SWATCHES = [
["plum", "#8b5cf6"], ["coral", "#f97316"], ["sunshine", "#facc15"],
["leaf", "#22c55e"], ["sky", "#3b82f6"], ["ink", "#111827"], ["paper", "#ffffff"],
];
Blockly.defineBlocksWithJsonArray([
{
type: "piece_heading",
message0: "heading %1 named %2",
args0: [
{ type: "field_input", name: "TEXT", text: "Hello there" },
{ type: "field_input", name: "NAME", text: "" },
],
message1: "looks %1",
args1: [{ type: "input_statement", name: "LOOKS", check: "look" }],
previousStatement: "piece",
nextStatement: "piece",
colour: 20,
tooltip: "A big line of text.",
},
{
type: "piece_text",
message0: "paragraph %1 named %2",
args0: [
{ type: "field_input", name: "TEXT", text: "Write something" },
{ type: "field_input", name: "NAME", text: "" },
],
message1: "looks %1",
args1: [{ type: "input_statement", name: "LOOKS", check: "look" }],
previousStatement: "piece",
nextStatement: "piece",
colour: 20,
},
{
type: "piece_button",
message0: "button %1 named %2",
args0: [
{ type: "field_input", name: "TEXT", text: "Press me" },
{ type: "field_input", name: "NAME", text: "" },
],
message1: "looks %1",
args1: [{ type: "input_statement", name: "LOOKS", check: "look" }],
message2: "when pressed %1",
args2: [{ type: "input_statement", name: "PRESSED", check: "action" }],
previousStatement: "piece",
nextStatement: "piece",
colour: 20,
},
{
type: "piece_box",
message0: "box named %1",
args0: [{ type: "field_input", name: "NAME", text: "" }],
message1: "looks %1",
args1: [{ type: "input_statement", name: "LOOKS", check: "look" }],
message2: "inside %1",
args2: [{ type: "input_statement", name: "INSIDE", check: "piece" }],
previousStatement: "piece",
nextStatement: "piece",
colour: 340,
},
{
type: "look_background",
message0: "background %1",
args0: [{ type: "field_dropdown", name: "COLOR", options: SWATCHES }],
previousStatement: "look",
nextStatement: "look",
colour: 210,
},
{
type: "look_text_color",
message0: "text color %1",
args0: [{ type: "field_dropdown", name: "COLOR", options: SWATCHES }],
previousStatement: "look",
nextStatement: "look",
colour: 210,
},
{
type: "look_size",
message0: "size %1",
args0: [{ type: "field_dropdown", name: "SIZE", options: [["small", "14px"], ["medium", "20px"], ["large", "32px"], ["huge", "56px"]] }],
previousStatement: "look",
nextStatement: "look",
colour: 210,
},
{
type: "look_rounded",
message0: "rounded corners",
previousStatement: "look",
nextStatement: "look",
colour: 210,
},
{
type: "action_change_text",
message0: "change text of %1 to %2",
args0: [
{ type: "field_input", name: "TARGET", text: "greeting" },
{ type: "field_input", name: "TEXT", text: "You pressed it!" },
],
previousStatement: "action",
nextStatement: "action",
colour: 120,
},
{
type: "action_toggle",
message0: "show or hide %1",
args0: [{ type: "field_input", name: "TARGET", text: "secret" }],
previousStatement: "action",
nextStatement: "action",
colour: 120,
},
]);
const category = (name, colour, types) => ({
kind: "category",
name,
colour,
contents: types.map((type) => ({ kind: "block", type })),
});
export const toolbox = {
kind: "categoryToolbox",
contents: [
category("Pieces", "20", ["piece_heading", "piece_text", "piece_button", "piece_box"]),
category("Looks", "210", ["look_background", "look_text_color", "look_size", "look_rounded"]),
category("Actions", "120", ["action_change_text", "action_toggle"]),
],
};
// A generator whose output is a web page. `forBlock` holds one function per block type;
// `scrub_` is what chains a block to the block under it.
const html = new Blockly.CodeGenerator("PageHtml");
html.scrub_ = function (block, code, thisOnly) {
const next = block.nextConnection && block.nextConnection.targetBlock();
return next && !thisOnly ? code + this.blockToCode(next) : code;
};
const escapeText = (s) => String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
const slug = (s) => String(s ?? "").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
// Looks compile to CSS declarations and pieces compile to tags. Each piece collects its own
// looks and actions into the shared `page` object, so one pass yields markup, styles and script.
function piece(block, tag, inner) {
const page = html.page;
page.count += 1;
const id = slug(block.getFieldValue("NAME")) || `piece-${page.count}`;
const looks = html.statementToCode(block, "LOOKS");
if (looks) page.css.push(`#${id} {\n${looks}}`);
const pressed = block.getInput("PRESSED") ? html.statementToCode(block, "PRESSED") : "";
if (pressed) page.js.push(`document.getElementById("${id}").addEventListener("click", () => {\n${pressed}});`);
return `<${tag} id="${id}">${inner}</${tag}>\n`;
}
html.forBlock["piece_heading"] = (b) => piece(b, "h1", escapeText(b.getFieldValue("TEXT")));
html.forBlock["piece_text"] = (b) => piece(b, "p", escapeText(b.getFieldValue("TEXT")));
html.forBlock["piece_button"] = (b) => piece(b, "button", escapeText(b.getFieldValue("TEXT")));
html.forBlock["piece_box"] = (b, gen) => piece(b, "section", "\n" + gen.statementToCode(b, "INSIDE"));
html.forBlock["look_background"] = (b) => ` background: ${b.getFieldValue("COLOR")};\n`;
html.forBlock["look_text_color"] = (b) => ` color: ${b.getFieldValue("COLOR")};\n`;
html.forBlock["look_size"] = (b) => ` font-size: ${b.getFieldValue("SIZE")};\n`;
html.forBlock["look_rounded"] = () => ` border-radius: 16px;\n padding: 12px;\n`;
html.forBlock["action_change_text"] = (b) =>
` document.getElementById(${JSON.stringify(slug(b.getFieldValue("TARGET")))}).textContent = ${JSON.stringify(b.getFieldValue("TEXT"))};\n`;
html.forBlock["action_toggle"] = (b) =>
` document.getElementById(${JSON.stringify(slug(b.getFieldValue("TARGET")))}).toggleAttribute("hidden");\n`;
/** The whole page as one HTML document — what the preview iframe and the download both show. */
export function compile(workspace) {
html.page = { count: 0, css: [], js: [] };
html.init(workspace);
let body = "";
for (const top of workspace.getTopBlocks(true)) {
if (top.type.startsWith("piece_")) body += html.blockToCode(top);
}
const { css, js } = html.page;
const style = ["body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; }", ...css].join("\n");
const script = js.join("\n");
const full = `<!doctype html>\n<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>\n${style}\n</style></head>\n<body>\n${body}<script>\n${script}\n</script></body></html>`;
return { html: body, css: style, js: script, full };
}
/** What an empty project opens on, so the first drag has something to snap to. */
export const STARTER = {
blocks: {
languageVersion: 0,
blocks: [
{
type: "piece_heading",
x: 24,
y: 24,
fields: { TEXT: "Hello there", NAME: "greeting" },
inputs: {
LOOKS: {
block: { type: "look_background", fields: { COLOR: "#8b5cf6" }, next: { block: { type: "look_text_color", fields: { COLOR: "#ffffff" } } } },
},
},
next: {
block: {
type: "piece_button",
fields: { TEXT: "Press me", NAME: "" },
inputs: { PRESSED: { block: { type: "action_change_text", fields: { TARGET: "greeting", TEXT: "You pressed it!" } } } },
},
},
},
],
},
};
The shell. The workspace is injected once, inside a useEffect(() => {…}, [])
that also disposes it; projects are documents whose workspaceJson is the
saved workspace; every change regenerates the page and, when this person may
edit the open project, schedules an autosave.
App.jsx
jsximport React, { useEffect, useMemo, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { useVibe, useViewer } from "use-vibes";
import { Badge, Button, Card, Grid, Heading, Row, Segmented, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
import { Switch } from "@vibes.diy/pouf/interactive";
import { Blockly, compile, toolbox, STARTER } from "./lib/blocks.js";
const EMPTY = { html: "", css: "", js: "", full: "" };
export default function App() {
const { database, useLiveQuery } = useFireproof("pageBlocks");
const { me, can, ready } = useVibe("pageBlocks");
const { requestLogin } = useViewer();
const { docs } = useLiveQuery("type", { key: "project" });
const projects = useMemo(() => [...docs].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)), [docs]);
const [activeId, setActiveId] = useState(null);
const [code, setCode] = useState(EMPTY);
const [peek, setPeek] = useState(false);
const [pane, setPane] = useState("blocks");
const [saveState, setSaveState] = useState("saved");
const hostRef = useRef(null);
const wsRef = useRef(null);
const saveTimer = useRef(null);
const activeRef = useRef(null);
const canEditRef = useRef(false);
const active = projects.find((p) => p._id === activeId) ?? projects[0] ?? null;
const canEdit = Boolean(active && ready && can.edit(active).ok);
const canMake = Boolean(ready && me && can.create({ type: "project", name: "x", creatorHandle: me.userHandle }).ok);
activeRef.current = active;
canEditRef.current = canEdit;
async function saveNow() {
saveTimer.current = null;
const ws = wsRef.current;
const target = activeRef.current;
if (!ws || !target || !canEditRef.current) return;
const workspaceJson = Blockly.serialization.workspaces.save(ws);
await database.put({ ...target, workspaceJson, updatedAt: Date.now() });
setSaveState("saved");
}
// Inject once. The host needs a real height (see below), and the cleanup disposes the workspace.
useEffect(() => {
const ws = Blockly.inject(hostRef.current, {
toolbox,
renderer: "zelos",
zoom: { controls: true, wheel: true, pinch: true, startScale: 0.85 },
move: { scrollbars: true, drag: true, wheel: false },
trashcan: true,
sounds: false,
});
wsRef.current = ws;
ws.addChangeListener((e) => {
if (e.isUiEvent) return;
setCode(compile(ws));
if (activeRef.current && canEditRef.current) {
setSaveState("saving");
clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(saveNow, 800);
}
});
const ro = new ResizeObserver(() => Blockly.svgResize(ws));
ro.observe(hostRef.current);
return () => {
ro.disconnect();
clearTimeout(saveTimer.current);
ws.dispose();
wsRef.current = null;
};
}, []);
// Load the open project's blocks with events off, then regenerate once.
useEffect(() => {
const ws = wsRef.current;
if (!ws) return;
Blockly.Events.disable();
try {
ws.clear();
Blockly.serialization.workspaces.load(active?.workspaceJson || STARTER, ws);
} finally {
Blockly.Events.enable();
}
setCode(compile(ws));
setSaveState("saved");
}, [active?._id]);
async function pickProject(id) {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
await saveNow();
}
setActiveId(id);
}
async function createProject(name, workspaceJson) {
if (!canMake) return;
if (saveTimer.current) {
clearTimeout(saveTimer.current);
await saveNow();
}
const doc = { _id: crypto.randomUUID(), type: "project", name, workspaceJson, creatorHandle: me.userHandle, updatedAt: Date.now() };
await database.put(doc);
setActiveId(doc._id);
}
function keepScratch() {
const ws = wsRef.current;
if (ws) createProject("My page", Blockly.serialization.workspaces.save(ws));
}
function download() {
const url = URL.createObjectURL(new Blob([code.full], { type: "text/html" }));
const a = document.createElement("a");
a.href = url;
a.download = `${active?.name || "page"}.html`;
a.click();
URL.revokeObjectURL(url);
}
const peekSections = [
{ key: "html", title: "Structure", text: code.html },
{ key: "css", title: "Looks", text: code.css },
{ key: "js", title: "Actions", text: code.js },
];
return (
<main className="min-h-screen bg-background text-foreground p-3 md:p-6">
<Stack gap={4}>
<Row gap={2} align="center" wrap>
<Heading level={1}>Page Blocks</Heading>
<Spacer />
{!ready && <Skeleton variant="row" />}
{ready && projects.map((p) => (
<Button key={p._id} size="sm" variant={p._id === active?._id ? "solid" : "quiet"} onClick={() => pickProject(p._id)}>{p.name}</Button>
))}
{canMake && <Button size="sm" variant="quiet" onClick={() => createProject("New page", STARTER)}>New page</Button>}
{active && <Badge tone={saveState === "saved" ? "up" : "idle"}>{saveState === "saved" ? "Saved" : "Saving…"}</Badge>}
{active && ready && !canEdit && <Badge tone="info">View only</Badge>}
</Row>
{ready && !me && <Row gap={2} align="center" wrap><Text muted>You are building on a scratch pad.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in to keep this page</Button></Row>}
{ready && me && !active && <Row gap={2} align="center" wrap><Text muted>Like what you built?</Text><Button size="sm" variant="quiet" onClick={keepScratch}>Keep it as my first page</Button></Row>}
<div className="sm:hidden">
<Segmented label="Blocks or preview" value={pane} onChange={setPane} options={[{ value: "blocks", label: "Blocks" }, { value: "preview", label: "Preview" }]} />
</div>
<Grid cols={2} gap={4}>
<section className={pane === "blocks" ? "block" : "hidden sm:block"}>
<Card variant="flush">
<div className="relative h-96 md:h-screen">
<div ref={hostRef} className="absolute inset-0 touch-none" />
</div>
</Card>
</section>
<section className={pane === "preview" ? "block" : "hidden sm:block"}>
<Stack gap={3}>
<Row gap={2} align="center" wrap>
<Heading level={2}>Live page</Heading>
<Spacer />
<Switch label="Code Peek" checked={peek} onChange={setPeek} />
<Button size="sm" onClick={download}>Download</Button>
</Row>
<Card variant="flush">
<iframe title="Live page" sandbox="allow-scripts" srcDoc={code.full} className="block w-full h-96 bg-card" />
</Card>
{peek && peekSections.map((s) => (
<Card key={s.key} variant="tight">
<Stack gap={2}>
<Badge tone="info">{s.title}</Badge>
{s.text ? <pre className="font-mono text-sm whitespace-pre-wrap break-words bg-muted rounded-md p-3 max-h-64 overflow-y-auto">{s.text}</pre> : <Text size="sm" muted>Nothing here yet.</Text>}
</Stack>
</Card>
))}
</Stack>
</section>
</Grid>
</Stack>
</main>
);
}
access.js
js// project: a page. Its creator owns it; the creator may also let a named helper in.
// membership: { projectId, handle, addedBy } lets one more person edit one project.
export function pageBlocks(doc, oldDoc, user, ctx) {
if (!user) throw { forbidden: "sign in to build pages" };
if (doc._deleted) {
if (oldDoc && oldDoc.type === "project") {
ctx.requireAccess(`project:${oldDoc._id}/admin`);
return { channels: [`project:${oldDoc._id}`] };
}
if (oldDoc && oldDoc.type === "membership") {
ctx.requireAccess(`project:${oldDoc.projectId}/admin`);
return { channels: [`project:${oldDoc.projectId}`] };
}
throw { forbidden: "nothing to delete here" };
}
if (doc.type === "project") {
const ch = `project:${doc._id}`;
if (oldDoc) {
if (doc.creatorHandle !== oldDoc.creatorHandle) throw { forbidden: "creator is fixed" };
ctx.requireAccess(ch);
} else if (doc.creatorHandle !== user.userHandle) {
throw { forbidden: "projects are created as yourself" };
}
return { channels: [ch], grant: { users: { [doc.creatorHandle]: [ch, `${ch}/admin`] } } };
}
if (doc.type === "membership") {
const ch = `project:${doc.projectId}`;
if (doc.addedBy !== user.userHandle) throw { forbidden: "addedBy must be you" };
ctx.requireAccess(`${ch}/admin`);
return { channels: [ch], grant: { users: { [doc.handle]: [ch] } } };
}
throw { forbidden: "unknown document type" };
}
Mounting the workspace in React
Inject the workspace once inside useEffect(() => {…}, []), keep it in a
ref, and dispose the workspace in the effect's cleanup. Blockly owns its own
DOM under the host element; React renders the chrome around it. Per-change
values the engine needs (the open project, whether this person may edit) travel
through refs the change listener reads, so the listener installed on mount sees
the current project without re-injecting.
Give the host a real height. Blockly sizes its SVG from the host element,
and an auto-height <div> is 0px tall, which renders as a toolbox with nothing
beside it. The shape above — a wrapper with h-96 md:h-screen and the host as
absolute inset-0 inside it — is the one that works everywhere. A
ResizeObserver on the host calling Blockly.svgResize(ws) keeps the
workspace right when the layout changes; call it too when a hidden tab becomes
visible, since a workspace laid out at display: none measures as empty.
Options that matter:
renderer: "zelos"is the Scratch 3.0 look — rounded, big targets, friendly on a phone. The default (thrasos) is the compact classic look, right for a dense rules editor an analyst uses at a desk.zoom: { controls: true, wheel: true, pinch: true, startScale: 0.85 }— zelos blocks are large, so start a little zoomed out.pinchis the phone's zoom.move: { scrollbars: true, drag: true, wheel: false }— dragging the background pans; the wheel zooms instead of scrolling the page away.trashcan: true,sounds: false. Keyboard navigation is built in since v13, so Tab reaches the blocks with no extra setup.touch-noneon the host (and on the wrapper, when overlays render inside it) so a drag is a drag rather than a page scroll.
Defining blocks
Define blocks with Blockly.defineBlocksWithJsonArray and give each
connection a check string, so a block only snaps where its family belongs.
message0: "heading %1 named %2" interleaves label text with args0, one
entry per %n; message1/args1 adds a second row. Field types a vibe uses:
field_input (text), field_dropdown (options: [["label", "value"], …]),
field_number (value, min, max, precision), field_checkbox. Inputs:
input_value (a round socket for a reporter block, check: "Boolean" or
"Number"), input_statement (a C-shaped slot, check: "look"),
input_dummy (a row with fields only). inputsInline: true puts a block's
inputs on one row — the right call for a and b.
Connections: previousStatement/nextStatement make a stackable statement
block, output makes a reporter that plugs into a socket; the value of each is
the check ("piece", "look", "Boolean"), and a socket accepts a block
only when the checks share a value. That is the type system: a look block
dropped onto a piece slot bounces off, so the generator meets only shapes it
knows. colour is a hue 0–360 (or a blockStyles name from the theme);
tooltip shows on hover. getFieldValue("NAME") returns a string for text and
dropdown fields and a number for field_number.
The standard blocks arrive with the bundle. A toolbox category listing
controls_if, logic_compare, controls_repeat_ext, math_number,
math_arithmetic, text, text_print, text_join, lists_create_with
gives a coding playground its vocabulary, and { kind: "category", name: "Variables", custom: "VARIABLE" } adds the variable-making category.
The toolbox
A categoryToolbox has categories (name, colour, contents); a
flyoutToolbox is one strip of blocks and suits a rules editor with five
pieces. A block item may preset its fields and pre-fill its sockets:
js{ kind: "block", type: "controls_repeat_ext",
inputs: { TIMES: { shadow: { type: "math_number", fields: { NUM: 3 } } } } }
A shadow block is a default value the person can type into or replace by
dropping a real block over it — give every socket one, so a block dragged from
the toolbox already does something. { kind: "label", text: "Pieces" } and
{ kind: "sep" } organise a flyout. ws.updateToolbox(newToolbox) swaps the
toolbox at runtime — the way a rules editor offers the fields of whichever
record type the person picked.
Generators — blocks become text, and the text is yours to define
Blockly's generator turns a workspace into a string. Each block type gets one
function on generator.forBlock, called with (block, generator):
a statement block returns the code string for itself, a reporter (value) block
returns [code, order], where order is the precedence the caller uses to
decide on parentheses (Order.ATOMIC when the output is already a unit).
Inside a function, generator.valueToCode(block, "TEST", Order.ATOMIC) compiles
the block plugged into a socket (empty string when nothing is plugged in — give
it a fallback), and generator.statementToCode(block, "THEN") compiles the
whole stack inside a C-slot, indented. generator.workspaceToCode(ws) compiles
every top-level stack; generator.blockToCode(block) compiles one stack, which
is how the starter picks only piece_ stacks and leaves a loose look block on
the canvas alone.
Your own generator
new Blockly.CodeGenerator("PageHtml") is an empty generator; set scrub_ so
a statement's output is followed by the block beneath it (the recipe above
concatenates; a JSON generator joins with ",\n"), then fill forBlock. Call
generator.init(workspace) before a pass. The output is any text at all: HTML,
CSS, a JSON rule tree, a list of steps, a cron line, SQL-shaped filters your app
interprets. Compile blocks to data your own code interprets whenever the
blocks describe rules or settings, because a JSON tree is safe to evaluate on
any record anywhere — in the browser for a live match count and in
backend.js when a record arrives — and your evaluator can only do the things
it has a case for. Here is the whole shape for a rules builder:
lib/rules.js
jsimport * as Blockly from "https://esm.sh/[email protected]?bundle";
export { Blockly };
const FIELDS = [["total", "total"], ["quantity", "quantity"], ["region", "region"], ["status", "status"]];
Blockly.defineBlocksWithJsonArray([
{
type: "rule_when",
message0: "when a record arrives %1 if %2 %3 then %4",
args0: [
{ type: "input_dummy" },
{ type: "input_value", name: "TEST", check: "Boolean" },
{ type: "input_dummy" },
{ type: "input_statement", name: "THEN", check: "outcome" },
],
colour: 290,
tooltip: "One rule. Several can live on the canvas; each is checked against every record.",
},
{
type: "cond_compare",
message0: "%1 %2 %3",
args0: [
{ type: "field_dropdown", name: "FIELD", options: FIELDS },
{ type: "field_dropdown", name: "OP", options: [["is over", ">"], ["is under", "<"], ["is", "=="], ["is not", "!="]] },
{ type: "field_input", name: "VALUE", text: "500" },
],
output: "Boolean",
colour: 210,
},
{
type: "cond_both",
message0: "%1 %2 %3",
args0: [
{ type: "input_value", name: "A", check: "Boolean" },
{ type: "field_dropdown", name: "OP", options: [["and", "and"], ["or", "or"]] },
{ type: "input_value", name: "B", check: "Boolean" },
],
inputsInline: true,
output: "Boolean",
colour: 210,
},
{
type: "outcome_tag",
message0: "tag it %1",
args0: [{ type: "field_input", name: "TAG", text: "review" }],
previousStatement: "outcome",
nextStatement: "outcome",
colour: 120,
},
{
type: "outcome_route",
message0: "send it to %1",
args0: [{ type: "field_dropdown", name: "QUEUE", options: [["the manager", "manager"], ["billing", "billing"], ["the archive", "archive"]] }],
previousStatement: "outcome",
nextStatement: "outcome",
colour: 120,
},
]);
export const toolbox = {
kind: "flyoutToolbox",
contents: [
{ kind: "block", type: "rule_when" },
{ kind: "block", type: "cond_compare" },
{ kind: "block", type: "cond_both" },
{ kind: "block", type: "outcome_tag" },
{ kind: "block", type: "outcome_route" },
],
};
// The generator emits JSON text. Value blocks return [code, order]; statement blocks return code.
const json = new Blockly.CodeGenerator("RuleJson");
const ORDER = { ATOMIC: 0 };
json.scrub_ = function (block, code, thisOnly) {
const next = block.nextConnection && block.nextConnection.targetBlock();
return next && !thisOnly ? code + ",\n" + this.blockToCode(next) : code;
};
json.forBlock["rule_when"] = (b, gen) => {
const test = gen.valueToCode(b, "TEST", ORDER.ATOMIC) || "true";
const outcomes = gen.statementToCode(b, "THEN");
return `{ "if": ${test}, "then": [\n${outcomes}\n] }`;
};
json.forBlock["cond_compare"] = (b) => {
const raw = b.getFieldValue("VALUE");
const value = Number.isFinite(Number(raw)) && raw.trim() !== "" ? Number(raw) : raw;
return [JSON.stringify({ field: b.getFieldValue("FIELD"), op: b.getFieldValue("OP"), value }), ORDER.ATOMIC];
};
json.forBlock["cond_both"] = (b, gen) => {
const a = gen.valueToCode(b, "A", ORDER.ATOMIC) || "true";
const c = gen.valueToCode(b, "B", ORDER.ATOMIC) || "true";
return [`{ "${b.getFieldValue("OP")}": [${a}, ${c}] }`, ORDER.ATOMIC];
};
json.forBlock["outcome_tag"] = (b) => ` ${JSON.stringify({ tag: b.getFieldValue("TAG") })}`;
json.forBlock["outcome_route"] = (b) => ` ${JSON.stringify({ route: b.getFieldValue("QUEUE") })}`;
/** Every top-level rule on the canvas, parsed — saved beside the blocks on the ruleset document. */
export function compileRules(workspace) {
json.init(workspace);
const rules = [];
for (const top of workspace.getTopBlocks(true)) {
if (top.type === "rule_when") rules.push(JSON.parse(json.blockToCode(top)));
}
return rules;
}
// Plain data in, plain data out: the same function runs in the browser for the live match count
// and in backend.js when a record arrives.
export function test(cond, record) {
if (cond === true) return true;
if (cond.and) return cond.and.every((c) => test(c, record));
if (cond.or) return cond.or.some((c) => test(c, record));
const actual = record[cond.field];
switch (cond.op) {
case ">": return Number(actual) > Number(cond.value);
case "<": return Number(actual) < Number(cond.value);
case "==": return String(actual) === String(cond.value);
case "!=": return String(actual) !== String(cond.value);
default: return false;
}
}
export function outcomesFor(rules, record) {
return rules.filter((rule) => test(rule.if, record)).flatMap((rule) => rule.then);
}
export const STARTER = {
blocks: {
languageVersion: 0,
blocks: [
{
type: "rule_when",
x: 24,
y: 24,
inputs: {
TEST: {
block: {
type: "cond_both",
fields: { OP: "and" },
inputs: {
A: { block: { type: "cond_compare", fields: { FIELD: "total", OP: ">", VALUE: "500" } } },
B: { block: { type: "cond_compare", fields: { FIELD: "region", OP: "==", VALUE: "west" } } },
},
},
},
THEN: { block: { type: "outcome_tag", fields: { TAG: "review" }, next: { block: { type: "outcome_route", fields: { QUEUE: "manager" } } } } },
},
},
],
},
};
The shell around it is the page builder's shell with two swaps: the preview
pane is a list of the app's records with each one's outcomes beside it
(outcomesFor(rules, record)), headed by a live count — "matches 12 of 40" —
and the autosave writes { workspaceJson, rules: compileRules(ws) } on the
ruleset document, so backend.js reads rules and evaluates the saved rule
data with outcomesFor on every record that lands. The starter
rule reads: when a record arrives, if total is over 500 and region is west,
tag it "review" and send it to the manager.
The JavaScript generator and running its output
For a coding playground, the standard blocks plus javascriptGenerator give
you real JavaScript for free; add javascriptGenerator.forBlock["my_block"]
entries for your own blocks in the same style ((block, generator) => …,
with Order from the same import for reporters).
Run generated JavaScript inside a sandboxed iframe
(<iframe sandbox="allow-scripts" srcDoc={doc} />, a document that wraps the code in a try, swaps window.alert/console.log
for parent.postMessage, and the parent listens on message to show the
output). The iframe is a fresh origin with no access to the app's page, data
or identity, so the same stage shows a kid's loop and a stranger's remix
equally safely. Bound every generated loop with the loop trap, since a
sandboxed frame still shares the tab's thread and a while (true) with no
exit would hold the editor too:
jsjavascriptGenerator.INFINITE_LOOP_TRAP = 'if (--window.LoopTrap === 0) throw "Infinite loop.";\n';
// and in the stage document, before the generated code: window.LoopTrap = 10000;
Every while, repeat and for the standard blocks emit then counts down
and stops with a readable message after ten thousand turns, which the stage
shows like any other output, and the Run button stays responsive.
Step highlighting is two lines: javascriptGenerator.STATEMENT_PREFIX = "highlightBlock(%1);\n" and javascriptGenerator.addReservedWords("highlightBlock")
make every statement begin with a call carrying its block id; the iframe posts
those ids up and the parent calls ws.highlightBlock(id) (and
ws.highlightBlock(null) when the run ends).
Live output as blocks change
ws.addChangeListener((e) => { if (e.isUiEvent) return; … }) fires on every
workspace event; isUiEvent marks clicks, selection, scroll and toolbox
opens, so the early return keeps regeneration to real edits — a block created,
moved, deleted, a field changed. Regenerate the code in the listener (it is
cheap; the page builder recompiles the whole page on every event) and let a
debounce own the save.
Load a project with events disabled, then regenerate once: wrap ws.clear()
and Blockly.serialization.workspaces.load(state, ws) in
Blockly.Events.disable() … Blockly.Events.enable() (in a try/finally)
so the listener stays quiet while the stack is rebuilt, then call compile(ws)
yourself. That is what keeps a project switch from autosaving the incoming
project over itself.
Persistence — save the blocks, regenerate the code
Store the workspace JSON from Blockly.serialization.workspaces.save, and
regenerate code on load. The object it returns ({ blocks: { languageVersion: 0, blocks: [...] }, variables? }) is the project; store it as workspaceJson
on the project document. The generated page, rule or script is derived, so a
generator fix reaches every saved project the next time it opens. When the
server needs the compiled form too (the rules case), save both — blocks for
the editor, rules for backend.js — and treat rules as a cache the editor
rewrites on every save.
Autosave on a short debounce (~800ms after the last real event), flush the
pending save before switching projects, and write only when
can.edit(project).ok: a viewer who opens a shared project edits freely on
their own screen and keeps a view-only badge in the header. Seed two or three
finished projects in seed.json so a first visit opens on a page or a rule that
already does something, and let a signed-out visitor build on a scratch pad
with "Keep it" as the moment to sign in.
Colour — a Blockly theme from the app's tokens
Blockly draws its own chrome inside the host, so the app's theme reaches it
through a Blockly theme, built from the injected CSS variables resolved to
concrete colours at mount (the same probe-element trick the p5 and three-js
docs use — a var(--background, #fff) on a hidden span, read back with
getComputedStyle):
jsconst theme = Blockly.Theme.defineTheme("vibe", {
base: Blockly.Themes.Classic,
componentStyles: {
workspaceBackgroundColour: cssColor(host, "--background", "#ffffff"),
toolboxBackgroundColour: cssColor(host, "--surface", "#f4f4f5"),
flyoutBackgroundColour: cssColor(host, "--surface", "#f4f4f5"),
toolboxForegroundColour: cssColor(host, "--text-primary", "#111111"),
scrollbarColour: cssColor(host, "--border", "#cccccc"),
},
fontStyle: { family: "system-ui, sans-serif", size: 12 },
});
const ws = Blockly.inject(host, { toolbox, theme, renderer: "zelos" });
Block colours stay as hues per family (structure, looks, actions), which is how
Scratch teaches: a kid learns that blue blocks are looks before reading a word.
ws.setTheme(theme) swaps the theme on a live workspace when the owner
restyles the app.
Mobile
The zelos renderer, pinch zoom, touch-none on the host and a Segmented
control that shows blocks or the preview one at a time under sm make the same
app work on a phone. toolboxPosition: "end" puts the toolbox on the right
for a right-handed thumb; horizontalLayout: true lays the categories across
the top when the screen is short. Call Blockly.svgResize(ws) after the pane
switch so the workspace measures its newly visible host.
Shapes a Blockly vibe takes
- A rules engine the owner of the policy edits — credit scoring, fraud
flags, approval thresholds, compliance checks, discount and pricing rules,
eligibility, routing and triage: "if the total is over 500 and the region is
west, tag it for review". Blocks compile to a JSON rule tree; the editor
shows a live count of the app's records each rule matches, with the matching
records listed beneath so a change is checked against real data before it
is saved;
backend.jsruns the sameoutcomesForwhen a record arrives. The analyst, the bookkeeper or the operations lead edits the policy without a release, and the connection checks mean every rule they can build is one the evaluator understands. Field dropdowns come from the record type, sows.updateToolboxoffers the right fields when the type changes. - A CRM, ERP or ticket workflow automation — "when a form comes in, send it to billing and tag it urgent", "when a deal passes 10,000, assign it to the manager and schedule a follow-up": trigger blocks over the app's record types, condition blocks over their fields, action blocks over its queues, tags, assignees and dates, compiled to the same data shape and run server-side when the record lands or on a schedule (the backend skill has both handlers). A log of which rule fired on which record is the audit trail.
- A low-code form or screen builder — field, section and validation blocks compile to a form definition the app renders and validates; the same block set drives a settings screen or an intake flow.
- A test or checklist builder — "open the page, fill the field, expect this text", or an API check with a mock response and an expected value: step blocks compile to a plan the app runs step by step, highlighting the current block, with pass and fail marks beside each step.
- A page builder for a kid — structure, style and action blocks compile to one HTML document shown live in a sandboxed iframe, with Code Peek showing the real HTML, CSS and JavaScript the blocks wrote and a download of the finished page. The worked example above.
- A coding playground — the standard blocks and
javascriptGenerator, a Run button, output in the sandboxed stage, step highlighting on the canvas. - A device, robot or conveyor sequence — move/turn/wait/pick-up blocks that drive an SVG turtle, an animated arm or a line diagram so a technician rehearses a sequence before it runs; a schedule builder whose blocks compile to the times a scheduled handler reads.
Each is the same three parts: a block vocabulary with checks, a generator whose output your code understands, and a stage that shows the result the moment a block lands.
Common gotchas
- The workspace renders as a toolbox with nothing beside it — the host has
no height. Size the wrapper and make the host
absolute inset-0. - The standard blocks are missing or
Blockly.Blocksis empty — a second Blockly instance. One import, with?bundle, re-exported from one module. - Every project switch autosaves the wrong blocks — the listener ran during
load. Disable events around the load and regenerate once after it. And flush the pending debounce before switching or creating a project, so the last edit lands on the project it belongs to. - Generated HTML breaks on a
<in a field — escape field text on the way into markup, andJSON.stringifyanything on the way into a script string. valueToCodereturns an empty string — the socket is empty; give the call a fallback (|| "true") or put a shadow block in the toolbox entry.- A hidden pane shows a blank workspace when it appears — call
Blockly.svgResize(ws)after the pane becomes visible. - Dropdown values arrive as strings — a numeric choice such as a size is a string on the block; convert where the generator needs a number.