Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
branches: [main]

jobs:
build:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -16,5 +16,7 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- run: npm run typecheck
- run: npm run lint
- run: npm test
- run: npm run build
50 changes: 50 additions & 0 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Deploys the static Vite build to GitHub Pages.
#
# Requirements (one-time repo setup):
# Settings → Pages → Source: "GitHub Actions".
# Note: on GitHub Free, Pages requires a public repository.
#
# The site is fully client-side. Runtime network dependency: Pyodide is
# loaded from the jsDelivr CDN when a program is first run.
name: Deploy to GitHub Pages

on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
# Served from https://<user>.github.io/coding-circus/, so assets must
# resolve under that subpath.
- run: npm run build -- --base=/coding-circus/
- uses: actions/upload-pages-artifact@v3
with:
path: dist

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
29 changes: 27 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,28 @@ The MVP intentionally does not depend on a backend server — everything (Blockl

## Block engine

Blockly (`blockly` npm package, core only — `blockly/core`) is the block engine. We do not use Blockly's stock toolbox; instead we define our own namespaced `python_*` blocks so the toolbox only ever shows Python-relevant primitives (values, variables, text/print, math, logic, control flow). Block JSON definitions live in `src/blockly/blocks/*.ts`, one file per category, registered via `src/blockly/blocks/index.ts`.
Blockly (`blockly` npm package, core only — `blockly/core`) is the block engine. We do not use Blockly's stock toolbox; instead we define our own namespaced `python_*` blocks so the toolbox only ever shows Python-relevant primitives across twelve categories (Values, Variables, Text, Math, Logic, Control, Input, Lists, Random, Functions, Debug, Stage — see [BLOCKS.md](BLOCKS.md) for the full inventory). Block JSON definitions live in `src/blockly/blocks/*.ts`, one file per category, registered via `src/blockly/blocks/index.ts`.

Code generation reuses Blockly's maintained `pythonGenerator` (from `blockly/python`) rather than a hand-rolled generator, registering a `forBlock[type]` function per custom block type (`src/blockly/generators/*.ts`). This gets us correct operator-precedence parenthesization, indentation, and variable-name legalization for free, while every block we expose is still fully custom. `generatePython()` (`src/blockly/setup.ts`) is the single entry point the UI calls.

### Generator safety rules

Field values are treated as untrusted (corrupt or hand-edited project JSON can contain anything), and `src/blockly/generators/helpers.ts` is the enforcement point:

- Dropdown operators go through `pickOperator` (unknown values fall back to a safe default instead of crashing).
- Number fields go through `safeNumber` (NaN/Infinity become `0`, never invalid Python).
- Comment text goes through `sanitizeInlineText` (line breaks collapse to spaces, so a field value cannot inject extra Python lines).
- Typed function names go through `legalizePythonName` (legal identifier, keyword-safe).
- Blockly's protected `definitions_` (hoisted imports) and `nameDB_` (collision-free loop variables) are only touched via `addDefinition`/`distinctName`, keeping the unsafe casts in one commented place.
- Empty statement branches always generate `pass`.

### Design decisions worth knowing

- **Input blocks** generate standard `input(...)` Python, but the in-browser runner has no interactive stdin: making `input()` block synchronously inside a worker requires `SharedArrayBuffer` + cross-origin isolation headers, which static hosting (GitHub Pages) cannot provide. The runner instead normalizes the resulting `EOFError`/`OSError` into a friendly "export and run with desktop Python" hint. `LocalPythonRunner` (below) would support input natively.
- **Stage blocks** compile to plain `print()` calls. The Stage panel mirrors the latest printed line, and an empty printed line clears it. This keeps exported programs 100% standard Python with no Coding Circus runtime library.
- **Functions have no parameters.** Parameterized functions need Blockly mutators (dynamic block shapes); the beginner set trades that away for simplicity. Define/call blocks are matched by typed name.
- **List indexing is Python-native (0-based)** — the point of the tool is learning real Python, so `lst[0]` is shown as-is rather than Scratch-style 1-based indexing.

If Blockly ever needs to be replaced, the replacement only needs to (a) render a workspace, (b) fire a change event the UI can listen to, and (c) let `generatePython`-equivalent code walk it — nothing else in the app depends on Blockly internals directly except `BlockEditor.tsx` and the `blockly/` directory.

## Runner abstraction
Expand Down Expand Up @@ -66,4 +84,11 @@ These share the same `RunnerInterface`/`RunResult` contract, so the UI (`App.tsx

- **Save/Load**: the Blockly workspace is serialized with `Blockly.serialization.workspaces.save(workspace)` (plain JSON, not XML) and stored in `localStorage`, keyed by project name, with an index key tracking known project names (`src/project/ProjectStorage.ts`).
- **Export**: `Export .py` downloads the currently generated Python source. `Export project` downloads a `.json` file (`{ formatVersion, name, updatedAt, workspaceJson }`) that fully round-trips through `Import project`.
- Projects are local-only in the MVP (no accounts, no sync) — consistent with "no backend server."
- **Untrusted-data posture** (`src/project/validation.ts`): every read path assumes the data may be corrupt. Imported files are shape-validated (with a distinct "made with a newer version" message for future `formatVersion`s — the migration hook lives in `validateProjectFile`), stored entries that fail parsing load as `null` instead of throwing, the name index self-heals, download filenames are sanitized, and workspace deserialization failures surface a console message over a cleared workspace instead of crashing. Name collisions on import are renamed `"(imported)"`.
- Projects are local-only (no accounts, no sync) — consistent with "no backend server."

## Static site & deployment

- The app is a fully client-side Vite build (`npm run build` → `dist/`), hostable on any static file server. The only runtime network dependency is the Pyodide CDN fetch on first Run (disclosed on the landing screen).
- First visit shows a landing hero (`Landing.tsx`) with a "Start Coding" CTA and an opt-in self-building demo (`LiveDemo.tsx` + `src/demo/`); starter projects live in `src/examples/` as workspace JSON and load through the same guarded path as saved projects.
- GitHub Pages: `.github/workflows/deploy-pages.yml` builds with `--base=/coding-circus/` and deploys via `actions/deploy-pages`. Blockly's media path is `BASE_URL`-relative so the editor works from a subpath. Pages must be enabled with source "GitHub Actions" (public repo required on GitHub Free).
49 changes: 49 additions & 0 deletions BLOCKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Block Inventory

Every custom block, its category, and the Python it generates. All blocks are
covered by the generator test suite (`npm test`); "Notes" flags runtime
caveats, not codegen problems.

| Block type | Category | Generated Python | Status | Notes |
| --- | --- | --- | --- | --- |
| `python_string` | Values | `'text'` | ✅ Stable | Escaped via Blockly's `quote_` |
| `python_number` | Values | `42`, `3.5` | ✅ Stable | NaN/Infinity guard → `0` |
| `python_boolean` | Values | `True` / `False` | ✅ Stable | |
| `python_var_set` | Variables | `name = value` | ✅ Stable | Blockly-safe variable naming |
| `python_var_get` | Variables | `name` | ✅ Stable | |
| `python_print` | Text | `print(value)` | ✅ Stable | |
| `python_join` | Text | `str(a) + str(b)` | ✅ Stable | |
| `python_comment` | Text / Debug | `# text` | ✅ Stable | Newline-injection sanitized |
| `python_math_op` | Math | `a + b`, `a // b`, … | ✅ Stable | Unknown operator falls back to `+` |
| `python_compare` | Math | `a == b`, `a > b`, … | ✅ Stable | Unknown operator falls back to `==` |
| `python_logic_op` | Logic | `a and b` / `a or b` | ✅ Stable | |
| `python_not` | Logic | `not a` | ✅ Stable | |
| `python_if` | Control | `if cond:` | ✅ Stable | Empty branch → `pass` |
| `python_if_else` | Control | `if cond: … else: …` | ✅ Stable | Empty branches → `pass` |
| `python_repeat` | Control | `for count in range(n):` | ✅ Stable | Loop var collision-safe |
| `python_while` | Control | `while cond:` | ✅ Stable | |
| `python_repeat_until` | Control | `while not cond:` | ✅ Stable | |
| `python_count_with` | Control | `for i in range(a, b + 1):` | ✅ Stable | Inclusive upper bound |
| `python_break` | Control | `break` | ✅ Stable | Only valid inside a loop |
| `python_continue` | Control | `continue` | ✅ Stable | Only valid inside a loop |
| `python_wait` | Control | `time.sleep(s)` | ✅ Stable | Hoists `import time` once |
| `python_ask_text` | Input | `input(q)` | ✅ Stable | ⚠️ No stdin in browser runner — export to run |
| `python_ask_number` | Input | `float(input(q))` | ✅ Stable | ⚠️ Same browser limitation |
| `python_ask_integer` | Input | `int(input(q))` | ✅ Stable | ⚠️ Same browser limitation |
| `python_list_create` | Lists | `[a, b, c]` | ✅ Stable | Up to 3 items; empty slots skipped |
| `python_list_append` | Lists | `lst.append(x)` | ✅ Stable | |
| `python_list_get` | Lists | `lst[i]` | ✅ Stable | Python 0-based indexing |
| `python_list_length` | Lists | `len(x)` | ✅ Stable | |
| `python_for_each` | Lists | `for item in lst:` | ✅ Stable | Empty body → `pass` |
| `python_random_int` | Random | `random.randint(a, b)` | ✅ Stable | Hoists `import random` once |
| `python_random_float` | Random | `random.random()` | ✅ Stable | |
| `python_random_choice` | Random | `random.choice(lst)` | ✅ Stable | |
| `python_def` | Functions | `def name():` | ✅ Stable | No parameters (see ARCHITECTURE.md); names legalized |
| `python_call` | Functions | `name()` | ✅ Stable | Name-matched to the definition |
| `python_call_value` | Functions | `name()` (as value) | ✅ Stable | |
| `python_return` | Functions | `return value` | ✅ Stable | Only valid inside a function |
| `python_print_var` | Debug | `print('x', '=', x)` | ✅ Stable | |
| `python_show_type` | Debug | `type(v).__name__` | ✅ Stable | |
| `python_assert` | Debug | `assert cond, 'msg'` | ✅ Stable | |
| `python_say` | Stage | `print(value)` | ✅ Stable | Stage mirrors printed lines |
| `python_clear_stage` | Stage | `print()` | ✅ Stable | Empty printed line clears the stage |
67 changes: 48 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,28 @@ explained in plain language → save, load, or export the project.

## Highlights

- **Custom Python-focused Blockly blocks** — print, variables, math, logic,
control flow, and a `wait` block for timing/animation — each with a
deterministic, readable Python generator (see [`src/blockly/`](src/blockly)).
- **40+ custom Python-focused Blockly blocks** across twelve categories —
values, variables, text, math, logic, control flow (incl. break/continue,
repeat-until, counted loops), input, lists, random, functions, debugging
helpers, and stage output. Full inventory with generated Python per block:
[BLOCKS.md](BLOCKS.md).
- **Deterministic, beginner-readable Python** — with hardened generators:
corrupt project data degrades to safe output (`pass`, `0`, sanitized
comments) instead of crashes or invalid syntax.
- **Starter examples** — Hello World, Variables, If/Else, Repeat Loop, Input,
Lists, and Random, loadable from the toolbar's Examples menu.
- **Landing + live demo** — a first-visit hero with a "Start Coding" CTA and
an opt-in demo where the app builds, runs, and saves a real program in
front of you using the actual editor.
- **Pluggable runner abstraction** — `BrowserPyodideRunner` is the first
implementation of `RunnerInterface`; `LocalPythonRunner` and
`DockerSandboxRunner` are documented future targets (see
[`ARCHITECTURE.md`](ARCHITECTURE.md)).
[ARCHITECTURE.md](ARCHITECTURE.md)).
- **Beginner-friendly error messages** — tracebacks are normalized into plain
language with an "advanced" toggle for the raw traceback.
- **Project persistence** — save/load via `localStorage`, export to `.py` or
a portable `.json` project file, import it back.
- **Live demo splash screen** — on first load, the app demonstrates itself:
real blocks drag in from the real palette, assemble a working program,
run it, and save it, before handing control to you.
- **Safe project persistence** — save/load via `localStorage`, export to
`.py` or a portable `.json` project file, import it back. Malformed or
corrupt project files are rejected with clear messages, never crashes.

## Getting started

Expand All @@ -33,34 +41,55 @@ npm install
npm run dev
```

Then open the printed local URL. `npm run build` produces a static
production build (`dist/`) — the whole app is client-side, so it can be
hosted anywhere that serves static files.
Then open the printed local URL.

> **Runtime note:** Python execution uses Pyodide, fetched from the jsDelivr
> CDN the first time you press Run. Everything else is fully local.
>
> **Known limitation:** the `ask …` (input) blocks generate correct
> `input()` Python, but the browser runner has no keyboard stdin — export
> your program as `.py` and run it with desktop Python to use them.

## Scripts

| Command | Description |
| --- | --- |
| `npm run dev` | Start the Vite dev server |
| `npm run build` | Type-check (`tsc -b`) and build for production |
| `npm run preview` | Preview the production build locally |
| `npm run typecheck` | Type-check only |
| `npm test` | Run the Vitest suite (block generation + persistence safety) |
| `npm run lint` | Run Oxlint |
| `npm run preview` | Preview the production build locally |

## Deployment (GitHub Pages)

The app is a static site. `.github/workflows/deploy-pages.yml` builds with
`--base=/coding-circus/` and deploys `dist/` to GitHub Pages on every push to
`main`.

One-time setup: **Settings → Pages → Source: "GitHub Actions"**. On GitHub
Free, Pages requires the repository to be public.

To host anywhere else, run `npm run build` (add `-- --base=/your-path/` if
serving from a subpath) and upload `dist/`.

## Project structure

```
src/
blockly/ Custom block definitions, Python generators, toolbox
blockly/ Custom block definitions, Python generators (+ safety helpers), toolbox
runner/ RunnerInterface, BrowserPyodideRunner, Pyodide worker
project/ Save/load/export (localStorage + file-based)
project/ Save/load/export with untrusted-data validation
examples/ Starter projects (workspace JSON)
demo/ The live block-building demo script + player
components/ React UI: editor, code/console/stage panels, toolbar
components/ React UI: landing, editor, code/console/stage panels, toolbar
```

See [`ARCHITECTURE.md`](ARCHITECTURE.md) for the deeper technical rationale
behind the runner abstraction and future backend-execution targets.
See [ARCHITECTURE.md](ARCHITECTURE.md) for the deeper technical rationale
behind the block system, runner abstraction, persistence posture, and
deployment; [BLOCKS.md](BLOCKS.md) for the block-by-block inventory.

## Tech stack

Vite, React 19, TypeScript, [Blockly](https://developers.google.com/blockly),
[Pyodide](https://pyodide.org/).
[Pyodide](https://pyodide.org/), Vitest.
Loading
Loading