A modern dual Python editor that combines Blockly (visual block-based programming) and CodeMirror 6 (text editing) with bidirectional synchronization, powered by Pyodide for in-browser Python execution.
BlockPy lets learners write Python code either as visual blocks or as text, with both views staying in sync. This lowers the barrier to learning Python: beginners can start with blocks and gradually transition to text.
src/
├── types/
│ └── index.ts # Shared TypeScript types (SyncState, ParseResult, etc.)
├── services/
│ ├── pythonBlocks.ts # Blockly block definitions for Python constructs
│ ├── blockToPython.ts # Blockly workspace → Python source code
│ ├── pythonToBlocks.ts # Python source → Blockly XML (uses CodeMirror parser)
│ ├── pyodideRunner.ts # Lazy Pyodide loader + Python execution runtime
│ └── syncController.ts # Bidirectional sync logic (debounced, no React deps)
├── components/
│ ├── BlocklyWorkspace.tsx # Blockly editor React wrapper
│ ├── CodeMirrorEditor.tsx # CodeMirror 6 editor React wrapper
│ └── BlockPyEditor.tsx # Main container: coordinates sync, run, output
├── App.tsx
└── main.tsx
| Layer | Responsibility |
|---|---|
React UI (components/) |
Rendering only; delegates logic to services |
syncController.ts |
Source-of-truth for sync state; no React deps |
pythonToBlocks.ts |
Pure function: Python string → Blockly XML |
blockToPython.ts |
Pure function: Blockly workspace → Python string |
pythonBlocks.ts |
Block schema and toolbox definitions |
pyodideRunner.ts |
Pyodide lifecycle; CDN-loaded lazily |
The sync flow is bidirectional but change-source aware:
User edits blocks → blockToPython → update CodeMirror text
User edits text → (debounced 300ms) → pythonToBlocks → update Blockly workspace
Key properties:
- Debounced: Text changes wait 300 ms before triggering a parse, preventing lag during typing.
- Source tracking:
SyncSource = 'blocks' | 'text' | 'external'prevents circular updates. - Non-destructive fallback: If text cannot be parsed, the last valid Blockly workspace is preserved.
- Dirty state: Tracked in
SyncState.isDirty. - No infinite loops:
isUpdatingflag insyncController.tsgates re-entrant calls.
| Construct | Block Type |
|---|---|
| Number literals | py_number |
| String literals | py_string |
Boolean literals (True/False) |
py_boolean |
None |
py_none |
| Variable references | py_variable |
Assignment (x = expr) |
py_assign |
Arithmetic (+, -, *, /, %, **) |
py_add, etc. |
Comparisons (==, !=, <, <=, >, >=) |
py_compare |
Boolean operators (and, or) |
py_bool_op |
not |
py_not |
if/else |
py_if |
while loop |
py_while |
for loop |
py_for |
| Function definition | py_func_def |
| Function call | py_func_call |
return |
py_return |
| List literal | py_list |
print(...) |
py_print |
import |
py_import |
When the Python-to-blocks parser encounters syntax it cannot convert:
- Parse errors produce a
py_errorblock with the error message. - Unsupported-but-valid syntax produces a
py_unsupportedblock with the raw source. - The last valid workspace is preserved — unsupported code does NOT overwrite the workspace.
- Errors are reported in the UI's Parse Issues panel with location info (line/col).
Every unsupported node emits a structured UnsupportedSyntaxError with:
nodeType: the CST node type namelocation:{ line, col, endLine, endCol }sourceExcerpt: up to 60 chars of the offending code
- Node.js 18+
- npm 9+
npm install
npm run devOpen http://localhost:5173 in your browser.
| Script | Description |
|---|---|
npm run dev |
Start Vite dev server |
npm run build |
TypeScript compile + Vite production build |
npm run preview |
Preview production build |
npm test |
Run tests once (Vitest) |
npm run test:watch |
Run tests in watch mode |
npm run lint |
ESLint check |
npm run format |
Prettier format |
npm run typecheck |
TypeScript type check (no emit) |
npm testTests use Vitest + React Testing Library + jsdom.
| Test File | What It Tests |
|---|---|
blockToPython.test.ts |
Block structures → Python source code |
pythonToBlocks.test.ts |
Python source → Blockly XML (all supported constructs) |
roundTrip.test.ts |
Round-trip stability: blocks→Python→blocks, Python→blocks→Python |
pyodideRunner.test.ts |
Pyodide loader/runner API (mocked) |
syncController.test.ts |
Sync logic: debounce, source tracking, no-infinite-loops |
BlockPyEditor.test.tsx |
React rendering smoke tests |
Property-based tests (via fast-check) cover round-trip stability for numeric and string literals.
Golden-file tests cover representative Python snippets (assignments, if/else, for loops, function defs).
- Chrome (current stable)
- Firefox (current stable)
- Safari (current stable)
- Edge (current stable)
Pyodide/WebAssembly limitations: Pyodide requires SharedArrayBuffer in some modes; the app uses the single-threaded Pyodide build which works without special HTTP headers.
elifchains are not yet supported (modeled as nestedif/else).- Multi-target assignments (
a = b = c) are not supported. - Augmented assignments (
+=,-=) are not supported. - List/dict/tuple comprehensions are not supported.
try/exceptblocks are not supported.- f-strings produce
py_unsupportedblocks. - Lambda expressions are not supported.
- Decorator syntax is not supported.
- Comments are not preserved through round-trips (they appear as
py_unsupportedblocks). - The Blockly workspace state is not persisted between page reloads.
- Python code is executed entirely in-browser via Pyodide's WebAssembly sandbox.
- No code is sent to any external server.
- Execution has a 10-second timeout.
- No telemetry is collected.
-
CodeMirror parser for Python→Blocks: We use the actual
@codemirror/lang-pythonLezer parser rather than a custom parser. This is more reliable but means we depend on Lezer's CST node names. -
Dynamic Blockly import: Blockly is lazy-loaded to keep the initial bundle smaller and avoid SSR issues.
-
Pyodide via CDN: Pyodide (~7MB) is loaded lazily from jsDelivr only when the user clicks "Run", keeping initial load fast.
-
Sync debounce: 300ms debounce on text changes balances responsiveness against parse cost. The last valid block workspace is always preserved on parse failure.
-
Block schema versioning: The
PYTHON_BLOCK_TYPESconstant serves as the block schema. Future migrations should use thepy_error/py_unsupportedfallback blocks for unknown types.