From 60ea1ebc077cd357043545208a4669bdba1d04c9 Mon Sep 17 00:00:00 2001 From: TheRealBrofessor <196406002+TheRealBrofessor@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:20:44 -0400 Subject: [PATCH 1/8] Harden block generators and workspace loading - Add generators/helpers.ts: validated dropdown lookup, NaN/Infinity-safe number literals, newline-stripping sanitizer for comment text, Python identifier legalizer, and single-point wrappers for Blockly's protected definitions_/nameDB_ APIs. - Route math/compare operators, number literals, and comment text through the helpers so corrupt project JSON degrades to blander Python instead of crashing generation or emitting invalid syntax. - Make workspace loading non-throwing: corrupt saved/imported projects now surface a console message and leave a cleared workspace instead of crashing the app; imports that collide with a saved project name are renamed "(imported)". Co-Authored-By: Claude Fable 5 --- src/App.tsx | 54 ++++++++++++++---- src/blockly/generators/control.ts | 6 +- src/blockly/generators/helpers.ts | 95 +++++++++++++++++++++++++++++++ src/blockly/generators/math.ts | 5 +- src/blockly/generators/text.ts | 5 +- src/blockly/generators/values.ts | 6 +- 6 files changed, 152 insertions(+), 19 deletions(-) create mode 100644 src/blockly/generators/helpers.ts diff --git a/src/App.tsx b/src/App.tsx index 8be93a5..a8a8eed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,9 +16,23 @@ import './App.css'; const RUN_TIMEOUT_MS = 20_000; const INTRO_SESSION_KEY = 'coding-circus:intro-seen'; -function loadWorkspaceState(workspace: Blockly.Workspace, state: unknown): void { +/** + * Loads serialized workspace state, treating the input as untrusted: a corrupt + * or hand-edited project must degrade to an error message, never a crash. On + * failure the workspace is left cleared (not half-loaded). + */ +function loadWorkspaceState(workspace: Blockly.Workspace, state: unknown): { ok: true } | { ok: false; message: string } { workspace.clear(); - Blockly.serialization.workspaces.load(state as never, workspace); + try { + Blockly.serialization.workspaces.load(state as never, workspace); + return { ok: true }; + } catch (err) { + workspace.clear(); + return { + ok: false, + message: `Could not load this project — its block data appears to be corrupted. (${err instanceof Error ? err.message : String(err)})`, + }; + } } export default function App() { @@ -100,13 +114,24 @@ export default function App() { setSavedProjects(listProjects()); }, [projectName]); - const handleLoad = useCallback((name: string) => { - const workspace = workspaceRef.current; - const project = loadProject(name); - if (!workspace || !project) return; - loadWorkspaceState(workspace, project.workspaceJson); - setProjectName(project.name); - }, []); + const handleLoad = useCallback( + (name: string) => { + const workspace = workspaceRef.current; + if (!workspace) return; + const project = loadProject(name); + if (!project) { + appendConsole('system', `Could not load "${name}" — the saved data is missing or corrupted.`); + return; + } + const result = loadWorkspaceState(workspace, project.workspaceJson); + if (!result.ok) { + appendConsole('system', result.message); + return; + } + setProjectName(project.name); + }, + [appendConsole], + ); const handleExportPython = useCallback(() => { exportPython(projectName, code); @@ -150,8 +175,15 @@ export default function App() { if (!workspace) return; try { const project = await readProjectJsonFile(file); - loadWorkspaceState(workspace, project.workspaceJson); - setProjectName(project.name); + const result = loadWorkspaceState(workspace, project.workspaceJson); + if (!result.ok) { + appendConsole('system', result.message); + return; + } + // If a saved project already uses this name, rename the import so a + // later Save doesn't silently overwrite the existing one. + const name = listProjects().includes(project.name) ? `${project.name} (imported)` : project.name; + setProjectName(name); } catch (err) { appendConsole('system', err instanceof Error ? err.message : 'Could not import that file.'); } diff --git a/src/blockly/generators/control.ts b/src/blockly/generators/control.ts index 6f28e14..e951b33 100644 --- a/src/blockly/generators/control.ts +++ b/src/blockly/generators/control.ts @@ -1,5 +1,5 @@ -import * as Blockly from 'blockly/core'; import { pythonGenerator, Order } from 'blockly/python'; +import { addDefinition, distinctName } from './helpers'; function branchOrPass(branch: string): string { return branch || `${pythonGenerator.INDENT}pass\n`; @@ -21,7 +21,7 @@ pythonGenerator.forBlock['python_if_else'] = function (block, generator) { pythonGenerator.forBlock['python_repeat'] = function (block, generator) { const times = generator.valueToCode(block, 'TIMES', Order.NONE) || '0'; const branch = branchOrPass(generator.statementToCode(block, 'DO')); - const loopVar = generator.nameDB_!.getDistinctName('count', Blockly.Names.NameType.VARIABLE); + const loopVar = distinctName(generator, 'count'); return `for ${loopVar} in range(${times}):\n${branch}`; }; @@ -32,7 +32,7 @@ pythonGenerator.forBlock['python_while'] = function (block, generator) { }; pythonGenerator.forBlock['python_wait'] = function (block, generator) { - (generator as unknown as { definitions_: Record }).definitions_['import_time'] = 'import time'; + addDefinition(generator, 'import_time', 'import time'); const seconds = generator.valueToCode(block, 'SECONDS', Order.NONE) || '0'; return `time.sleep(${seconds})\n`; }; diff --git a/src/blockly/generators/helpers.ts b/src/blockly/generators/helpers.ts new file mode 100644 index 0000000..709d6e5 --- /dev/null +++ b/src/blockly/generators/helpers.ts @@ -0,0 +1,95 @@ +import type { PythonGenerator } from 'blockly/python'; + +/** + * Shared safety helpers for the Python generators. + * + * Corrupt or hand-edited project JSON can put arbitrary values into dropdown + * fields, number fields, and text fields. Nothing that comes out of a field is + * trusted to be well-formed here — every generator routes field values through + * one of these helpers so the worst possible outcome is blander Python, never + * a generator crash or a Python syntax error. + */ + +/** + * Looks up a dropdown operator in a table, falling back to a known-good key + * when the stored value is missing or unrecognized (e.g. corrupt import). + */ +export function pickOperator(table: Record, key: unknown, fallbackKey: string): T { + if (typeof key === 'string' && key in table) return table[key]; + return table[fallbackKey]; +} + +/** + * Converts a number-field value to a Python-safe numeric literal. + * NaN/Infinity (possible via corrupt JSON) become "0" instead of invalid Python. + */ +export function safeNumber(raw: unknown): string { + const n = Number(raw); + if (!Number.isFinite(n)) return '0'; + return String(n); +} + +/** + * Strips line breaks and control characters from single-line text destined for + * comments or other non-quoted contexts, so a crafted field value cannot + * inject extra Python lines into the generated program. + */ +export function sanitizeInlineText(raw: unknown): string { + if (typeof raw !== 'string') return ''; + return ( + raw + .replace(/[\r\n\u2028\u2029]+/g, ' ') + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '') + ); +} + +const PYTHON_KEYWORDS = new Set([ + 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', + 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', + 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', + 'with', 'yield', 'print', 'input', 'len', 'range', 'type', +]); + +/** + * Turns free-form text (e.g. a typed function name) into a legal, collision-safe + * Python identifier. Falls back to the given default when nothing usable remains. + */ +export function legalizePythonName(raw: unknown, fallback: string): string { + const cleaned = (typeof raw === 'string' ? raw : '') + .trim() + .replace(/[^A-Za-z0-9_]+/g, '_') + .replace(/^_+|_+$/g, ''); + let name = cleaned || fallback; + if (/^[0-9]/.test(name)) name = `_${name}`; + if (PYTHON_KEYWORDS.has(name)) name = `${name}_`; + return name; +} + +/** + * Registers a top-of-file definition (like an import) exactly once. + * + * This is the single sanctioned crossing into Blockly's protected + * `definitions_` map — TypeScript marks it protected, but it is the documented + * mechanism the stock generators themselves use for hoisted imports, and + * funnelling every use through this helper keeps the unsafe cast in one place. + */ +export function addDefinition(generator: PythonGenerator, key: string, code: string): void { + (generator as unknown as { definitions_: Record }).definitions_[key] = code; +} + +/** + * Reserves a variable name that will not collide with any user variable. + * + * Wraps Blockly's internal `nameDB_` (initialised by `pythonGenerator.init`); + * if it is somehow absent, the base name is returned unchanged, which is still + * valid Python — just with a small collision risk instead of a crash. + */ +export function distinctName(generator: PythonGenerator, base: string): string { + const nameDB = ( + generator as unknown as { + nameDB_?: { getDistinctName(name: string, type: string): string }; + } + ).nameDB_; + return nameDB ? nameDB.getDistinctName(base, 'VARIABLE') : base; +} diff --git a/src/blockly/generators/math.ts b/src/blockly/generators/math.ts index 1dae129..32c55da 100644 --- a/src/blockly/generators/math.ts +++ b/src/blockly/generators/math.ts @@ -1,4 +1,5 @@ import { pythonGenerator, Order } from 'blockly/python'; +import { pickOperator } from './helpers'; const ARITHMETIC_OPERATORS: Record = { ADD: ['+', Order.ADDITIVE], @@ -20,14 +21,14 @@ const COMPARE_OPERATORS: Record = { }; pythonGenerator.forBlock['python_math_op'] = function (block, generator) { - const [symbol, order] = ARITHMETIC_OPERATORS[block.getFieldValue('OP')]; + const [symbol, order] = pickOperator(ARITHMETIC_OPERATORS, block.getFieldValue('OP'), 'ADD'); const a = generator.valueToCode(block, 'A', order) || '0'; const b = generator.valueToCode(block, 'B', order) || '0'; return [`${a} ${symbol} ${b}`, order]; }; pythonGenerator.forBlock['python_compare'] = function (block, generator) { - const symbol = COMPARE_OPERATORS[block.getFieldValue('OP')]; + const symbol = pickOperator(COMPARE_OPERATORS, block.getFieldValue('OP'), 'EQ'); const a = generator.valueToCode(block, 'A', Order.RELATIONAL) || 'None'; const b = generator.valueToCode(block, 'B', Order.RELATIONAL) || 'None'; return [`${a} ${symbol} ${b}`, Order.RELATIONAL]; diff --git a/src/blockly/generators/text.ts b/src/blockly/generators/text.ts index 82b09dc..1548c2d 100644 --- a/src/blockly/generators/text.ts +++ b/src/blockly/generators/text.ts @@ -1,4 +1,5 @@ import { pythonGenerator, Order } from 'blockly/python'; +import { sanitizeInlineText } from './helpers'; pythonGenerator.forBlock['python_print'] = function (block, generator) { const value = generator.valueToCode(block, 'VALUE', Order.NONE) || "''"; @@ -12,5 +13,7 @@ pythonGenerator.forBlock['python_join'] = function (block, generator) { }; pythonGenerator.forBlock['python_comment'] = function (block) { - return `# ${block.getFieldValue('TEXT')}\n`; + // Sanitized so a line break typed (or imported) into the field cannot + // escape the comment and become executable Python. + return `# ${sanitizeInlineText(block.getFieldValue('TEXT'))}\n`; }; diff --git a/src/blockly/generators/values.ts b/src/blockly/generators/values.ts index 09198c9..1ca62af 100644 --- a/src/blockly/generators/values.ts +++ b/src/blockly/generators/values.ts @@ -1,11 +1,13 @@ import { pythonGenerator, Order } from 'blockly/python'; +import { safeNumber } from './helpers'; pythonGenerator.forBlock['python_string'] = function (block) { - return [pythonGenerator.quote_(block.getFieldValue('TEXT')), Order.ATOMIC]; + // quote_ handles escaping (including newlines) — the value always stays inside the string literal. + return [pythonGenerator.quote_(String(block.getFieldValue('TEXT') ?? '')), Order.ATOMIC]; }; pythonGenerator.forBlock['python_number'] = function (block) { - return [String(Number(block.getFieldValue('NUM'))), Order.ATOMIC]; + return [safeNumber(block.getFieldValue('NUM')), Order.ATOMIC]; }; pythonGenerator.forBlock['python_boolean'] = function (block) { From 05a1ddf6b4c817a3462367d95d8c7675904f3f92 Mon Sep 17 00:00:00 2001 From: TheRealBrofessor <196406002+TheRealBrofessor@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:28:32 -0400 Subject: [PATCH 2/8] Add beginner block categories: Input, Lists, Random, loop control, Functions, Debug, Stage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Input: ask text / ask number / ask whole number -> input(), float(input()), int(input()). Standard readable Python; the browser runner has no stdin, so EOFError/OSError now normalize to a friendly "export and run with desktop Python" hint. - Lists: create (up to 3 items), append, get by index (Python 0-based), length, for-each loop. - Random: randint, random(), choice — hoisting "import random" once. - Control additions: repeat-until, count-with (inclusive range), break, continue. - Functions: name-matched define/call/call-for-value/return with typed names legalized into safe Python identifiers (no parameters; mutators out of scope). - Debug: print variable with its name, type-of value, assert with message. - Stage: "show on stage" and "clear the stage" compile to plain print() calls; the app's stage now clears when an empty line is printed, keeping exported programs 100% standard Python. - Toolbox: six new categories with beginner-friendly shadow defaults. Co-Authored-By: Claude Fable 5 --- src/App.tsx | 6 ++ src/blockly/blocks/control.ts | 45 +++++++++++++ src/blockly/blocks/debug.ts | 40 +++++++++++ src/blockly/blocks/functions.ts | 48 +++++++++++++ src/blockly/blocks/index.ts | 6 ++ src/blockly/blocks/input.ts | 36 ++++++++++ src/blockly/blocks/lists.ts | 72 ++++++++++++++++++++ src/blockly/blocks/random.ts | 37 ++++++++++ src/blockly/blocks/stage.ts | 29 ++++++++ src/blockly/generators/control.ts | 22 ++++++ src/blockly/generators/debug.ts | 18 +++++ src/blockly/generators/functions.ts | 27 ++++++++ src/blockly/generators/index.ts | 6 ++ src/blockly/generators/input.ts | 21 ++++++ src/blockly/generators/lists.ts | 32 +++++++++ src/blockly/generators/random.ts | 20 ++++++ src/blockly/generators/stage.ts | 14 ++++ src/blockly/toolbox.ts | 101 ++++++++++++++++++++++++++++ src/runner/errorNormalization.ts | 6 ++ 19 files changed, 586 insertions(+) create mode 100644 src/blockly/blocks/debug.ts create mode 100644 src/blockly/blocks/functions.ts create mode 100644 src/blockly/blocks/input.ts create mode 100644 src/blockly/blocks/lists.ts create mode 100644 src/blockly/blocks/random.ts create mode 100644 src/blockly/blocks/stage.ts create mode 100644 src/blockly/generators/debug.ts create mode 100644 src/blockly/generators/functions.ts create mode 100644 src/blockly/generators/input.ts create mode 100644 src/blockly/generators/lists.ts create mode 100644 src/blockly/generators/random.ts create mode 100644 src/blockly/generators/stage.ts diff --git a/src/App.tsx b/src/App.tsx index a8a8eed..fcf6cb3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -79,6 +79,12 @@ export default function App() { timeoutMs: RUN_TIMEOUT_MS, onStdout: (chunk) => { appendConsole('stdout', chunk); + // Stage convention: the stage mirrors the latest printed line, and an + // empty printed line (the "clear the stage" block) blanks it. + if (chunk.trim() === '') { + setStageText(''); + return; + } const lastLine = chunk.split('\n').filter((l) => l.length > 0).pop(); if (lastLine) setStageText(lastLine); }, diff --git a/src/blockly/blocks/control.ts b/src/blockly/blocks/control.ts index 60d7293..cb195d8 100644 --- a/src/blockly/blocks/control.ts +++ b/src/blockly/blocks/control.ts @@ -63,4 +63,49 @@ Blockly.common.defineBlocksWithJsonArray([ tooltip: 'Pause the program for a number of seconds. Useful for animations.', helpUrl: '', }, + { + type: 'python_repeat_until', + message0: 'repeat until %1', + args0: [{ type: 'input_value', name: 'CONDITION', check: 'Boolean' }], + message1: '%1', + args1: [{ type: 'input_statement', name: 'DO' }], + previousStatement: null, + nextStatement: null, + colour: HUE, + tooltip: 'Keep running the enclosed blocks until the condition becomes true.', + helpUrl: '', + }, + { + type: 'python_count_with', + message0: 'count with %1 from %2 to %3', + args0: [ + { type: 'field_variable', name: 'VAR', variable: 'i' }, + { type: 'input_value', name: 'FROM', check: 'Number' }, + { type: 'input_value', name: 'TO', check: 'Number' }, + ], + inputsInline: true, + message1: '%1', + args1: [{ type: 'input_statement', name: 'DO' }], + previousStatement: null, + nextStatement: null, + colour: HUE, + tooltip: 'Count from the first number to the second (both included), running the blocks each time.', + helpUrl: '', + }, + { + type: 'python_break', + message0: 'break out of loop', + previousStatement: null, + colour: HUE, + tooltip: 'Stop the loop this block is inside immediately.', + helpUrl: '', + }, + { + type: 'python_continue', + message0: 'skip to next loop turn', + previousStatement: null, + colour: HUE, + tooltip: 'Skip the rest of this loop turn and start the next one.', + helpUrl: '', + }, ]); diff --git a/src/blockly/blocks/debug.ts b/src/blockly/blocks/debug.ts new file mode 100644 index 0000000..08b11d1 --- /dev/null +++ b/src/blockly/blocks/debug.ts @@ -0,0 +1,40 @@ +import * as Blockly from 'blockly/core'; + +const HUE = 65; + +Blockly.common.defineBlocksWithJsonArray([ + { + type: 'python_print_var', + message0: 'print variable %1 with its name', + args0: [{ type: 'field_variable', name: 'VAR', variable: 'item' }], + previousStatement: null, + nextStatement: null, + colour: HUE, + tooltip: 'Print a variable together with its name, like "score = 10". Handy for debugging.', + helpUrl: '', + }, + { + type: 'python_show_type', + message0: 'type of %1', + args0: [{ type: 'input_value', name: 'VALUE' }], + inputsInline: true, + output: 'String', + colour: HUE, + tooltip: "Get a value's Python type name, like int, str, or list.", + helpUrl: '', + }, + { + type: 'python_assert', + message0: 'check that %1 or stop with %2', + args0: [ + { type: 'input_value', name: 'CONDITION', check: 'Boolean' }, + { type: 'field_input', name: 'MESSAGE', text: 'something went wrong' }, + ], + inputsInline: true, + previousStatement: null, + nextStatement: null, + colour: HUE, + tooltip: 'Stop the program with a message if the condition is not true.', + helpUrl: '', + }, +]); diff --git a/src/blockly/blocks/functions.ts b/src/blockly/blocks/functions.ts new file mode 100644 index 0000000..e3b72b2 --- /dev/null +++ b/src/blockly/blocks/functions.ts @@ -0,0 +1,48 @@ +import * as Blockly from 'blockly/core'; + +const HUE = 290; + +// Simple name-matched functions (no parameters). Full parameter support needs +// Blockly mutators, which is out of scope for the beginner block set — see +// ARCHITECTURE.md. +Blockly.common.defineBlocksWithJsonArray([ + { + type: 'python_def', + message0: 'define function %1', + args0: [{ type: 'field_input', name: 'NAME', text: 'my_function' }], + message1: '%1', + args1: [{ type: 'input_statement', name: 'DO' }], + colour: HUE, + tooltip: 'Create a reusable function. Give it a name, then call it by that name.', + helpUrl: '', + }, + { + type: 'python_call', + message0: 'call function %1', + args0: [{ type: 'field_input', name: 'NAME', text: 'my_function' }], + previousStatement: null, + nextStatement: null, + colour: HUE, + tooltip: 'Run a function you defined. The name must match the definition.', + helpUrl: '', + }, + { + type: 'python_call_value', + message0: 'result of calling %1', + args0: [{ type: 'field_input', name: 'NAME', text: 'my_function' }], + output: null, + colour: HUE, + tooltip: "Run a function and use the value it returns.", + helpUrl: '', + }, + { + type: 'python_return', + message0: 'return %1', + args0: [{ type: 'input_value', name: 'VALUE' }], + inputsInline: true, + previousStatement: null, + colour: HUE, + tooltip: 'Give a value back from inside a function. Only works inside "define function".', + helpUrl: '', + }, +]); diff --git a/src/blockly/blocks/index.ts b/src/blockly/blocks/index.ts index 68e9c36..f9137eb 100644 --- a/src/blockly/blocks/index.ts +++ b/src/blockly/blocks/index.ts @@ -7,3 +7,9 @@ import './text'; import './math'; import './logic'; import './control'; +import './input'; +import './lists'; +import './random'; +import './functions'; +import './debug'; +import './stage'; diff --git a/src/blockly/blocks/input.ts b/src/blockly/blocks/input.ts new file mode 100644 index 0000000..c0fd8c7 --- /dev/null +++ b/src/blockly/blocks/input.ts @@ -0,0 +1,36 @@ +import * as Blockly from 'blockly/core'; + +const HUE = 20; + +Blockly.common.defineBlocksWithJsonArray([ + { + type: 'python_ask_text', + message0: 'ask %1 and get text', + args0: [{ type: 'input_value', name: 'QUESTION', check: 'String' }], + inputsInline: true, + output: 'String', + colour: HUE, + tooltip: 'Ask a question and get the answer as text. (In the browser runner, export your program to use this.)', + helpUrl: '', + }, + { + type: 'python_ask_number', + message0: 'ask %1 and get a number', + args0: [{ type: 'input_value', name: 'QUESTION', check: 'String' }], + inputsInline: true, + output: 'Number', + colour: HUE, + tooltip: 'Ask a question and get the answer as a number (decimals allowed).', + helpUrl: '', + }, + { + type: 'python_ask_integer', + message0: 'ask %1 and get a whole number', + args0: [{ type: 'input_value', name: 'QUESTION', check: 'String' }], + inputsInline: true, + output: 'Number', + colour: HUE, + tooltip: 'Ask a question and get the answer as a whole number.', + helpUrl: '', + }, +]); diff --git a/src/blockly/blocks/lists.ts b/src/blockly/blocks/lists.ts new file mode 100644 index 0000000..f300852 --- /dev/null +++ b/src/blockly/blocks/lists.ts @@ -0,0 +1,72 @@ +import * as Blockly from 'blockly/core'; + +const HUE = 260; + +Blockly.common.defineBlocksWithJsonArray([ + { + type: 'python_list_create', + message0: 'list of %1 %2 %3', + args0: [ + { type: 'input_value', name: 'ITEM0' }, + { type: 'input_value', name: 'ITEM1' }, + { type: 'input_value', name: 'ITEM2' }, + ], + inputsInline: true, + output: 'Array', + colour: HUE, + tooltip: 'Make a list with up to three starting items. Leave slots empty for a shorter list.', + helpUrl: '', + }, + { + type: 'python_list_append', + message0: 'add %1 to list %2', + args0: [ + { type: 'input_value', name: 'ITEM' }, + { type: 'input_value', name: 'LIST', check: 'Array' }, + ], + inputsInline: true, + previousStatement: null, + nextStatement: null, + colour: HUE, + tooltip: 'Add an item to the end of a list.', + helpUrl: '', + }, + { + type: 'python_list_get', + message0: 'item at index %1 of %2', + args0: [ + { type: 'input_value', name: 'INDEX', check: 'Number' }, + { type: 'input_value', name: 'LIST', check: 'Array' }, + ], + inputsInline: true, + output: null, + colour: HUE, + tooltip: 'Get one item from a list. Python counts from 0: index 0 is the first item.', + helpUrl: '', + }, + { + type: 'python_list_length', + message0: 'length of %1', + args0: [{ type: 'input_value', name: 'LIST' }], + inputsInline: true, + output: 'Number', + colour: HUE, + tooltip: 'How many items a list (or characters a text) has.', + helpUrl: '', + }, + { + type: 'python_for_each', + message0: 'for each %1 in list %2', + args0: [ + { type: 'field_variable', name: 'VAR', variable: 'item' }, + { type: 'input_value', name: 'LIST', check: 'Array' }, + ], + message1: '%1', + args1: [{ type: 'input_statement', name: 'DO' }], + colour: HUE, + previousStatement: null, + nextStatement: null, + tooltip: 'Run the enclosed blocks once for every item in the list.', + helpUrl: '', + }, +]); diff --git a/src/blockly/blocks/random.ts b/src/blockly/blocks/random.ts new file mode 100644 index 0000000..7a0158a --- /dev/null +++ b/src/blockly/blocks/random.ts @@ -0,0 +1,37 @@ +import * as Blockly from 'blockly/core'; + +const HUE = 40; + +Blockly.common.defineBlocksWithJsonArray([ + { + type: 'python_random_int', + message0: 'random whole number from %1 to %2', + args0: [ + { type: 'input_value', name: 'FROM', check: 'Number' }, + { type: 'input_value', name: 'TO', check: 'Number' }, + ], + inputsInline: true, + output: 'Number', + colour: HUE, + tooltip: 'Pick a random whole number between the two values (both included).', + helpUrl: '', + }, + { + type: 'python_random_float', + message0: 'random decimal from 0 to 1', + output: 'Number', + colour: HUE, + tooltip: 'Pick a random decimal number between 0 and 1.', + helpUrl: '', + }, + { + type: 'python_random_choice', + message0: 'random item from list %1', + args0: [{ type: 'input_value', name: 'LIST', check: 'Array' }], + inputsInline: true, + output: null, + colour: HUE, + tooltip: 'Pick one item from a list at random.', + helpUrl: '', + }, +]); diff --git a/src/blockly/blocks/stage.ts b/src/blockly/blocks/stage.ts new file mode 100644 index 0000000..38ff5aa --- /dev/null +++ b/src/blockly/blocks/stage.ts @@ -0,0 +1,29 @@ +import * as Blockly from 'blockly/core'; + +const HUE = 180; + +// Stage blocks generate plain print() calls — the Stage panel mirrors printed +// output, and an empty printed line clears it (see ARCHITECTURE.md). Exported +// programs therefore stay 100% standard Python. +Blockly.common.defineBlocksWithJsonArray([ + { + type: 'python_say', + message0: 'show %1 on stage', + args0: [{ type: 'input_value', name: 'VALUE' }], + inputsInline: true, + previousStatement: null, + nextStatement: null, + colour: HUE, + tooltip: 'Show a message big on the stage (it also prints to the console).', + helpUrl: '', + }, + { + type: 'python_clear_stage', + message0: 'clear the stage', + previousStatement: null, + nextStatement: null, + colour: HUE, + tooltip: 'Clear the stage display (prints an empty line).', + helpUrl: '', + }, +]); diff --git a/src/blockly/generators/control.ts b/src/blockly/generators/control.ts index e951b33..7c8f4a3 100644 --- a/src/blockly/generators/control.ts +++ b/src/blockly/generators/control.ts @@ -36,3 +36,25 @@ pythonGenerator.forBlock['python_wait'] = function (block, generator) { const seconds = generator.valueToCode(block, 'SECONDS', Order.NONE) || '0'; return `time.sleep(${seconds})\n`; }; + +pythonGenerator.forBlock['python_repeat_until'] = function (block, generator) { + const condition = generator.valueToCode(block, 'CONDITION', Order.LOGICAL_NOT) || 'True'; + const branch = branchOrPass(generator.statementToCode(block, 'DO')); + return `while not ${condition}:\n${branch}`; +}; + +pythonGenerator.forBlock['python_count_with'] = function (block, generator) { + const loopVar = generator.getVariableName(block.getFieldValue('VAR')); + const from = generator.valueToCode(block, 'FROM', Order.NONE) || '0'; + const to = generator.valueToCode(block, 'TO', Order.ADDITIVE) || '0'; + const branch = branchOrPass(generator.statementToCode(block, 'DO')); + return `for ${loopVar} in range(${from}, ${to} + 1):\n${branch}`; +}; + +pythonGenerator.forBlock['python_break'] = function () { + return 'break\n'; +}; + +pythonGenerator.forBlock['python_continue'] = function () { + return 'continue\n'; +}; diff --git a/src/blockly/generators/debug.ts b/src/blockly/generators/debug.ts new file mode 100644 index 0000000..b4a5cc3 --- /dev/null +++ b/src/blockly/generators/debug.ts @@ -0,0 +1,18 @@ +import { pythonGenerator, Order } from 'blockly/python'; + +pythonGenerator.forBlock['python_print_var'] = function (block, generator) { + const varName = generator.getVariableName(block.getFieldValue('VAR')); + // quote_ escapes the (already legalized) variable name for the label string. + return `print(${pythonGenerator.quote_(varName)}, '=', ${varName})\n`; +}; + +pythonGenerator.forBlock['python_show_type'] = function (block, generator) { + const value = generator.valueToCode(block, 'VALUE', Order.NONE) || 'None'; + return [`type(${value}).__name__`, Order.MEMBER]; +}; + +pythonGenerator.forBlock['python_assert'] = function (block, generator) { + const condition = generator.valueToCode(block, 'CONDITION', Order.NONE) || 'True'; + const message = pythonGenerator.quote_(String(block.getFieldValue('MESSAGE') ?? '')); + return `assert ${condition}, ${message}\n`; +}; diff --git a/src/blockly/generators/functions.ts b/src/blockly/generators/functions.ts new file mode 100644 index 0000000..f6c026f --- /dev/null +++ b/src/blockly/generators/functions.ts @@ -0,0 +1,27 @@ +import { pythonGenerator, Order } from 'blockly/python'; +import { legalizePythonName } from './helpers'; + +// Function names are typed free-form and legalized into safe Python +// identifiers, so "My Cool Function!" defines (and calls) my_cool_function- +// style names instead of producing invalid syntax. + +pythonGenerator.forBlock['python_def'] = function (block, generator) { + const name = legalizePythonName(block.getFieldValue('NAME'), 'my_function'); + const body = generator.statementToCode(block, 'DO') || `${generator.INDENT}pass\n`; + return `def ${name}():\n${body}`; +}; + +pythonGenerator.forBlock['python_call'] = function (block) { + const name = legalizePythonName(block.getFieldValue('NAME'), 'my_function'); + return `${name}()\n`; +}; + +pythonGenerator.forBlock['python_call_value'] = function (block) { + const name = legalizePythonName(block.getFieldValue('NAME'), 'my_function'); + return [`${name}()`, Order.FUNCTION_CALL]; +}; + +pythonGenerator.forBlock['python_return'] = function (block, generator) { + const value = generator.valueToCode(block, 'VALUE', Order.NONE); + return value ? `return ${value}\n` : 'return\n'; +}; diff --git a/src/blockly/generators/index.ts b/src/blockly/generators/index.ts index fdbc08a..2a1b9b6 100644 --- a/src/blockly/generators/index.ts +++ b/src/blockly/generators/index.ts @@ -7,5 +7,11 @@ import './text'; import './math'; import './logic'; import './control'; +import './input'; +import './lists'; +import './random'; +import './functions'; +import './debug'; +import './stage'; export { pythonGenerator } from 'blockly/python'; diff --git a/src/blockly/generators/input.ts b/src/blockly/generators/input.ts new file mode 100644 index 0000000..ccabad5 --- /dev/null +++ b/src/blockly/generators/input.ts @@ -0,0 +1,21 @@ +import { pythonGenerator, Order } from 'blockly/python'; + +// input() generates standard, readable Python. Note: the in-browser Pyodide +// runner has no interactive stdin (see ARCHITECTURE.md), so these raise a +// beginner-friendly normalized error there; exported programs work normally +// when run with desktop Python. + +pythonGenerator.forBlock['python_ask_text'] = function (block, generator) { + const question = generator.valueToCode(block, 'QUESTION', Order.NONE) || "''"; + return [`input(${question})`, Order.FUNCTION_CALL]; +}; + +pythonGenerator.forBlock['python_ask_number'] = function (block, generator) { + const question = generator.valueToCode(block, 'QUESTION', Order.NONE) || "''"; + return [`float(input(${question}))`, Order.FUNCTION_CALL]; +}; + +pythonGenerator.forBlock['python_ask_integer'] = function (block, generator) { + const question = generator.valueToCode(block, 'QUESTION', Order.NONE) || "''"; + return [`int(input(${question}))`, Order.FUNCTION_CALL]; +}; diff --git a/src/blockly/generators/lists.ts b/src/blockly/generators/lists.ts new file mode 100644 index 0000000..c0214ae --- /dev/null +++ b/src/blockly/generators/lists.ts @@ -0,0 +1,32 @@ +import { pythonGenerator, Order } from 'blockly/python'; + +pythonGenerator.forBlock['python_list_create'] = function (block, generator) { + const items = ['ITEM0', 'ITEM1', 'ITEM2'] + .map((name) => generator.valueToCode(block, name, Order.NONE)) + .filter((code) => code !== ''); + return [`[${items.join(', ')}]`, Order.ATOMIC]; +}; + +pythonGenerator.forBlock['python_list_append'] = function (block, generator) { + const list = generator.valueToCode(block, 'LIST', Order.MEMBER) || '[]'; + const item = generator.valueToCode(block, 'ITEM', Order.NONE) || 'None'; + return `${list}.append(${item})\n`; +}; + +pythonGenerator.forBlock['python_list_get'] = function (block, generator) { + const list = generator.valueToCode(block, 'LIST', Order.MEMBER) || '[]'; + const index = generator.valueToCode(block, 'INDEX', Order.NONE) || '0'; + return [`${list}[${index}]`, Order.MEMBER]; +}; + +pythonGenerator.forBlock['python_list_length'] = function (block, generator) { + const list = generator.valueToCode(block, 'LIST', Order.NONE) || '[]'; + return [`len(${list})`, Order.FUNCTION_CALL]; +}; + +pythonGenerator.forBlock['python_for_each'] = function (block, generator) { + const loopVar = generator.getVariableName(block.getFieldValue('VAR')); + const list = generator.valueToCode(block, 'LIST', Order.RELATIONAL) || '[]'; + const branch = generator.statementToCode(block, 'DO') || `${generator.INDENT}pass\n`; + return `for ${loopVar} in ${list}:\n${branch}`; +}; diff --git a/src/blockly/generators/random.ts b/src/blockly/generators/random.ts new file mode 100644 index 0000000..b414f9f --- /dev/null +++ b/src/blockly/generators/random.ts @@ -0,0 +1,20 @@ +import { pythonGenerator, Order } from 'blockly/python'; +import { addDefinition } from './helpers'; + +pythonGenerator.forBlock['python_random_int'] = function (block, generator) { + addDefinition(generator, 'import_random', 'import random'); + const from = generator.valueToCode(block, 'FROM', Order.NONE) || '0'; + const to = generator.valueToCode(block, 'TO', Order.NONE) || '0'; + return [`random.randint(${from}, ${to})`, Order.FUNCTION_CALL]; +}; + +pythonGenerator.forBlock['python_random_float'] = function (_block, generator) { + addDefinition(generator, 'import_random', 'import random'); + return ['random.random()', Order.FUNCTION_CALL]; +}; + +pythonGenerator.forBlock['python_random_choice'] = function (block, generator) { + addDefinition(generator, 'import_random', 'import random'); + const list = generator.valueToCode(block, 'LIST', Order.NONE) || '[]'; + return [`random.choice(${list})`, Order.FUNCTION_CALL]; +}; diff --git a/src/blockly/generators/stage.ts b/src/blockly/generators/stage.ts new file mode 100644 index 0000000..63a5047 --- /dev/null +++ b/src/blockly/generators/stage.ts @@ -0,0 +1,14 @@ +import { pythonGenerator, Order } from 'blockly/python'; + +// Stage blocks compile to plain print() calls: the Stage panel mirrors the +// latest printed line, and an empty line clears it. Exported programs remain +// standard Python with no Coding Circus runtime dependency. + +pythonGenerator.forBlock['python_say'] = function (block, generator) { + const value = generator.valueToCode(block, 'VALUE', Order.NONE) || "''"; + return `print(${value})\n`; +}; + +pythonGenerator.forBlock['python_clear_stage'] = function () { + return 'print()\n'; +}; diff --git a/src/blockly/toolbox.ts b/src/blockly/toolbox.ts index afa50b2..a310e63 100644 --- a/src/blockly/toolbox.ts +++ b/src/blockly/toolbox.ts @@ -79,6 +79,17 @@ export const toolbox: Blockly.utils.toolbox.ToolboxInfo = { inputs: { TIMES: { shadow: { type: 'python_number', fields: { NUM: 10 } } } }, }, { kind: 'block', type: 'python_while' }, + { kind: 'block', type: 'python_repeat_until' }, + { + kind: 'block', + type: 'python_count_with', + inputs: { + FROM: { shadow: { type: 'python_number', fields: { NUM: 1 } } }, + TO: { shadow: { type: 'python_number', fields: { NUM: 10 } } }, + }, + }, + { kind: 'block', type: 'python_break' }, + { kind: 'block', type: 'python_continue' }, { kind: 'block', type: 'python_wait', @@ -86,5 +97,95 @@ export const toolbox: Blockly.utils.toolbox.ToolboxInfo = { }, ], }, + { + kind: 'category', + name: 'Input', + colour: '20', + contents: [ + { + kind: 'block', + type: 'python_ask_text', + inputs: { QUESTION: { shadow: { type: 'python_string', fields: { TEXT: 'What is your name?' } } } }, + }, + { + kind: 'block', + type: 'python_ask_number', + inputs: { QUESTION: { shadow: { type: 'python_string', fields: { TEXT: 'Pick a number:' } } } }, + }, + { + kind: 'block', + type: 'python_ask_integer', + inputs: { QUESTION: { shadow: { type: 'python_string', fields: { TEXT: 'Pick a whole number:' } } } }, + }, + ], + }, + { + kind: 'category', + name: 'Lists', + colour: '260', + contents: [ + { kind: 'block', type: 'python_list_create' }, + { kind: 'block', type: 'python_list_append' }, + { + kind: 'block', + type: 'python_list_get', + inputs: { INDEX: { shadow: { type: 'python_number', fields: { NUM: 0 } } } }, + }, + { kind: 'block', type: 'python_list_length' }, + { kind: 'block', type: 'python_for_each' }, + ], + }, + { + kind: 'category', + name: 'Random', + colour: '40', + contents: [ + { + kind: 'block', + type: 'python_random_int', + inputs: { + FROM: { shadow: { type: 'python_number', fields: { NUM: 1 } } }, + TO: { shadow: { type: 'python_number', fields: { NUM: 10 } } }, + }, + }, + { kind: 'block', type: 'python_random_float' }, + { kind: 'block', type: 'python_random_choice' }, + ], + }, + { + kind: 'category', + name: 'Functions', + colour: '290', + contents: [ + { kind: 'block', type: 'python_def' }, + { kind: 'block', type: 'python_call' }, + { kind: 'block', type: 'python_call_value' }, + { kind: 'block', type: 'python_return' }, + ], + }, + { + kind: 'category', + name: 'Debug', + colour: '65', + contents: [ + { kind: 'block', type: 'python_print_var' }, + { kind: 'block', type: 'python_show_type' }, + { kind: 'block', type: 'python_assert' }, + { kind: 'block', type: 'python_comment' }, + ], + }, + { + kind: 'category', + name: 'Stage', + colour: '180', + contents: [ + { + kind: 'block', + type: 'python_say', + inputs: { VALUE: { shadow: { type: 'python_string', fields: { TEXT: 'Ta-da!' } } } }, + }, + { kind: 'block', type: 'python_clear_stage' }, + ], + }, ], }; diff --git a/src/runner/errorNormalization.ts b/src/runner/errorNormalization.ts index ac04a4c..f09791c 100644 --- a/src/runner/errorNormalization.ts +++ b/src/runner/errorNormalization.ts @@ -19,6 +19,12 @@ const HINTS: Record = { AttributeError: "You tried to use a feature that this value doesn't have.", RecursionError: 'Your program called itself too many times without stopping. Check your loop or repeat conditions.', KeyboardInterrupt: 'The program was stopped before it finished.', + AssertionError: 'A "check that" block found its condition was not true, so the program stopped.', + // input() has no interactive stdin in the in-browser runner (see ARCHITECTURE.md). + EOFError: + 'This program asks for input, which the browser runner cannot collect yet. Export the .py file and run it with Python on your computer to try it.', + OSError: + 'This program tried to use a feature (like asking for input) that the browser runner does not support. Export the .py file and run it with desktop Python.', }; function extractExceptionType(traceback: string): string { From b04560093fd10c9ef0a2d5f7b60da4510e4f4770 Mon Sep 17 00:00:00 2001 From: TheRealBrofessor <196406002+TheRealBrofessor@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:32:04 -0400 Subject: [PATCH 3/8] Harden project persistence, import, and export - New project/validation.ts: single source of truth for project-name normalization, download-filename sanitization, and untrusted-JSON validation of imported project files (formatVersion-aware, with a clear message for files from newer app versions). - ProjectStorage treats localStorage as unreliable: corrupt entries load as null instead of throwing, the index self-heals to string entries, and save failures (quota/unavailable) raise beginner-readable errors. - ProjectExport sanitizes filenames, caps import size, and reports bad JSON / wrong-shape files with friendly messages. - Save in the UI now reports success/failure to the console panel and normalizes the project name field. Co-Authored-By: Claude Fable 5 --- src/App.tsx | 24 +++++++---- src/project/ProjectExport.ts | 25 +++++++++--- src/project/ProjectStorage.ts | 54 ++++++++++++++++++++----- src/project/validation.ts | 75 +++++++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 24 deletions(-) create mode 100644 src/project/validation.ts diff --git a/src/App.tsx b/src/App.tsx index fcf6cb3..9815bf9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -115,10 +115,16 @@ export default function App() { const handleSave = useCallback(() => { const workspace = workspaceRef.current; - if (!workspace || !projectName.trim()) return; - saveProject(projectName.trim(), Blockly.serialization.workspaces.save(workspace)); - setSavedProjects(listProjects()); - }, [projectName]); + if (!workspace) return; + try { + const saved = saveProject(projectName, Blockly.serialization.workspaces.save(workspace)); + setSavedProjects(listProjects()); + setProjectName(saved.name); + appendConsole('system', `Saved "${saved.name}".`); + } catch (err) { + appendConsole('system', err instanceof Error ? err.message : 'Could not save the project.'); + } + }, [projectName, appendConsole]); const handleLoad = useCallback( (name: string) => { @@ -159,9 +165,13 @@ export default function App() { const workspace = workspaceRef.current; if (!workspace) return; const demoName = 'coding-circus-demo'; - saveProject(demoName, Blockly.serialization.workspaces.save(workspace)); - setSavedProjects(listProjects()); - setProjectName(demoName); + try { + saveProject(demoName, Blockly.serialization.workspaces.save(workspace)); + setSavedProjects(listProjects()); + setProjectName(demoName); + } catch { + // Demo auto-save is best-effort; storage may be unavailable. + } }, []); const dismissIntro = useCallback(() => { diff --git a/src/project/ProjectExport.ts b/src/project/ProjectExport.ts index 12a522a..67cfe5b 100644 --- a/src/project/ProjectExport.ts +++ b/src/project/ProjectExport.ts @@ -1,4 +1,7 @@ import type { ProjectFile } from './types'; +import { sanitizeFilename, validateProjectFile } from './validation'; + +const MAX_IMPORT_BYTES = 5 * 1024 * 1024; function download(filename: string, content: string, mime: string): void { const blob = new Blob([content], { type: mime }); @@ -11,18 +14,28 @@ function download(filename: string, content: string, mime: string): void { } export function exportPython(name: string, code: string): void { - download(`${name || 'project'}.py`, code, 'text/x-python'); + download(`${sanitizeFilename(name)}.py`, code, 'text/x-python'); } export function exportProjectJson(project: ProjectFile): void { - download(`${project.name || 'project'}.json`, JSON.stringify(project, null, 2), 'application/json'); + download(`${sanitizeFilename(project.name)}.json`, JSON.stringify(project, null, 2), 'application/json'); } +/** + * Reads and validates an imported project file. All failure modes (wrong file + * type, oversized file, invalid JSON, wrong shape, future format versions) + * throw an Error with a beginner-readable message. + */ export async function readProjectJsonFile(file: File): Promise { + if (file.size > MAX_IMPORT_BYTES) { + throw new Error('That file is too large to be a Coding Circus project.'); + } const text = await file.text(); - const parsed = JSON.parse(text) as ProjectFile; - if (parsed.formatVersion !== 1 || !parsed.workspaceJson) { - throw new Error('This file is not a valid Coding Circus project.'); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error('That file is not valid JSON, so it cannot be a Coding Circus project.'); } - return parsed; + return validateProjectFile(parsed); } diff --git a/src/project/ProjectStorage.ts b/src/project/ProjectStorage.ts index 50bcfeb..6f15ebc 100644 --- a/src/project/ProjectStorage.ts +++ b/src/project/ProjectStorage.ts @@ -1,12 +1,20 @@ import type { ProjectFile } from './types'; +import { CURRENT_FORMAT_VERSION, normalizeProjectName, validateProjectFile } from './validation'; const STORAGE_PREFIX = 'coding-circus:project:'; const INDEX_KEY = 'coding-circus:project-index'; +// localStorage can be unavailable (privacy modes), full (quota), or contain +// corrupted entries (manual edits, older versions). Every function here treats +// it as unreliable: reads fall back to safe defaults, writes report failure +// via exceptions with beginner-readable messages. + function readIndex(): string[] { try { const raw = localStorage.getItem(INDEX_KEY); - return raw ? (JSON.parse(raw) as string[]) : []; + const parsed: unknown = raw ? JSON.parse(raw) : []; + if (!Array.isArray(parsed)) return []; + return parsed.filter((item): item is string => typeof item === 'string'); } catch { return []; } @@ -20,26 +28,50 @@ export function listProjects(): string[] { return readIndex(); } +/** + * Saves a project under a normalized name. Throws a friendly Error when the + * name is unusable or storage is unavailable/full. + */ export function saveProject(name: string, workspaceJson: unknown): ProjectFile { + const normalized = normalizeProjectName(name); + if (!normalized) { + throw new Error('Please give the project a name before saving.'); + } const project: ProjectFile = { - formatVersion: 1, - name, + formatVersion: CURRENT_FORMAT_VERSION, + name: normalized, updatedAt: new Date().toISOString(), workspaceJson, }; - localStorage.setItem(STORAGE_PREFIX + name, JSON.stringify(project)); - const names = readIndex(); - if (!names.includes(name)) writeIndex([...names, name]); + try { + localStorage.setItem(STORAGE_PREFIX + normalized, JSON.stringify(project)); + const names = readIndex(); + if (!names.includes(normalized)) writeIndex([...names, normalized]); + } catch { + throw new Error('Could not save — browser storage is full or unavailable. Try exporting the project instead.'); + } return project; } +/** + * Loads a saved project, returning null when it is missing or its stored data + * is corrupted (rather than throwing into the UI). + */ export function loadProject(name: string): ProjectFile | null { - const raw = localStorage.getItem(STORAGE_PREFIX + name); - if (!raw) return null; - return JSON.parse(raw) as ProjectFile; + try { + const raw = localStorage.getItem(STORAGE_PREFIX + name); + if (!raw) return null; + return validateProjectFile(JSON.parse(raw)); + } catch { + return null; + } } export function deleteProject(name: string): void { - localStorage.removeItem(STORAGE_PREFIX + name); - writeIndex(readIndex().filter((n) => n !== name)); + try { + localStorage.removeItem(STORAGE_PREFIX + name); + writeIndex(readIndex().filter((n) => n !== name)); + } catch { + // Deleting from unavailable storage is a no-op. + } } diff --git a/src/project/validation.ts b/src/project/validation.ts new file mode 100644 index 0000000..584aaaf --- /dev/null +++ b/src/project/validation.ts @@ -0,0 +1,75 @@ +import type { ProjectFile } from './types'; + +/** Current on-disk project format. Bump + add a migration in validateProjectFile when it changes. */ +export const CURRENT_FORMAT_VERSION = 1; + +export const MAX_PROJECT_NAME_LENGTH = 60; + +/** + * Normalizes a user-typed project name: trims, strips control characters, + * and caps the length. Returns null when nothing usable remains. + */ +export function normalizeProjectName(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const cleaned = raw + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x1f\x7f]/g, '') + .trim() + .slice(0, MAX_PROJECT_NAME_LENGTH) + .trim(); + return cleaned.length > 0 ? cleaned : null; +} + +/** + * Converts a project name into a safe download filename base: removes path + * separators and characters that are reserved on common filesystems, and + * never returns an empty or dot-only result. + */ +export function sanitizeFilename(raw: unknown): string { + const cleaned = (typeof raw === 'string' ? raw : '') + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x1f\x7f]/g, '') + .replace(/[/\\:*?"<>|]/g, '-') + .replace(/\s+/g, ' ') + .trim() + .replace(/^\.+|\.+$/g, ''); + return cleaned || 'project'; +} + +/** + * Validates untrusted parsed JSON as a ProjectFile. Throws a beginner-readable + * Error describing what is wrong rather than letting malformed data flow into + * Blockly deserialization. This is the single place to add format migrations. + */ +export function validateProjectFile(parsed: unknown): ProjectFile { + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('This file is not a Coding Circus project (expected a JSON object).'); + } + const candidate = parsed as Record; + + if (typeof candidate.formatVersion !== 'number') { + throw new Error('This file is not a Coding Circus project (missing formatVersion).'); + } + if (candidate.formatVersion > CURRENT_FORMAT_VERSION) { + throw new Error( + `This project was made with a newer version of Coding Circus (format ${candidate.formatVersion}). Please update the app.`, + ); + } + if (candidate.formatVersion < 1) { + throw new Error('This file is not a valid Coding Circus project (unknown format version).'); + } + + if (typeof candidate.workspaceJson !== 'object' || candidate.workspaceJson === null) { + throw new Error('This project file has no block data in it.'); + } + + const name = normalizeProjectName(candidate.name) ?? 'imported-project'; + const updatedAt = typeof candidate.updatedAt === 'string' ? candidate.updatedAt : new Date().toISOString(); + + return { + formatVersion: CURRENT_FORMAT_VERSION, + name, + updatedAt, + workspaceJson: candidate.workspaceJson, + }; +} From c251b8afd0f51b3183e7cf4a43464e2a1f0b8675 Mon Sep 17 00:00:00 2001 From: TheRealBrofessor <196406002+TheRealBrofessor@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:37:16 -0400 Subject: [PATCH 4/8] Add Vitest test suite for block generation and persistence safety - 52 tests across 5 files: block/generator/toolbox coverage (every python_* block has a generator, appears in the toolbox, and vice versa), generated-Python checks for common programs (hello world, variables, if/else, loops+imports, lists, random, functions, count-with, input), empty-branch pass insertion, comment newline- injection defense, no NaN/undefined/Infinity artifacts. - Helper unit tests: operator fallback, safe numeric literals, inline text sanitizer, Python identifier legalization. - Persistence tests: name normalization, filename sanitization, corrupt/foreign JSON rejection (incl. future formatVersion message), localStorage round trip, corrupt entry -> null, index self-healing. - Wire vitest (jsdom) into vite.config.ts; add "typecheck" and "test" npm scripts. Co-Authored-By: Claude Fable 5 --- package-lock.json | 668 ++++++++++++++++++------- package.json | 6 +- src/blockly/coverage.test.ts | 52 ++ src/blockly/generation.test.ts | 281 +++++++++++ src/blockly/generators/helpers.test.ts | 67 +++ src/blockly/testUtils.ts | 18 + src/project/storage.test.ts | 62 +++ src/project/validation.test.ts | 67 +++ vite.config.ts | 7 + 9 files changed, 1033 insertions(+), 195 deletions(-) create mode 100644 src/blockly/coverage.test.ts create mode 100644 src/blockly/generation.test.ts create mode 100644 src/blockly/generators/helpers.test.ts create mode 100644 src/blockly/testUtils.ts create mode 100644 src/project/storage.test.ts create mode 100644 src/project/validation.test.ts diff --git a/package-lock.json b/package-lock.json index 86d4921..9dbdddf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "coding-circus", "version": "0.0.0", + "license": "MIT", "dependencies": { "blockly": "^13.1.0", "react": "^19.2.7", @@ -17,52 +18,71 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "jsdom": "^29.1.1", "oxlint": "^1.71.0", "typescript": "~6.0.2", - "vite": "^8.1.1" + "vite": "^8.1.1", + "vitest": "^4.1.10" } }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "license": "MIT", - "peer": true - }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", - "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "license": "MIT", - "peer": true, "dependencies": { - "@csstools/css-calc": "^3.0.0", - "@csstools/css-color-parser": "^4.0.1", + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.5" + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "license": "MIT", - "peer": true, "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", "license": "MIT", - "peer": true + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } }, "node_modules/@csstools/color-helpers": { "version": "6.1.0", @@ -79,7 +99,6 @@ } ], "license": "MIT-0", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -99,7 +118,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -123,7 +141,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/color-helpers": "^6.1.0", "@csstools/css-calc": "^3.2.1" @@ -151,7 +168,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -174,7 +190,6 @@ } ], "license": "MIT-0", - "peer": true, "peerDependencies": { "css-tree": "^3.2.1" }, @@ -199,7 +214,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -243,7 +257,6 @@ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "license": "MIT", - "peer": true, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, @@ -256,6 +269,13 @@ } } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -872,6 +892,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -883,6 +910,31 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", @@ -939,14 +991,127 @@ } } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">= 14" + "node": ">=12" } }, "node_modules/bidi-js": { @@ -954,7 +1119,6 @@ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "license": "MIT", - "peer": true, "dependencies": { "require-from-string": "^2.0.2" } @@ -971,12 +1135,28 @@ "jsdom": "^27.4.0" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "license": "MIT", - "peer": true, "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" @@ -985,22 +1165,6 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/cssstyle": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", - "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@asamuzakjp/css-color": "^4.1.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.21", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.4" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1009,53 +1173,23 @@ "license": "MIT" }, "node_modules/data-urls": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", - "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "license": "MIT", - "peer": true, "dependencies": { "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^15.1.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/data-urls/node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "peer": true, - "dependencies": { - "ms": "^2.1.3" + "whatwg-url": "^16.0.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/detect-libc": { "version": "2.1.2", @@ -1072,7 +1206,6 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1080,6 +1213,33 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1118,7 +1278,6 @@ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "license": "MIT", - "peer": true, "dependencies": { "@exodus/bytes": "^1.6.0" }, @@ -1126,71 +1285,42 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "peer": true, - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "peer": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/jsdom": { - "version": "27.4.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", - "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "license": "MIT", - "peer": true, "dependencies": { - "@acemir/cssom": "^0.9.28", - "@asamuzakjp/dom-selector": "^6.7.6", - "@exodus/bytes": "^1.6.0", - "cssstyle": "^5.3.4", - "data-urls": "^6.0.0", + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.0", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.1.0", - "ws": "^8.18.3", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -1467,24 +1597,25 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "license": "BlueOak-1.0.0", - "peer": true, "engines": { "node": "20 || >=22" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "license": "CC0-1.0", - "peer": true - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "peer": true + "license": "CC0-1.0" }, "node_modules/nanoid": { "version": "3.3.15", @@ -1505,6 +1636,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/oxlint": { "version": "1.72.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz", @@ -1559,7 +1704,6 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "license": "MIT", - "peer": true, "dependencies": { "entities": "^8.0.0" }, @@ -1567,6 +1711,13 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1621,7 +1772,6 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -1652,7 +1802,6 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -1696,7 +1845,6 @@ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "license": "ISC", - "peer": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -1710,6 +1858,13 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1719,12 +1874,42 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, "license": "MIT", - "peer": true + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.17", @@ -1743,12 +1928,21 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.4.6", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", "license": "MIT", - "peer": true, "dependencies": { "tldts-core": "^7.4.6" }, @@ -1760,15 +1954,13 @@ "version": "7.4.6", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "tldts": "^7.0.5" }, @@ -1781,7 +1973,6 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "license": "MIT", - "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -1811,6 +2002,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -1896,12 +2096,101 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "license": "MIT", - "peer": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -1914,55 +2203,48 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=20" } }, "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "license": "MIT", - "peer": true, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "license": "MIT", - "peer": true, "dependencies": { + "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" + "webidl-conversions": "^8.0.1" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.0.0" + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" + "bin": { + "why-is-node-running": "cli.js" }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "engines": { + "node": ">=8" } }, "node_modules/xml-name-validator": { @@ -1970,7 +2252,6 @@ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18" } @@ -1979,8 +2260,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "license": "MIT", - "peer": true + "license": "MIT" } } } diff --git a/package.json b/package.json index 5c4b425..46ff323 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "typecheck": "tsc -b", + "test": "vitest run", "lint": "oxlint", "preview": "vite preview" }, @@ -20,8 +22,10 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "jsdom": "^29.1.1", "oxlint": "^1.71.0", "typescript": "~6.0.2", - "vite": "^8.1.1" + "vite": "^8.1.1", + "vitest": "^4.1.10" } } diff --git a/src/blockly/coverage.test.ts b/src/blockly/coverage.test.ts new file mode 100644 index 0000000..2e9e128 --- /dev/null +++ b/src/blockly/coverage.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import * as Blockly from 'blockly/core'; +import { pythonGenerator } from 'blockly/python'; +import './setup'; +import { toolbox } from './toolbox'; + +function collectToolboxBlockTypes(): string[] { + const types: string[] = []; + const walk = (items: unknown[]): void => { + for (const item of items) { + const entry = item as { kind?: string; type?: string; contents?: unknown[] }; + if (entry.kind === 'block' && entry.type) types.push(entry.type); + if (Array.isArray(entry.contents)) walk(entry.contents); + } + }; + walk(toolbox.contents as unknown[]); + return types; +} + +const customBlockTypes = Object.keys(Blockly.Blocks).filter((type) => type.startsWith('python_')); + +describe('block/generator coverage', () => { + it('defines at least the full beginner block set', () => { + expect(customBlockTypes.length).toBeGreaterThanOrEqual(30); + }); + + it('has a Python generator for every custom block definition', () => { + for (const type of customBlockTypes) { + expect(pythonGenerator.forBlock[type], `missing generator for ${type}`).toBeTypeOf('function'); + } + }); + + it('has a block definition for every custom Python generator', () => { + const generatorTypes = Object.keys(pythonGenerator.forBlock).filter((type) => type.startsWith('python_')); + for (const type of generatorTypes) { + expect(Blockly.Blocks[type], `generator without block definition: ${type}`).toBeDefined(); + } + }); + + it('only references defined blocks from the toolbox', () => { + for (const type of collectToolboxBlockTypes()) { + expect(Blockly.Blocks[type], `toolbox references undefined block: ${type}`).toBeDefined(); + } + }); + + it('exposes every custom block in the toolbox', () => { + const toolboxTypes = new Set(collectToolboxBlockTypes()); + for (const type of customBlockTypes) { + expect(toolboxTypes.has(type), `block missing from toolbox: ${type}`).toBe(true); + } + }); +}); diff --git a/src/blockly/generation.test.ts b/src/blockly/generation.test.ts new file mode 100644 index 0000000..15da260 --- /dev/null +++ b/src/blockly/generation.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from 'vitest'; +import { codeFor } from './testUtils'; + +function statementProgram(block: object): object { + return { blocks: { languageVersion: 0, blocks: [block] } }; +} + +describe('Python generation for common programs', () => { + it('hello world', () => { + const code = codeFor( + statementProgram({ + type: 'python_print', + inputs: { VALUE: { block: { type: 'python_string', fields: { TEXT: 'Hello, world!' } } } }, + }), + ); + expect(code).toBe("print('Hello, world!')\n"); + }); + + it('variables: set then print with name', () => { + const code = codeFor({ + variables: [{ name: 'score', id: 'score-id' }], + blocks: { + languageVersion: 0, + blocks: [ + { + type: 'python_var_set', + fields: { VAR: { id: 'score-id' } }, + inputs: { VALUE: { block: { type: 'python_number', fields: { NUM: 10 } } } }, + next: { block: { type: 'python_print_var', fields: { VAR: { id: 'score-id' } } } }, + }, + ], + }, + }); + expect(code).toContain('score = 10'); + expect(code).toContain("print('score', '=', score)"); + }); + + it('if/else with a comparison', () => { + const code = codeFor( + statementProgram({ + type: 'python_if_else', + inputs: { + CONDITION: { + block: { + type: 'python_compare', + fields: { OP: 'GT' }, + inputs: { + A: { block: { type: 'python_number', fields: { NUM: 5 } } }, + B: { block: { type: 'python_number', fields: { NUM: 3 } } }, + }, + }, + }, + DO: { + block: { + type: 'python_print', + inputs: { VALUE: { block: { type: 'python_string', fields: { TEXT: 'big' } } } }, + }, + }, + ELSE: { + block: { + type: 'python_print', + inputs: { VALUE: { block: { type: 'python_string', fields: { TEXT: 'small' } } } }, + }, + }, + }, + }), + ); + expect(code).toBe("if 5 > 3:\n print('big')\nelse:\n print('small')\n"); + }); + + it('repeat loop with wait hoists the time import once', () => { + const code = codeFor( + statementProgram({ + type: 'python_repeat', + inputs: { + TIMES: { block: { type: 'python_number', fields: { NUM: 3 } } }, + DO: { + block: { + type: 'python_wait', + inputs: { SECONDS: { block: { type: 'python_number', fields: { NUM: 0.5 } } } }, + next: { + block: { + type: 'python_wait', + inputs: { SECONDS: { block: { type: 'python_number', fields: { NUM: 0.5 } } } }, + }, + }, + }, + }, + }, + }), + ); + expect(code.match(/import time/g)).toHaveLength(1); + expect(code).toContain('for count in range(3):'); + expect(code).toContain('time.sleep(0.5)'); + }); + + it('empty statement branches generate pass', () => { + const code = codeFor( + statementProgram({ + type: 'python_if', + inputs: { CONDITION: { block: { type: 'python_boolean', fields: { BOOL: 'TRUE' } } } }, + }), + ); + expect(code).toBe('if True:\n pass\n'); + }); + + it('lists: create, append, get, length, for-each', () => { + const code = codeFor({ + variables: [ + { name: 'items', id: 'items-id' }, + { name: 'thing', id: 'thing-id' }, + ], + blocks: { + languageVersion: 0, + blocks: [ + { + type: 'python_var_set', + fields: { VAR: { id: 'items-id' } }, + inputs: { + VALUE: { + block: { + type: 'python_list_create', + inputs: { + ITEM0: { block: { type: 'python_number', fields: { NUM: 1 } } }, + ITEM1: { block: { type: 'python_number', fields: { NUM: 2 } } }, + }, + }, + }, + }, + next: { + block: { + type: 'python_list_append', + inputs: { + ITEM: { block: { type: 'python_number', fields: { NUM: 3 } } }, + LIST: { block: { type: 'python_var_get', fields: { VAR: { id: 'items-id' } } } }, + }, + next: { + block: { + type: 'python_for_each', + fields: { VAR: { id: 'thing-id' } }, + inputs: { + LIST: { block: { type: 'python_var_get', fields: { VAR: { id: 'items-id' } } } }, + }, + }, + }, + }, + }, + }, + ], + }, + }); + expect(code).toContain('items = [1, 2]'); + expect(code).toContain('items.append(3)'); + expect(code).toContain('for thing in items:'); + expect(code).toContain('pass'); + }); + + it('random blocks hoist a single import', () => { + const code = codeFor( + statementProgram({ + type: 'python_print', + inputs: { + VALUE: { + block: { + type: 'python_random_int', + inputs: { + FROM: { block: { type: 'python_number', fields: { NUM: 1 } } }, + TO: { block: { type: 'python_number', fields: { NUM: 10 } } }, + }, + }, + }, + }, + }), + ); + expect(code.match(/import random/g)).toHaveLength(1); + expect(code).toContain('print(random.randint(1, 10))'); + }); + + it('functions: define, call, return, and name legalization', () => { + const code = codeFor({ + blocks: { + languageVersion: 0, + blocks: [ + { + type: 'python_def', + fields: { NAME: 'My Cool Function!' }, + inputs: { + DO: { + block: { + type: 'python_return', + inputs: { VALUE: { block: { type: 'python_number', fields: { NUM: 7 } } } }, + }, + }, + }, + }, + { + type: 'python_call', + fields: { NAME: 'My Cool Function!' }, + x: 0, + y: 200, + }, + ], + }, + }); + expect(code).toContain('def My_Cool_Function():'); + expect(code).toContain('return 7'); + expect(code).toContain('My_Cool_Function()'); + }); + + it('count-with produces an inclusive range', () => { + const code = codeFor({ + variables: [{ name: 'i', id: 'i-id' }], + blocks: { + languageVersion: 0, + blocks: [ + { + type: 'python_count_with', + fields: { VAR: { id: 'i-id' } }, + inputs: { + FROM: { block: { type: 'python_number', fields: { NUM: 1 } } }, + TO: { block: { type: 'python_number', fields: { NUM: 10 } } }, + }, + }, + ], + }, + }); + expect(code).toContain('for i in range(1, 10 + 1):'); + }); + + it('comment text cannot inject extra Python lines', () => { + const code = codeFor( + statementProgram({ + type: 'python_comment', + fields: { TEXT: "note\nprint('injected')" }, + }), + ); + expect(code).toBe("# note print('injected')\n"); + }); + + it('ask blocks generate standard input() calls', () => { + const code = codeFor({ + variables: [{ name: 'n', id: 'n-id' }], + blocks: { + languageVersion: 0, + blocks: [ + { + type: 'python_var_set', + fields: { VAR: { id: 'n-id' } }, + inputs: { + VALUE: { + block: { + type: 'python_ask_number', + inputs: { QUESTION: { block: { type: 'python_string', fields: { TEXT: 'Pick:' } } } }, + }, + }, + }, + }, + ], + }, + }); + expect(code).toContain("float(input('Pick:'))"); + }); + + it('generated code never contains JS artifacts', () => { + const code = codeFor( + statementProgram({ + type: 'python_print', + inputs: { + VALUE: { + block: { + type: 'python_math_op', + fields: { OP: 'DIVIDE' }, + }, + }, + }, + }), + ); + expect(code).not.toMatch(/NaN|undefined|Infinity|null/); + expect(code).toContain('print(0 / 0)'); + }); +}); diff --git a/src/blockly/generators/helpers.test.ts b/src/blockly/generators/helpers.test.ts new file mode 100644 index 0000000..015c203 --- /dev/null +++ b/src/blockly/generators/helpers.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { legalizePythonName, pickOperator, safeNumber, sanitizeInlineText } from './helpers'; + +describe('pickOperator', () => { + const table = { ADD: '+', MINUS: '-' }; + + it('returns the mapped operator for a known key', () => { + expect(pickOperator(table, 'MINUS', 'ADD')).toBe('-'); + }); + + it('falls back for unknown, missing, or non-string keys', () => { + expect(pickOperator(table, 'EVIL', 'ADD')).toBe('+'); + expect(pickOperator(table, undefined, 'ADD')).toBe('+'); + expect(pickOperator(table, 42, 'ADD')).toBe('+'); + }); +}); + +describe('safeNumber', () => { + it('passes through finite numbers', () => { + expect(safeNumber(5)).toBe('5'); + expect(safeNumber(-3.25)).toBe('-3.25'); + expect(safeNumber('12')).toBe('12'); + }); + + it('never emits invalid Python literals', () => { + expect(safeNumber(NaN)).toBe('0'); + expect(safeNumber(Infinity)).toBe('0'); + expect(safeNumber(-Infinity)).toBe('0'); + expect(safeNumber('not a number')).toBe('0'); + expect(safeNumber(undefined)).toBe('0'); + }); +}); + +describe('sanitizeInlineText', () => { + it('collapses line breaks into spaces', () => { + expect(sanitizeInlineText('a\nb\r\nc')).toBe('a b c'); + }); + + it('strips control characters', () => { + expect(sanitizeInlineText('a\x00b\x1fc')).toBe('abc'); + }); + + it('handles non-string input', () => { + expect(sanitizeInlineText(undefined)).toBe(''); + expect(sanitizeInlineText(42)).toBe(''); + }); +}); + +describe('legalizePythonName', () => { + it('turns free text into a legal identifier', () => { + expect(legalizePythonName('My Cool Function!', 'fn')).toBe('My_Cool_Function'); + }); + + it('prefixes names starting with a digit', () => { + expect(legalizePythonName('2fast', 'fn')).toBe('_2fast'); + }); + + it('avoids Python keywords and builtins', () => { + expect(legalizePythonName('def', 'fn')).toBe('def_'); + expect(legalizePythonName('print', 'fn')).toBe('print_'); + }); + + it('falls back when nothing usable remains', () => { + expect(legalizePythonName('!!!', 'my_function')).toBe('my_function'); + expect(legalizePythonName(undefined, 'my_function')).toBe('my_function'); + }); +}); diff --git a/src/blockly/testUtils.ts b/src/blockly/testUtils.ts new file mode 100644 index 0000000..39b0c8a --- /dev/null +++ b/src/blockly/testUtils.ts @@ -0,0 +1,18 @@ +import * as Blockly from 'blockly/core'; +import { pythonGenerator } from 'blockly/python'; +// Importing setup registers all custom blocks + generators and the locale. +import './setup'; + +/** + * Test helper: loads serialized workspace state into a headless workspace and + * returns the generated Python. + */ +export function codeFor(state: object): string { + const workspace = new Blockly.Workspace(); + try { + Blockly.serialization.workspaces.load(state as never, workspace); + return pythonGenerator.workspaceToCode(workspace); + } finally { + workspace.dispose(); + } +} diff --git a/src/project/storage.test.ts b/src/project/storage.test.ts new file mode 100644 index 0000000..369e7a8 --- /dev/null +++ b/src/project/storage.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { deleteProject, listProjects, loadProject, saveProject } from './ProjectStorage'; + +const INDEX_KEY = 'coding-circus:project-index'; +const PREFIX = 'coding-circus:project:'; + +describe('ProjectStorage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('round-trips a project through save and load', () => { + const workspaceJson = { blocks: { languageVersion: 0, blocks: [] } }; + const saved = saveProject(' My Game ', workspaceJson); + expect(saved.name).toBe('My Game'); + expect(listProjects()).toEqual(['My Game']); + + const loaded = loadProject('My Game'); + expect(loaded).not.toBeNull(); + expect(loaded!.workspaceJson).toEqual(workspaceJson); + expect(loaded!.formatVersion).toBe(1); + }); + + it('rejects unusable names with a friendly error', () => { + expect(() => saveProject(' ', {})).toThrowError(/name/i); + }); + + it('returns null for missing projects', () => { + expect(loadProject('ghost')).toBeNull(); + }); + + it('returns null instead of throwing for corrupt stored data', () => { + localStorage.setItem(`${PREFIX}bad`, '{definitely not json'); + expect(loadProject('bad')).toBeNull(); + + localStorage.setItem(`${PREFIX}wrong-shape`, JSON.stringify({ hello: 'world' })); + expect(loadProject('wrong-shape')).toBeNull(); + }); + + it('self-heals a corrupt index', () => { + localStorage.setItem(INDEX_KEY, '"not an array"'); + expect(listProjects()).toEqual([]); + + localStorage.setItem(INDEX_KEY, JSON.stringify(['ok', 42, null, 'fine'])); + expect(listProjects()).toEqual(['ok', 'fine']); + }); + + it('deletes projects and updates the index', () => { + saveProject('keep', {}); + saveProject('drop', {}); + deleteProject('drop'); + expect(listProjects()).toEqual(['keep']); + expect(loadProject('drop')).toBeNull(); + }); + + it('overwrites when saving under an existing name without duplicating the index entry', () => { + saveProject('same', { v: 1 }); + saveProject('same', { v: 2 }); + expect(listProjects()).toEqual(['same']); + expect(loadProject('same')!.workspaceJson).toEqual({ v: 2 }); + }); +}); diff --git a/src/project/validation.test.ts b/src/project/validation.test.ts new file mode 100644 index 0000000..a610e09 --- /dev/null +++ b/src/project/validation.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeProjectName, sanitizeFilename, validateProjectFile } from './validation'; + +describe('normalizeProjectName', () => { + it('trims and returns usable names', () => { + expect(normalizeProjectName(' My Game ')).toBe('My Game'); + }); + + it('strips control characters', () => { + expect(normalizeProjectName('a\x00b\nc')).toBe('abc'); + }); + + it('caps overly long names', () => { + expect(normalizeProjectName('x'.repeat(200))!.length).toBe(60); + }); + + it('rejects unusable input', () => { + expect(normalizeProjectName('')).toBeNull(); + expect(normalizeProjectName(' ')).toBeNull(); + expect(normalizeProjectName(42)).toBeNull(); + }); +}); + +describe('sanitizeFilename', () => { + it('replaces path separators and reserved characters', () => { + expect(sanitizeFilename('a/b\\c:d*e?f"gi|j')).toBe('a-b-c-d-e-f-g-h-i-j'); + }); + + it('never returns an empty or dot-only name', () => { + expect(sanitizeFilename('')).toBe('project'); + expect(sanitizeFilename('...')).toBe('project'); + expect(sanitizeFilename(undefined)).toBe('project'); + }); +}); + +describe('validateProjectFile', () => { + const valid = { formatVersion: 1, name: 'demo', updatedAt: '2026-01-01T00:00:00Z', workspaceJson: { blocks: {} } }; + + it('accepts a well-formed project and round-trips it', () => { + const project = validateProjectFile(JSON.parse(JSON.stringify(valid))); + expect(project.name).toBe('demo'); + expect(project.formatVersion).toBe(1); + expect(project.workspaceJson).toEqual({ blocks: {} }); + }); + + it('defaults a missing or junk name instead of failing', () => { + expect(validateProjectFile({ ...valid, name: undefined }).name).toBe('imported-project'); + expect(validateProjectFile({ ...valid, name: ' ' }).name).toBe('imported-project'); + }); + + it.each([ + ['null', null], + ['an array', []], + ['a string', 'nope'], + ['missing formatVersion', { workspaceJson: {} }], + ['string formatVersion', { formatVersion: '1', workspaceJson: {} }], + ['zero formatVersion', { formatVersion: 0, workspaceJson: {} }], + ['missing workspaceJson', { formatVersion: 1 }], + ['string workspaceJson', { formatVersion: 1, workspaceJson: 'blocks' }], + ])('rejects %s', (_label, input) => { + expect(() => validateProjectFile(input)).toThrowError(); + }); + + it('rejects future format versions with an upgrade message', () => { + expect(() => validateProjectFile({ formatVersion: 99, workspaceJson: {} })).toThrowError(/newer version/); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 8b0f57b..12bddbf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,14 @@ +/// import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' // https://vite.dev/config/ +// The base path defaults to '/' for local dev/builds; the GitHub Pages +// workflow overrides it with `--base=/coding-circus/` at build time. export default defineConfig({ plugins: [react()], + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts'], + }, }) From 716e3d704cc4a4eacb591591b725a07b65f2e2a9 Mon Sep 17 00:00:00 2001 From: TheRealBrofessor <196406002+TheRealBrofessor@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:46:54 -0400 Subject: [PATCH 5/8] Add landing hero, starter examples, and responsive layout - New Landing overlay on first visit: branding, feature list, "Start Coding" CTA, opt-in "Watch it build a program" (the existing live demo), and a clear disclosure that Python runs via Pyodide from a CDN. The demo no longer auto-plays; the toolbar button is now "Demo" and demo replays start from a cleared workspace. - Seven starter projects (Hello World, Variables, If/Else, Repeat Loop, Input, Lists, Random) stored as workspace JSON and loadable from a new Examples menu in the toolbar, with tests asserting each loads and generates artifact-free Python. - Responsive layout: panels stack under the editor below 900px; landing scales for small screens and respects prefers-reduced-motion. Co-Authored-By: Claude Fable 5 --- src/App.css | 29 ++++ src/App.tsx | 55 +++++-- src/components/Landing.css | 127 +++++++++++++++ src/components/Landing.tsx | 47 ++++++ src/components/LiveDemo.tsx | 2 + src/components/Toolbar.tsx | 27 +++- src/examples/examples.test.ts | 26 +++ src/examples/index.ts | 293 ++++++++++++++++++++++++++++++++++ 8 files changed, 595 insertions(+), 11 deletions(-) create mode 100644 src/components/Landing.css create mode 100644 src/components/Landing.tsx create mode 100644 src/examples/examples.test.ts create mode 100644 src/examples/index.ts diff --git a/src/App.css b/src/App.css index 66e31ce..03ad8e6 100644 --- a/src/App.css +++ b/src/App.css @@ -207,3 +207,32 @@ flex: 1 1 25%; border-bottom: none; } + +/* ---------- Responsive: stack panels under the editor on narrow screens ---------- */ + +@media (max-width: 900px) { + .app-body { + flex-direction: column; + overflow-y: auto; + } + + .block-editor { + flex: 0 0 auto; + min-height: 55vh; + } + + .app-side { + flex: 0 0 auto; + min-width: 0; + border-left: none; + border-top: 1px solid #ddd; + } + + .code-panel, + .console-panel, + .stage-panel { + flex: 0 0 auto; + min-height: 160px; + max-height: 240px; + } +} diff --git a/src/App.tsx b/src/App.tsx index 9815bf9..4560f82 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import * as Blockly from 'blockly/core'; import { BlockEditor } from './components/BlockEditor'; import { CodePanel } from './components/CodePanel'; import { ConsolePanel, type ConsoleLine } from './components/ConsolePanel'; +import { Landing } from './components/Landing'; import { LiveDemo } from './components/LiveDemo'; import { StagePanel } from './components/StagePanel'; import { Toolbar } from './components/Toolbar'; @@ -11,11 +12,15 @@ import type { NormalizedError } from './runner/RunResult'; import { listProjects, loadProject, saveProject } from './project/ProjectStorage'; import { exportProjectJson, exportPython, readProjectJsonFile } from './project/ProjectExport'; import type { ProjectFile } from './project/types'; +import { EXAMPLES } from './examples'; import './App.css'; const RUN_TIMEOUT_MS = 20_000; const INTRO_SESSION_KEY = 'coding-circus:intro-seen'; +/** Which full-screen overlay is showing: the landing hero, the self-building demo, or none. */ +type Overlay = 'landing' | 'demo' | null; + /** * Loads serialized workspace state, treating the input as untrusted: a corrupt * or hand-edited project must degrade to an error message, never a crash. On @@ -44,11 +49,11 @@ export default function App() { const [showRawTraceback, setShowRawTraceback] = useState(false); const [projectName, setProjectName] = useState('my-first-program'); const [savedProjects, setSavedProjects] = useState([]); - const [showIntro, setShowIntro] = useState(() => { + const [overlay, setOverlay] = useState(() => { try { - return sessionStorage.getItem(INTRO_SESSION_KEY) !== '1'; + return sessionStorage.getItem(INTRO_SESSION_KEY) === '1' ? null : 'landing'; } catch { - return true; + return 'landing'; } }); @@ -174,16 +179,44 @@ export default function App() { } }, []); - const dismissIntro = useCallback(() => { - setShowIntro(false); + const markIntroSeen = useCallback(() => { try { sessionStorage.setItem(INTRO_SESSION_KEY, '1'); } catch { - // Session storage may be unavailable (private browsing); the intro will just replay next load. + // Session storage may be unavailable (private browsing); the landing will just show again next load. } }, []); - const replayIntro = useCallback(() => setShowIntro(true), []); + const handleStartCoding = useCallback(() => { + markIntroSeen(); + setOverlay(null); + }, [markIntroSeen]); + + const handleWatchDemo = useCallback(() => { + markIntroSeen(); + setOverlay('demo'); + }, [markIntroSeen]); + + const dismissDemo = useCallback(() => { + markIntroSeen(); + setOverlay(null); + }, [markIntroSeen]); + + const handleLoadExample = useCallback( + (id: string) => { + const workspace = workspaceRef.current; + const example = EXAMPLES.find((e) => e.id === id); + if (!workspace || !example) return; + const result = loadWorkspaceState(workspace, example.state); + if (!result.ok) { + appendConsole('system', result.message); + return; + } + setProjectName(example.projectName); + appendConsole('system', `Loaded example "${example.label}". Click ▶ Run to try it!`); + }, + [appendConsole], + ); const handleImportProjectFile = useCallback( async (file: File) => { @@ -222,7 +255,8 @@ export default function App() { onExportPython={handleExportPython} onExportProject={handleExportProject} onImportProjectFile={handleImportProjectFile} - onReplayIntro={replayIntro} + onReplayIntro={handleWatchDemo} + onLoadExample={handleLoadExample} />
- {showIntro && ( + {overlay === 'demo' && ( )} + {overlay === 'landing' && } ); } diff --git a/src/components/Landing.css b/src/components/Landing.css new file mode 100644 index 0000000..d267dcf --- /dev/null +++ b/src/components/Landing.css @@ -0,0 +1,127 @@ +.landing { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: radial-gradient(ellipse at top, #2a2347 0%, #16112b 65%, #0e0a1d 100%); + overflow: auto; +} + +.landing-card { + max-width: 560px; + width: 100%; + text-align: center; + color: #f0edf8; + animation: landingFadeIn 0.5s ease both; +} + +.landing-emoji { + font-size: 64px; + line-height: 1; +} + +.landing-title { + margin: 12px 0 8px; + font-size: 44px; + font-weight: 800; + letter-spacing: 0.5px; + color: #fff; + text-shadow: 0 0 24px rgba(122, 92, 255, 0.45); +} + +.landing-tagline { + margin: 0 auto 20px; + max-width: 460px; + font-size: 18px; + line-height: 1.5; + color: #cfc7ea; +} + +.landing-features { + list-style: none; + margin: 0 auto 26px; + padding: 0; + display: inline-block; + text-align: left; + font-size: 15px; + line-height: 2; + color: #ddd6f3; +} + +.landing-actions { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; + margin-bottom: 22px; +} + +.landing-btn { + padding: 12px 22px; + font-size: 16px; + font-weight: 600; + color: #fff; + background: #382f5c; + border: 1px solid #504280; + border-radius: 8px; + cursor: pointer; +} + +.landing-btn:hover { + background: #4a3d78; +} + +.landing-btn:focus-visible { + outline: 2px solid #b7a4ff; + outline-offset: 2px; +} + +.landing-btn-primary { + background: #1f9d55; + border-color: #2bbf68; +} + +.landing-btn-primary:hover { + background: #24b862; +} + +.landing-footnote { + margin: 0 auto; + max-width: 440px; + font-size: 12.5px; + line-height: 1.5; + color: #9a8fc0; +} + +.landing-footnote a { + color: #b7a4ff; +} + +@keyframes landingFadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .landing-card { + animation: none; + } +} + +@media (max-width: 600px) { + .landing-title { + font-size: 34px; + } + .landing-tagline { + font-size: 16px; + } +} diff --git a/src/components/Landing.tsx b/src/components/Landing.tsx new file mode 100644 index 0000000..9236578 --- /dev/null +++ b/src/components/Landing.tsx @@ -0,0 +1,47 @@ +import './Landing.css'; + +interface LandingProps { + onStartCoding: () => void; + onWatchDemo: () => void; +} + +/** + * First-visit hero. Static and fast: the real product (the editor) is one + * click away, and the self-building demo is opt-in rather than forced. + */ +export function Landing({ onStartCoding, onWatchDemo }: LandingProps) { + return ( +
+
+ +

Coding Circus

+

+ Snap blocks together, watch real Python appear, and run it — right here in your browser. +

+
    +
  • 🧩 Drag blocks — no typing needed to start
  • +
  • 🐍 See the real Python code update live
  • +
  • ▶ Run it instantly, no installs, no account
  • +
  • 💾 Save, load, and export your programs
  • +
+
+ + +
+

+ Python runs in your browser via{' '} + + Pyodide + + , loaded from a CDN on first run — everything else works offline-style with no server. +

+
+
+ ); +} diff --git a/src/components/LiveDemo.tsx b/src/components/LiveDemo.tsx index 0f0deee..cc1be86 100644 --- a/src/components/LiveDemo.tsx +++ b/src/components/LiveDemo.tsx @@ -67,6 +67,8 @@ export function LiveDemo({ workspaceRef, onReveal, onRunDemo, onSaveDemo }: Live if (!workspace) return; started.current = true; cancelled.current = false; + // Replays start from a clean slate rather than stacking onto existing blocks. + workspace.clear(); (async () => { await playLiveDemo(workspace, LIVE_DEMO_SCRIPT, () => cancelled.current); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 6458874..90cc55f 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -1,4 +1,5 @@ import { useRef } from 'react'; +import { EXAMPLES } from '../examples'; interface ToolbarProps { isRunning: boolean; @@ -14,6 +15,7 @@ interface ToolbarProps { onExportProject: () => void; onImportProjectFile: (file: File) => void; onReplayIntro?: () => void; + onLoadExample?: (id: string) => void; } export function Toolbar({ @@ -30,6 +32,7 @@ export function Toolbar({ onExportProject, onImportProjectFile, onReplayIntro, + onLoadExample, }: ToolbarProps) { const importInputRef = useRef(null); @@ -49,6 +52,28 @@ export function Toolbar({ + {onLoadExample && ( +
+ +
+ )} +
{onReplayIntro && ( )}
diff --git a/src/examples/examples.test.ts b/src/examples/examples.test.ts new file mode 100644 index 0000000..46a5031 --- /dev/null +++ b/src/examples/examples.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { codeFor } from '../blockly/testUtils'; +import { EXAMPLES } from './index'; + +describe('starter examples', () => { + it('covers the advertised concepts', () => { + const ids = EXAMPLES.map((e) => e.id); + for (const required of ['hello-world', 'variables', 'if-else', 'repeat-loop', 'input', 'lists', 'random']) { + expect(ids).toContain(required); + } + }); + + it.each(EXAMPLES.map((e) => [e.label, e] as const))('"%s" loads and generates Python', (_label, example) => { + const code = codeFor(example.state); + expect(code.trim().length).toBeGreaterThan(0); + expect(code).not.toMatch(/NaN|undefined|Infinity/); + }); + + it('every example has a unique id and a usable project name', () => { + const ids = new Set(EXAMPLES.map((e) => e.id)); + expect(ids.size).toBe(EXAMPLES.length); + for (const example of EXAMPLES) { + expect(example.projectName.trim().length).toBeGreaterThan(0); + } + }); +}); diff --git a/src/examples/index.ts b/src/examples/index.ts new file mode 100644 index 0000000..9eb77e4 --- /dev/null +++ b/src/examples/index.ts @@ -0,0 +1,293 @@ +/** + * Starter projects, stored as Blockly workspace-serialization JSON — the same + * format saved projects use, so loading an example is exactly like loading a + * project. Each one is small, runs in the browser (except "Ask & Answer", + * which teaches input() and explains itself), and demonstrates one concept. + */ + +export interface ExampleProject { + id: string; + label: string; + projectName: string; + state: object; +} + +function program(blocks: object[], variables?: { name: string; id: string }[]): object { + return { + ...(variables ? { variables } : {}), + blocks: { languageVersion: 0, blocks }, + }; +} + +const str = (text: string) => ({ type: 'python_string', fields: { TEXT: text } }); +const num = (value: number) => ({ type: 'python_number', fields: { NUM: value } }); +const print = (value: object, next?: object) => ({ + type: 'python_print', + inputs: { VALUE: { block: value } }, + ...(next ? { next: { block: next } } : {}), +}); + +export const EXAMPLES: ExampleProject[] = [ + { + id: 'hello-world', + label: 'Hello World', + projectName: 'example-hello-world', + state: program([ + { + ...print(str('Hello, world!'), print(str('Welcome to Coding Circus 🎪'))), + x: 40, + y: 40, + }, + ]), + }, + { + id: 'variables', + label: 'Variables', + projectName: 'example-variables', + state: program( + [ + { + type: 'python_var_set', + x: 40, + y: 40, + fields: { VAR: { id: 'score-id' } }, + inputs: { VALUE: { block: num(10) } }, + next: { + block: { + type: 'python_var_set', + fields: { VAR: { id: 'score-id' } }, + inputs: { + VALUE: { + block: { + type: 'python_math_op', + fields: { OP: 'ADD' }, + inputs: { + A: { block: { type: 'python_var_get', fields: { VAR: { id: 'score-id' } } } }, + B: { block: num(5) }, + }, + }, + }, + }, + next: { block: { type: 'python_print_var', fields: { VAR: { id: 'score-id' } } } }, + }, + }, + }, + ], + [{ name: 'score', id: 'score-id' }], + ), + }, + { + id: 'if-else', + label: 'If / Else', + projectName: 'example-if-else', + state: program( + [ + { + type: 'python_var_set', + x: 40, + y: 40, + fields: { VAR: { id: 'coin-id' } }, + inputs: { + VALUE: { + block: { + type: 'python_random_int', + inputs: { FROM: { block: num(0) }, TO: { block: num(1) } }, + }, + }, + }, + next: { + block: { + type: 'python_if_else', + inputs: { + CONDITION: { + block: { + type: 'python_compare', + fields: { OP: 'EQ' }, + inputs: { + A: { block: { type: 'python_var_get', fields: { VAR: { id: 'coin-id' } } } }, + B: { block: num(0) }, + }, + }, + }, + DO: { block: print(str('Heads!')) }, + ELSE: { block: print(str('Tails!')) }, + }, + }, + }, + }, + ], + [{ name: 'coin', id: 'coin-id' }], + ), + }, + { + id: 'repeat-loop', + label: 'Repeat Loop', + projectName: 'example-repeat-loop', + state: program([ + { + type: 'python_repeat', + x: 40, + y: 40, + inputs: { + TIMES: { block: num(5) }, + DO: { + block: { + ...print(str('Around the ring we go! 🎠')), + next: { + block: { + type: 'python_wait', + inputs: { SECONDS: { block: num(0.3) } }, + }, + }, + }, + }, + }, + }, + ]), + }, + { + id: 'input', + label: 'Ask & Answer (input)', + projectName: 'example-input', + state: program( + [ + { + type: 'python_comment', + x: 40, + y: 40, + fields: { TEXT: 'input() needs a keyboard: export this as .py and run it with Python!' }, + next: { + block: { + type: 'python_var_set', + fields: { VAR: { id: 'name-id' } }, + inputs: { + VALUE: { + block: { + type: 'python_ask_text', + inputs: { QUESTION: { block: str('What is your name? ') } }, + }, + }, + }, + next: { + block: print({ + type: 'python_join', + inputs: { + A: { block: str('Hello, ') }, + B: { block: { type: 'python_var_get', fields: { VAR: { id: 'name-id' } } } }, + }, + }), + }, + }, + }, + }, + ], + [{ name: 'name', id: 'name-id' }], + ), + }, + { + id: 'lists', + label: 'Lists', + projectName: 'example-lists', + state: program( + [ + { + type: 'python_var_set', + x: 40, + y: 40, + fields: { VAR: { id: 'acts-id' } }, + inputs: { + VALUE: { + block: { + type: 'python_list_create', + inputs: { + ITEM0: { block: str('juggler') }, + ITEM1: { block: str('acrobat') }, + ITEM2: { block: str('clown') }, + }, + }, + }, + }, + next: { + block: { + type: 'python_list_append', + inputs: { + ITEM: { block: str('lion tamer') }, + LIST: { block: { type: 'python_var_get', fields: { VAR: { id: 'acts-id' } } } }, + }, + next: { + block: { + type: 'python_for_each', + fields: { VAR: { id: 'act-id' } }, + inputs: { + LIST: { block: { type: 'python_var_get', fields: { VAR: { id: 'acts-id' } } } }, + DO: { + block: print({ type: 'python_var_get', fields: { VAR: { id: 'act-id' } } }), + }, + }, + }, + }, + }, + }, + }, + ], + [ + { name: 'acts', id: 'acts-id' }, + { name: 'act', id: 'act-id' }, + ], + ), + }, + { + id: 'random', + label: 'Random', + projectName: 'example-random', + state: program( + [ + { + type: 'python_var_set', + x: 40, + y: 40, + fields: { VAR: { id: 'dice-id' } }, + inputs: { + VALUE: { + block: { + type: 'python_random_int', + inputs: { FROM: { block: num(1) }, TO: { block: num(6) } }, + }, + }, + }, + next: { + block: { + type: 'python_print_var', + fields: { VAR: { id: 'dice-id' } }, + next: { + block: { + type: 'python_say', + inputs: { + VALUE: { + block: { + type: 'python_random_choice', + inputs: { + LIST: { + block: { + type: 'python_list_create', + inputs: { + ITEM0: { block: str('🎪') }, + ITEM1: { block: str('🤹') }, + ITEM2: { block: str('🎠') }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + [{ name: 'dice', id: 'dice-id' }], + ), + }, +]; From b9de4d5840c313443cf33fc47b153eedbd87e0c5 Mon Sep 17 00:00:00 2001 From: TheRealBrofessor <196406002+TheRealBrofessor@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:48:30 -0400 Subject: [PATCH 6/8] Add GitHub Pages deployment and expand CI - New deploy-pages workflow: builds with --base=/coding-circus/ and deploys dist/ via actions/deploy-pages on pushes to main (Pages source must be set to "GitHub Actions"; on GitHub Free this requires the repo to be public). - Blockly media now loads relative to BASE_URL so the editor works when served from a subpath. - CI now runs typecheck, lint, test, and build. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 6 ++-- .github/workflows/deploy-pages.yml | 50 ++++++++++++++++++++++++++++++ src/components/BlockEditor.tsx | 3 +- 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/deploy-pages.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 383d43c..eec1ad6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: branches: [main] jobs: - build: + verify: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -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 diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 0000000..8b35115 --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -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://.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 diff --git a/src/components/BlockEditor.tsx b/src/components/BlockEditor.tsx index ba5c776..2aa9e89 100644 --- a/src/components/BlockEditor.tsx +++ b/src/components/BlockEditor.tsx @@ -16,7 +16,8 @@ export function BlockEditor({ onCodeChange, onWorkspaceReady }: BlockEditorProps const workspace = Blockly.inject(containerRef.current, { toolbox, - media: '/blockly-media/', + // BASE_URL-relative so the app works at a subpath (e.g. GitHub Pages). + media: `${import.meta.env.BASE_URL}blockly-media/`, trashcan: true, zoom: { controls: true, wheel: true, startScale: 1 }, grid: { spacing: 20, length: 3, colour: '#e5e5e5', snap: true }, From 971df60ce256f1527f5247d36f6626f30c395673 Mon Sep 17 00:00:00 2001 From: TheRealBrofessor <196406002+TheRealBrofessor@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:51:11 -0400 Subject: [PATCH 7/8] Update documentation: README, ARCHITECTURE, block inventory - BLOCKS.md: full inventory table (block type, category, generated Python, status, runtime notes) for all 42 blocks. - ARCHITECTURE.md: generator safety rules, design decisions (input/stdin limitation, print-based stage convention, no-parameter functions, 0-based list indexing), persistence untrusted-data posture with the formatVersion migration hook, and the static-site/Pages deployment section. - README.md: updated features, runtime/limitation disclosures, scripts table, Pages deployment instructions, and project structure. Co-Authored-By: Claude Fable 5 --- ARCHITECTURE.md | 29 +++++++++++++++++++-- BLOCKS.md | 49 ++++++++++++++++++++++++++++++++++++ README.md | 67 +++++++++++++++++++++++++++++++++++-------------- 3 files changed, 124 insertions(+), 21 deletions(-) create mode 100644 BLOCKS.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 88cb3b9..3ed009b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 @@ -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). diff --git a/BLOCKS.md b/BLOCKS.md new file mode 100644 index 0000000..491f08b --- /dev/null +++ b/BLOCKS.md @@ -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 | diff --git a/README.md b/README.md index d3013a3..a5edeac 100644 --- a/README.md +++ b/README.md @@ -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 @@ -33,9 +41,14 @@ 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 @@ -43,24 +56,40 @@ hosted anywhere that serves static files. | --- | --- | | `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. From aa1ec610bfb730f704a65fcffa80f6ec9e96da4a Mon Sep 17 00:00:00 2001 From: TheRealBrofessor <196406002+TheRealBrofessor@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:53:09 -0400 Subject: [PATCH 8/8] Pin jsdom to ^27 to satisfy blockly's peer dependency npm ci (strict peer resolution, as used in CI) fails with jsdom 29 because blockly@13 declares a peer dependency on jsdom@^27. Co-Authored-By: Claude Fable 5 --- package-lock.json | 237 ++++++++++++++++++++++++++++++---------------- package.json | 2 +- 2 files changed, 155 insertions(+), 84 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9dbdddf..ba9deae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,52 +18,43 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", - "jsdom": "^29.1.1", + "jsdom": "^27.4.0", "oxlint": "^1.71.0", "typescript": "~6.0.2", "vite": "^8.1.1", "vitest": "^4.1.10" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" } }, "node_modules/@asamuzakjp/nwsapi": { @@ -72,18 +63,6 @@ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", "license": "MIT" }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, "node_modules/@csstools/color-helpers": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", @@ -1104,6 +1083,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1165,6 +1153,21 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1173,16 +1176,42 @@ "license": "MIT" }, "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" + "whatwg-url": "^15.1.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/decimal.js": { @@ -1285,6 +1314,32 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -1292,35 +1347,34 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", - "parse5": "^8.0.1", + "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", + "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -1617,6 +1671,12 @@ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -2002,15 +2062,6 @@ "node": ">=14.17" } }, - "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -2208,26 +2259,25 @@ } }, "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", "license": "MIT", "dependencies": { - "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" + "webidl-conversions": "^8.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=20" } }, "node_modules/why-is-node-running": { @@ -2247,6 +2297,27 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index 46ff323..0465294 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", - "jsdom": "^29.1.1", + "jsdom": "^27.4.0", "oxlint": "^1.71.0", "typescript": "~6.0.2", "vite": "^8.1.1",