« get me outta code hell

scratchblocks-generator-3 - scratchblocks generator for projects made in 3.0
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/css/style.css173
-rw-r--r--src/index.html34
-rw-r--r--src/js/cli-playground.js21
-rw-r--r--src/js/main.js222
-rw-r--r--src/js/sb3-gen.js433
5 files changed, 883 insertions, 0 deletions
diff --git a/src/css/style.css b/src/css/style.css
new file mode 100644
index 0000000..f16b035
--- /dev/null
+++ b/src/css/style.css
@@ -0,0 +1,173 @@
+/* general */
+
+body, html, #container {
+    padding: 0;
+    margin: 0;
+    width: 100%;
+    height: 100%;
+}
+
+body {
+    font-family: -apple-system, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica Neue;
+    font-size: 20px;
+    text-align: center;
+}
+
+#container {
+    display: flex;
+    flex-direction: column;
+}
+
+#container h1 {
+    flex-grow: 0;
+}
+
+#setup-step {
+    flex-grow: 0;
+}
+
+#present-step {
+    flex-grow: 1;
+}
+
+/* setup */
+
+#url-input-container {
+    display: flex;
+    justify-content: center;
+}
+
+#url-input {
+    padding: 16px 24px;
+    background-color: hsl(212, 33%, 89%);
+    border-radius: 5px;
+    border: none;
+    flex-basis: 400px;
+    margin-bottom: 20px;
+    text-align: center;
+}
+
+.button {
+    padding: 10px 20px;
+    background-color: hsl(215, 100%, 65%);
+    color: white;
+    border: none;
+    border-radius: 200px;
+    cursor: pointer;
+    font-size: 16px;
+    transition: background-color 0.2s;
+    margin-top: 20px;
+}
+
+.button:hover {
+    background-color: hsl(215, 100%, 60%);
+}
+
+.button:focus {
+    /* border: 4px solid hsl(215, 100%, 85%); */
+    box-shadow: inset 0 -3px 0 hsl(215, 100%, 85%);
+}
+
+#url-input:focus {
+    box-shadow: inset 0 -2px 0 hsl(215, 100%, 80%);
+}
+
+#file-input {
+    display: none;
+}
+
+#or {
+    margin: 0 10px;
+}
+
+.button.disabled {
+    background-color: hsl(0, 0%, 60%);
+}
+
+#error-element {
+    color: red;
+    white-space: pre-wrap;
+    margin-top: 20px;
+}
+
+#error-element:empty {
+    display: none;
+}
+
+#download-indicator {
+    width: 0;
+    height: 10px;
+    background-color: hsla(215, 100%, 65%, 0.5);
+    transition: width 0.1s, opacity 1s;
+}
+
+#setup-step {
+    margin-bottom: 20px;
+}
+
+/* present */
+
+#present-step {
+    position: relative;
+}
+
+#target-list {
+    flex-grow: 0;
+    width: 180px;
+    position: absolute;
+    left: 0;
+    top: 0;
+    height: 100%;
+    background-color: rgba(255, 255, 255, 0.8);
+    border-right: 1px solid #cccccc;
+}
+
+.target {
+    display: flex;
+    flex-direction: column;
+    border: 1px solid black;
+    align-items: center;
+    padding: 10px;
+    margin: 10px;
+    cursor: pointer;
+    background-color: rgba(255, 255, 255, 0.5);
+}
+
+.target img {
+    max-width: 80px;
+    max-height: 80px;
+    margin: 4px;
+}
+
+.target span {
+    font-weight: 800;
+    overflow-wrap: break-word;
+}
+
+.target.selected {
+    background-color: hsl(215, 100%, 65%);
+}
+
+#script-area {
+    position: relative;
+    overflow: scroll;
+    flex-grow: 1;
+    background-color: #eeeeee;
+    height: 100%;
+    width: 100%;
+}
+
+.scratchblocks {
+    position: relative;
+}
+
+.scratchblocks::before {
+    position: absolute;
+    content: ' ';
+    top: -40px;
+    left: -40px;
+    width: 100%;
+    height: 100%;
+    padding: 40px;
+    z-index: -1;
+}
diff --git a/src/index.html b/src/index.html
new file mode 100644
index 0000000..d6f887e
--- /dev/null
+++ b/src/index.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <meta charset="utf-8">
+        <title>scratchblocks 3 generator</title>
+        <script src="https://scratchblocks.github.io/js/scratchblocks-v3.4-min.js"></script>
+    </head>
+    <body>
+        <div id="container">
+            <h1>scratchblocks 3 generator</h1>
+            <div id="setup-step">
+                <form id="form">
+                    <div id="url-input-container">
+                        <input id="url-input" type="text" placeholder="https://scratch.mit.edu/projects/...">
+                    </div>
+                    <div id="download-indicator"></div>
+                    <a id="generate-button" class="button" tabindex="0">Generate</a>
+                    <span id="or">or</span>
+                    <label>
+                        <a id="upload-button" class="button" tabindex="0">Upload a Project</a>
+                        <input id="file-input" type="file" accept=".sb3">
+                    </label>
+                    <div id="error-element"></div>
+                </form>
+            </div>
+            <div id="present-step">
+                <div id="script-area">
+                </div>
+                <div id="target-list">
+                </div>
+            </div>
+        </div>
+    </body>
+</html>
diff --git a/src/js/cli-playground.js b/src/js/cli-playground.js
new file mode 100644
index 0000000..c5829c2
--- /dev/null
+++ b/src/js/cli-playground.js
@@ -0,0 +1,21 @@
+'use strict';
+
+// Not actually included in the output bundle.
+
+const { scriptToScratchblocks } = require('./sb3-gen');
+
+async function main() {
+    const fs = require('fs');
+    const path = require('path');
+    const readFile = util.promisify(fs.readFile);
+    const sb3 = process.argv[2];
+    if (!sb3) {
+        console.error('Please pass an sb3 file.');
+        process.exit(1);
+    }
+    const file = await readFile(sb3);
+    const project = await Project.fromSb3(file);
+    console.log(scriptToScratchblocks(project.sprites[1].scripts[0], project.sprites[1]));
+}
+
+main().catch(err => console.error(err));
diff --git a/src/js/main.js b/src/js/main.js
new file mode 100644
index 0000000..3a3f823
--- /dev/null
+++ b/src/js/main.js
@@ -0,0 +1,222 @@
+'use strict';
+
+require('../index.html');
+require('../css/style.css');
+
+const { Project } = require('sb-edit');
+const { scriptToScratchblocks } = require('./sb3-gen');
+
+// setup step
+
+const urlInput = document.getElementById('url-input');
+const fileInput = document.getElementById('file-input');
+const generateButton = document.getElementById('generate-button');
+const errorElement = document.getElementById('error-element');
+const downloadIndicator = document.getElementById('download-indicator');
+
+function assetURL({md5, ext}) {
+    return 'https://assets.scratch.mit.edu/' + md5 + '.' + ext;
+}
+
+function isUrlInputValid() {
+    const re = /^((.*:\/\/scratch.mit.edu\/projects\/[0-9]+\/?)|[0-9]+)(:.*)?$/;
+    return re.test(urlInput.value);
+}
+
+urlInput.addEventListener('input', () => {
+    if (urlInput.value === '' || isUrlInputValid()) {
+        generateButton.classList.add('enabled');
+        generateButton.classList.remove('disabled');
+    } else {
+        generateButton.classList.remove('enabled');
+        generateButton.classList.add('disabled');
+    }
+});
+
+function clearChildren(el) {
+    while (el.firstChild) {
+        el.firstChild.remove();
+    }
+}
+
+function showError(msg) {
+    clearChildren(errorElement);
+    errorElement.appendChild(document.createTextNode(msg));
+}
+
+async function submitGenerate(evt) {
+    try {
+        if (!isUrlInputValid()) {
+            return;
+        }
+
+        const url = urlInput.value;
+        const id = url.match(/[0-9]+/)[0];
+        const result = await fetch('https://projects.scratch.mit.edu/' + id);
+
+        let projectObj;
+        try {
+            projectObj = await result.json();
+        } catch (error) { /* Scratch 1.4. */ }
+        if (!(projectObj && projectObj.meta && projectObj.meta.semver && projectObj.meta.semver.startsWith('3.'))) {
+            showError(`That project hasn't been saved in Scratch 3.0, so it won't work here.\nTry remixing it! That will make an up-to-date version.`);
+            return;
+        }
+
+        let assetsDownloaded = 0;
+        const totalAssets = projectObj.targets.map(t => t.costumes.length + t.sounds.length).reduce((a, b) => a + b, 0);
+        const project = await Project.fromSb3JSON(projectObj, {
+            getAsset: async asset => {
+                const body = await fetch(assetURL(asset));
+                const blob = await body.blob();
+                assetsDownloaded++;
+                downloadIndicator.style.width = `${Math.round(assetsDownloaded / totalAssets * 100)}%`;
+                return blob;
+            }
+        });
+        downloadIndicator.style.opacity = 0;
+        presentProject(project);
+    } catch (error) {
+        console.error(error);
+        showError(`Sorry, there was an error loading that project.`);
+    }
+}
+
+async function submitFile() {
+    try {
+        if (!fileInput.files.length) {
+            return;
+        }
+
+        const file = fileInput.files[0];
+        const project = await Project.fromSb3(file);
+        presentProject(project);
+    } catch (error) {
+        console.error(error);
+        showError(`Sorry, there was an error loading that project.`);
+    }
+}
+
+document.getElementById('form').addEventListener('submit', evt => {
+    evt.preventDefault();
+    submitGenerate();
+});
+
+function spaceEnterToClick(a) {
+    a.addEventListener('keydown', evt => {
+        if (evt.keyCode === 13 || evt.keyCode === 32) {
+            evt.target.click();
+        }
+    });
+}
+
+for (const a of document.querySelectorAll('a.button')) {
+    spaceEnterToClick(a);
+}
+
+document.getElementById('generate-button').addEventListener('click', submitGenerate);
+fileInput.addEventListener('input', submitFile);
+
+if (location.hash.length > 1) {
+    urlInput.value = decodeURIComponent(location.hash.slice(1));
+    submitGenerate();
+}
+
+// present step
+
+const targetList = document.getElementById('target-list');
+const scriptArea = document.getElementById('script-area');
+
+let project;
+let selectedTarget;
+const sym = Symbol();
+
+function presentTarget(targetName) {
+    if (!project) {
+        return;
+    }
+
+    const target = [project.stage, ...project.sprites].find(t => t.name === targetName);
+    if (!target) {
+        return;
+    }
+
+    if (target === selectedTarget) {
+        return;
+    }
+
+    if (selectedTarget) {
+        if (!selectedTarget[sym]) {
+            selectedTarget[sym] = {};
+        }
+        Object.assign(selectedTarget[sym], {
+            scrollLeft: scriptArea.scrollLeft,
+            scrollTop: scriptArea.scrollTop
+        });
+    }
+
+    selectedTarget = target;
+
+    if (target[sym]) {
+        Object.assign(scriptArea, {
+            scrollLeft: target[sym].scrollLeft,
+            scrollTop: target[sym].scrollTop
+        });
+    } else {
+        Object.assign(scriptArea, {
+            scrollLeft: 0,
+            scrollTop: 0
+        });
+    }
+
+    for (const el of document.querySelectorAll('.target.selected')) {
+        el.classList.remove('selected');
+    }
+    document.querySelector(`.target[data-name="${targetName.replace(/"/g, '\\"')}"]`).classList.add('selected');
+
+    clearChildren(scriptArea);
+    if (target.scripts.length) {
+        const offsetX = -target.scripts.reduce((least, s) => Math.min(least, s.x), target.scripts[0].x) + 240;
+        const offsetY = -target.scripts.reduce((least, s) => Math.min(least, s.y), target.scripts[0].y);
+        for (const script of target.scripts) {
+            const sb = scriptToScratchblocks(script, target);
+            const pre = document.createElement('pre');
+            pre.classList.add('blocks');
+            pre.appendChild(document.createTextNode(sb));
+            pre.style.position = 'absolute';
+            pre.style.left = (script.x + offsetX) + 'px';
+            pre.style.top = (script.y + offsetY) + 'px';
+            scriptArea.appendChild(pre);
+        }
+    }
+
+    window.scratchblocks.renderMatching('pre.blocks', {
+        style: 'scratch3'
+    });
+}
+
+function presentProject(p) {
+    project = p;
+
+    clearChildren(targetList);
+    for (const target of [project.stage, ...project.sprites]) {
+        const el = document.createElement('a');
+        el.setAttribute('tabindex', 0);
+        el.classList.add('target');
+        const img = document.createElement('img');
+        img.src = assetURL(target.costumes[target.costumeNumber]);
+        el.appendChild(img);
+        const span = document.createElement('span');
+        span.appendChild(document.createTextNode(target.name));
+        el.appendChild(span);
+        el.dataset.name = target.name;
+        targetList.appendChild(el);
+        spaceEnterToClick(el);
+        el.addEventListener('click', () => presentTarget(target.name));
+    }
+
+    if (location.hash.includes(':')) {
+        const targetName = decodeURIComponent(location.hash.slice(location.hash.indexOf(':') + 1));
+        presentTarget(targetName);
+    }
+}
diff --git a/src/js/sb3-gen.js b/src/js/sb3-gen.js
new file mode 100644
index 0000000..615d73a
--- /dev/null
+++ b/src/js/sb3-gen.js
@@ -0,0 +1,433 @@
+'use strict';
+
+const { Project } = require('sb-edit');
+const util = require('util');
+
+// TODO: set prefix to \t
+function indent(string, prefix = '    ') {
+    return string.split('\n').map(l => prefix + l).join('\n');
+}
+
+function input(inp, target, flag = false) {
+    if (!inp) {
+        return '';
+    }
+    const val = inp.value.replace ? inp.value.replace(/[()\[\]]|v$/g, m => '\\' + m) : inp.value;
+    switch (inp.type) {
+        case 'number':
+        case 'angle':
+            return `(${val})`;
+
+        case 'string':
+            return `[${val}]`;
+
+        case 'variable':
+        case 'list':
+        case 'graphicEffect':
+        case 'rotationStyle':
+        case 'greaterThanMenu':
+        case 'stopMenu':
+        case 'dragModeMenu':
+        case 'propertyOfMenu':
+        case 'currentMenu':
+        case 'mathopMenu':
+        case 'frontBackMenu':
+        case 'forwardBackwardMenu':
+        case 'costumeNumberName':
+            return `[${val} v]`;
+
+        case 'costume':
+        case 'sound':
+        case 'goToTarget':
+        case 'pointTowardsTarget':
+        case 'penColorParam':
+        case 'target':
+        case 'cloneTarget':
+        case 'touchingTarget':
+        case 'distanceToMenu':
+            return `(${val} v)`;
+
+        case 'broadcast':
+        case 'backdrop':
+        case 'key':
+            if (flag) {
+                return `[${val} v]`;
+            } else {
+                return `(${val} v)`;
+            }
+
+        case 'color':
+            const hex = k => inp.value[k].toString(16).padStart(2, '0')
+            return `[#${hex('r') + hex('g') + hex('b')}]`;
+
+        case 'block':
+            if (flag) {
+                return '\n' + indent(blockToScratchblocks(inp.value, target)) + '\n';
+            } else {
+                return blockToScratchblocks(inp.value, target);
+            }
+
+        case 'blocks':
+            return '\n' + indent(blocksToScratchblocks(inp.value, target)) + '\n';
+
+        default:
+            return `\x1b[33m<${inp.type}>\x1b[0m`;
+    }
+}
+
+function blockToScratchblocks(block, target) {
+    if (!target) {
+        throw new Error('expected target');
+    }
+
+    const i = (key, ...args) => input(block.inputs[key], target, ...args);
+    const operator = op => `(${i('NUM1')} ${op} ${i('NUM2')})`;
+    const boolop = op => `<${i('OPERAND1')} ${op} ${i('OPERAND2')}>`;
+
+    switch (block.opcode) {
+
+            // motion ------------------------------------------------------ //
+        case 'motion_movesteps':
+            return `move ${i('STEPS')} steps`;
+        case 'motion_turnright':
+            return `turn cw ${i('DEGREES')} degrees`;
+        case 'motion_turnleft':
+            return `turn ccw ${i('DEGREES')} degrees`;
+        case 'motion_goto':
+            return `go to ${i('TO')}`;
+        case 'motion_gotoxy':
+            return `go to x: ${i('X')} y: ${i('Y')}`;
+        case `motion_glideto`:
+            return `glide ${i('SECS')} secs to ${i('TO')}`;
+        case `motion_glidesecstoxy`:
+            return `glide ${i('SECS')} secs to x: ${i('X')} y: ${i('Y')}`;
+        case 'motion_pointindirection':
+            return `point in direction ${i('DIRECTION')}`;
+        case 'motion_pointtowards':
+            return `point towards ${i('TOWARDS')}`;
+        case 'motion_changexby':
+            return `change x by ${i('DX')}`;
+        case 'motion_setx':
+            return `set x to ${i('X')}`;
+        case 'motion_changeyby':
+            return `change y by ${i('DY')}`;
+        case 'motion_sety':
+            return `set y to ${i('Y')}`;
+        case 'motion_ifonedgebounce':
+            return 'if on edge, bounce';
+        case 'motion_setrotationstyle':
+            return `set rotation style ${i('STYLE')}`;
+        case 'motion_xposition':
+            return '(x position)';
+        case 'motion_yposition':
+            return '(y position)';
+        case 'motion_direction':
+            return '(direction)';
+
+            // looks ------------------------------------------------------- //
+        case 'looks_sayforsecs':
+            return `say ${i('MESSAGE')} for ${i('SECS')} seconds`;
+        case 'looks_say':
+            return `say ${i('MESSAGE')}`;
+        case 'looks_thinkforsecs':
+            return `think ${i('MESSAGE')} for ${i('SECS')} seconds`;
+        case 'looks_think':
+            return `think ${i('MESSAGE')}`;
+        case 'looks_switchcostumeto':
+            return `switch costume to ${i('COSTUME')}`;
+        case 'looks_nextcostume':
+            return 'next costume';
+        case 'looks_switchbackdropto':
+            return `switch backdrop to ${i('BACKDROP')}`;
+        case 'looks_nextbackdrop':
+            return `next backdrop`;
+        case 'looks_changesizeby':
+            return `change size by ${i('CHANGE')}`;
+        case 'looks_setsizeto':
+            return `set size to ${i('SIZE')}%`;
+        case 'looks_changeeffectby':
+            return `change ${i('EFFECT')} effect by ${i('CHANGE')} :: looks`;
+        case 'looks_seteffectto':
+            return `set ${i('EFFECT')} effect to ${i('VALUE')} :: looks`;
+        case 'looks_cleargraphiceffects':
+            return 'clear graphic effects';
+        case 'looks_show':
+            return 'show';
+        case 'looks_hide':
+            return 'hide';
+        case 'looks_gotofrontback':
+            return `go to ${i('FRONT_BACK')} layer`;
+        case 'looks_goforwardbackwardlayers':
+            return `go ${i('FORWARD_BACKWARD')} ${i('NUM')} layers`;
+        case 'looks_costumenumbername':
+            return `(costume ${i('NUMBER_NAME')})`;
+        case 'looks_backdropnumbername':
+            return `(backdrop ${i('NUMBER_NAME')})`;
+        case 'looks_size':
+            return '(size)';
+        case 'looks_hideallsprites':
+            return 'hide all sprites :: looks';
+        case 'looks_switchbackdroptoandwait':
+            return `switch backdrop to ${i('BACKDROP')} and wait`;
+        case 'looks_changestretchby':
+            return `change stretch by ${i('CHANGE')} :: looks`;
+        case 'looks_setstretchto':
+            return `set stretch to ${i('STRETCH')} % :: looks`;
+
+            // sound ------------------------------------------------------- //
+        case 'sound_playuntildone':
+            return `play sound ${i('SOUND_MENU')} until done`;
+        case 'sound_play':
+            return `start sound ${i('SOUND_MENU')}`;
+        case 'sound_stopallsounds':
+            return 'stop all sounds';
+        case 'sound_changeeffectby':
+            return `change ${i('EFFECT')} effect by ${i('VALUE')} :: sound`;
+        case 'sound_seteffectto':
+            return `set ${i('EFFECT')} effect to ${i('VALUE')} :: sound`;
+        case 'sound_cleareffects':
+            return 'clear sound effects';
+        case 'sound_changevolumeby':
+            return `change volume by ${i('VOLUME')}`;
+        case 'sound_setvolumeto':
+            return `set volume to ${i('VOLUME')} %`;
+        case 'sound_volume':
+            return '(volume)';
+
+            // events ------------------------------------------------------ //
+        case 'event_whenflagclicked':
+            return 'when green flag clicked';
+        case 'event_whenkeypressed':
+            return `when ${i('KEY_OPTION', true)} key pressed`;
+        case 'event_whenthisspriteclicked':
+            return 'when this sprite clicked';
+        case 'event_whenbackdropswitchesto':
+            return `when backdrop switches to ${i('BACKDROP', true)}`;
+        case 'event_whengreaterthan':
+            return `when ${i('WHENGREATERTHANMENU')} > ${i('VALUE')}`;
+        case 'event_whenbroadcastreceived':
+            return `when I receive ${i('BROADCAST_OPTION', true)}`;
+        case 'event_broadcast':
+            return `broadcast ${i('BROADCAST_INPUT')}`;
+        case 'event_broadcastandwait':
+            return `broadcast ${i('BROADCAST_INPUT')} and wait`;
+
+            // control ----------------------------------------------------- //
+        case 'control_wait':
+            return `wait ${i('DURATION')} seconds`;
+        case 'control_repeat':
+            return `repeat ${i('TIMES')}` + i('SUBSTACK', true) + 'end';
+        case 'control_forever':
+            return 'forever' + i('SUBSTACK', true) + 'end';
+        case 'control_if':
+            return `if ${i('CONDITION') || '<>'} then` + i('SUBSTACK', true) + 'end';
+        case 'control_if_else':
+            return `if ${i('CONDITION') || '<>'} then` + i('SUBSTACK', true) + 'else' + i('SUBSTACK2', true) + 'end';
+        case 'control_wait_until':
+            return `wait until ${i('CONDITION') || '<>'}`;
+        case 'control_repeat_until':
+            return `repeat until ${i('CONDITION') || '<>'}` + i('SUBSTACK', true) + 'end';
+        case 'control_stop':
+            return `stop ${i('STOP_OPTION')}`;
+        case 'control_start_as_clone':
+            return 'when I start as a clone';
+        case 'control_create_clone_of':
+            return `create clone of ${i('CLONE_OPTION')}`;
+        case 'control_delete_this_clone':
+            return 'delete this clone';
+
+            // sensing ----------------------------------------------------- //
+        case 'sensing_touchingobject':
+            return `<touching ${i('TOUCHINGOBJECTMENU')} ?>`;
+        case 'sensing_touchingcolor':
+            return `<touching ${i('COLOR')} ?>`;
+        case 'sensing_coloristouchingcolor':
+            return `<${i('COLOR')} is touching ${i('COLOR2')} ?>`;
+        case 'sensing_distanceto':
+            return `(distance to ${i('DISTANCETOMENU')})`;
+        case 'sensing_askandwait':
+            return `ask ${i('QUESTION')} and wait`;
+        case 'sensing_answer':
+            return '(answer)';
+        case 'sensing_keypressed':
+            return `<key ${i('KEY_OPTION')} pressed?>`;
+        case 'sensing_mousedown':
+            return '<mouse down?>';
+        case 'sensing_mousex':
+            return '(mouse x)';
+        case 'sensing_mousey':
+            return '(mouse y)';
+        case 'sensing_setdragmode':
+            return `set drag mode ${i('DRAG_MODE')}`;
+        case 'sensing_loudness':
+            return '(loudness)';
+        case 'sensing_loud':
+            return '<loud? :: sensing>';
+        case 'sensing_timer':
+            return '(timer)';
+        case 'sensing_resettimer':
+            return 'reset timer';
+        case 'sensing_of':
+            return `(${i('PROPERTY')} of ${i('OBJECT')})`;
+        case 'sensing_current':
+            return `(current ${i('CURRENTMENU')})`;
+        case 'sensing_dayssince2000':
+            return '(days since 2000)';
+        case 'sensing_username':
+            return '(username)';
+        case 'sensing_userid':
+            return '(user id :: sensing)';
+
+            // operators --------------------------------------------------- //
+        case 'operator_add':
+            return operator('+');
+        case 'operator_subtract':
+            return operator('-');
+        case 'operator_multiply':
+            return operator('*');
+        case 'operator_divide':
+            return operator('/');
+        case 'operator_random':
+            return `(pick random ${i('FROM')} to ${i('TO')})`;
+        case 'operator_gt':
+            return boolop('>');
+        case 'operator_lt':
+            return boolop('<');
+        case 'operator_equals':
+            return boolop('=');
+        case 'operator_and':
+            return boolop('and');
+        case 'operator_or':
+            return boolop('or');
+        case 'operator_not':
+            return `<not ${i('OPERAND')}>`;
+        case 'operator_join':
+            return `(join ${i('STRING1')} ${i('STRING2')})`;
+        case 'operator_letter_of':
+            return `(letter ${i('LETTER')} of ${i('STRING')})`;
+        case 'operator_length':
+            return `(length of ${i('STRING')})`;
+        case 'operator_contains':
+            return `<${i('STRING1')} contains ${i('STRING2')} ? :: operators>`;
+        case 'operator_mod':
+            return operator('mod');
+        case 'operator_round':
+            return `(round ${i('NUM')})`;
+        case 'operator_mathop':
+            return `(${i('OPERATOR')} of ${i('NUM')})`;
+
+            // data -------------------------------------------------------- //
+        case 'data_variable':
+            return `(${block.inputs.VARIABLE.value} :: variables)`;
+        case 'data_setvariableto':
+            return `set ${i('VARIABLE')} to ${i('VALUE')}`;
+        case 'data_changevariableby':
+            return `change ${i('VARIABLE')} by ${i('VALUE')}`;
+        case 'data_showvariable':
+            return `show variable ${i('VARIABLE')}`;
+        case 'data_hidevariable':
+            return `hide variable ${i('VARIABLE')}`;
+        case 'data_listcontents':
+            return `(${block.inputs.LIST.value} :: list)`;
+        case 'data_addtolist':
+            return `add ${i('ITEM')} to ${i('LIST')}`;
+        case 'data_deleteoflist':
+            return `delete ${i('INDEX')} of ${i('LIST')}`;
+        case 'data_deletealloflist':
+            return `delete all of ${i('LIST')}`;
+        case 'data_insertatlist':
+            return `insert ${i('ITEM')} at ${i('INDEX')} of ${i('LIST')}`;
+        case 'data_replaceitemoflist':
+            return `replace item ${i('INDEX')} of ${i('LIST')} with ${i('ITEM')}`;
+        case 'data_itemoflist':
+            return `(item ${i('INDEX')} of ${i('LIST')})`;
+        case 'data_itemnumoflist':
+            return `(item # of ${i('ITEM')} in ${i('LIST')})`;
+        case 'data_lengthoflist':
+            return `(length of ${i('LIST')})`;
+        case 'data_listcontainsitem':
+            return `<${i('LIST')} contains ${i('ITEM')} ? :: list>`;
+        case 'data_showlist':
+            return `show list ${i('LIST')}`;
+        case 'data_hidelist':
+            return `hide list ${i('LIST')}`;
+
+            // custom blocks ----------------------------------------------- //
+        case 'procedures_definition':
+            const spec = block.inputs.ARGUMENTS.value.map(({ type, name }) => {
+                switch (type) {
+                    case 'label':
+                        return name.replace(/\//g, '\\/');
+                    case 'numberOrString':
+                        return `(${name})`;
+                    case 'boolean':
+                        return `<${name}>`;
+                }
+            }).join(' ');
+            return `define ${spec}` + (block.inputs.WARP.value ? ' // run without screen refresh' : '');
+        case 'procedures_call':
+            const definition = target.scripts.map(s => s.blocks[0]).find(b => b.opcode === 'procedures_definition' && b.inputs.PROCCODE.value === block.inputs.PROCCODE.value);
+            if (definition) {
+                let index = 0;
+                return definition.inputs.ARGUMENTS.value.map(({ type, name }) => {
+                    switch (type) {
+                        case 'label':
+                            return name.replace(/\//g, '\\/');
+                        default:
+                            // TODO: deal with empty boolean inputs, which can't even load yet
+                            return input(block.inputs.INPUTS.value[index++], target);
+                    }
+                }).join(' ') + ' :: custom';
+            } else {
+                return `... // missing custom block definition for ${block.inputs.PROCCODE.value}`;
+            }
+        case 'argument_reporter_string_number':
+            return `(${block.inputs.VALUE.value} :: custom-arg)`;
+        case 'argument_reporter_boolean':
+            return `<${block.inputs.VALUE.value} :: custom-arg>`;
+
+            // extension: pen ---------------------------------------------- //
+        case 'pen_clear':
+            return 'erase all';
+        case 'pen_stamp':
+            return 'stamp';
+        case 'pen_penDown':
+            return 'pen down';
+        case 'pen_penUp':
+            return 'pen up';
+        case 'pen_setPenColorToColor':
+            return `set pen color to ${i('COLOR')}`;
+        case 'pen_changePenColorParamBy':
+            return `change pen ${i('COLOR_PARAM')} by ${i('VALUE')}`;
+        case 'pen_setPenColorParamTo':
+            return `set pen ${i('COLOR_PARAM')} to ${i('VALUE')}`;
+        case 'pen_changePenSizeBy':
+            return `change pen size by ${i('SIZE')}`;
+        case 'pen_setPenSizeTo':
+            return `set pen size to ${i('SIZE')}`;
+        case 'pen_changePenShadeBy':
+            return `change pen shade by ${i('SHADE')}`;
+        case 'pen_setPenShadeToNumber':
+            return `set pen shade to ${i('SHADE')}`;
+        case 'pen_changePenHueBy':
+            return `change pen color by ${i('HUE')}`;
+        case 'pen_setPenHueTo':
+            return `set pen hue to ${i('HUE')}`;
+
+        default:
+            return `\x1b[33m${block.opcode} (${Object.keys(block.inputs).join(', ')})\x1b[0m`;
+
+    }
+}
+
+function blocksToScratchblocks(blocks, target) {
+    return blocks.map(b => blockToScratchblocks(b, target)).join('\n');
+}
+
+function scriptToScratchblocks(script, target) {
+    return blocksToScratchblocks(script.blocks, target);
+}
+
+Object.assign(module.exports, {
+    scriptToScratchblocks
+});