Vibes DIY
Vibes DIY / Docs / Reference
creator docs · reference · generated

Blockly — block editors whose blocks write real code

This is the same document the Vibes code generator reads when it builds apps — rendered from 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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. pinch is 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-none on 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.js runs the same outcomesFor when 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, so ws.updateToolbox offers 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.Blocks is 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, and JSON.stringify anything on the way into a script string.
  • valueToCode returns 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.