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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion LICENCE
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,23 @@ FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
THIS SOFTWARE.

---

Portions of this software are derived from Observable Notebook Kit, which is
released under the ISC license.

Copyright 2025 Observable, Inc.

Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
2 changes: 1 addition & 1 deletion app/docs/animations-authoring.recho.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ echo("~".repeat(x) + "(๑•̀ㅂ•́)و✧");
const loop = () => {
const x = 40 - (i++ % 40);
const turtle = "🐢".padStart(x);
clear(); // Clear the output of the current block.
echo.clear(); // Clear the output of the current block.
echo(turtle);
};

Expand Down
6 changes: 3 additions & 3 deletions app/docs/api-reference.recho.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const array1000 = echo(recho.inspect(new Array(1000).fill(0), {limit: Infinity})

/**
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* clear()
* echo.clear()
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
*
* Clear the output of the current block.
Expand All @@ -93,7 +93,7 @@ const array1000 = echo(recho.inspect(new Array(1000).fill(0), {limit: Infinity})

{
echo("Hello, World!");
setTimeout(() => clear(), 1000);
setTimeout(() => echo.clear(), 1000);
}

/**
Expand All @@ -113,7 +113,7 @@ const array1000 = echo(recho.inspect(new Array(1000).fill(0), {limit: Infinity})
const timer = setInterval(() => {
if (count-- <= 0) clearInterval(timer);
else {
clear();
echo.clear();
echo(count);
}
}, 1000);
Expand Down
6 changes: 3 additions & 3 deletions app/docs/inline-echoing.recho.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,13 +229,13 @@ const array1000 = echo(recho.inspect(new Array(1000).fill(0), {limit: 80}));
* Clearing Output
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
*
* You can call `clear` to clear the output of the current block. For example,
* click the 🔁 button to see the output being cleared after 1 second.
* You can call `echo.clear()` to clear the output of the current block. For
* example, click the ▶️ button to see the output being cleared after 1 second.
*/

{
echo("hello world");
setTimeout(() => clear(), 1000);
setTimeout(() => echo.clear(), 1000);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion app/docs/reactive-blocks.recho.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ const random = echo(Math.random());
const timer = setInterval(() => {
if (count-- <= 0) clearInterval(timer);
else {
clear();
echo.clear();
echo(count);
}
}, 1000);
Expand Down
4 changes: 3 additions & 1 deletion app/examples/corpora-art-isms.recho.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ const response = await fetch("https://raw.githubusercontent.com/dariusk/corpora/
//➜ description: "A list of modernist art isms.",
//➜ isms: [ "abstract expressionism", "academic", "action painting", "aestheticism", "art deco", "art nouveau", "avant-garde", "barbizon school", "baroque", "bauhaus", "biedermeier", "caravaggisti", "carolingian", "classicism", "cloisonnism", "cobra", "color field painting", "conceptual art", "cubism", "cubo-futurism", "dada", "dadaism", "de stijl", "deformalism", "der blaue reiter", "die brücke", "divisionism", "eclecticism", "ego-futurism", "existentialism", "expressionism", "fauvism", "fluxus", "formalism", "futurism", "geometric abstraction", "gothic art", "gründerzeit", "hard-edge painting", "historicism", "hudson river school", "humanism", "hyperrealism", "idealism", "illusionism", "immagine&poesia", "impressionism", "incoherents", "installation art", "international gothic", "intervention art", "jugendstil", "kinetic art", "land art", "les nabis", "lettrism", "lowbrow", "luminism", "lyrical abstraction", "mail art", "manierism", "mannerism", "maximalism", "merovingian", "metaphysical art ", "minimalism", "modern art", "modernism", "monumentalism", "multiculturalism", "naturalism", "neo-classicism", "neo-dada", "neo-expressionism", "neo-fauvism", "neo-geo", "neo-impressionism", "neo-minimalism", "neoclassicism", "neoism", "neue slowenische kunst", "new media art", "new objectivity", "nonconformism", "nouveau realisme", "op art", "orphism", "ottonian", "outsider art", "performance art", "perspectivism", "photorealism", "pointilism", "pop art", "post-conceptualism", "post-impressionism", "post-minimalism", "post-painterly abstraction", "post-structuralism", "postminimalism", "postmodern art", "postmodernism", "pre-raphaelites", "precisionism", "primitivism", "purism", "rayonism", "realism", "relational art", "remodernism", "renaissance", "rococo", "romanesque", "romanticism", "russian futurism", "russian symbolism", "scuola romana", "secularism", "situationist international", "social realism", "socialist realism", "sound art", "street art", "structuralism", "stuckism international", "stuckism", "superflat", "superstroke", "suprematism", "surrealism", "symbolism", "synchromism", "synthetism", "systems art", "tachism", "tachisme", "tonalism", "video art", "video game art", "vorticism", "young british artists" ]
//➜ }
const data = echo(await response.json(), {indent: 2, limit: Infinity});
const data = await response.json();

echo(recho.inspect(data, {indent: 2, limit: Infinity}));

//➜ "academic"
echo(data.isms[frame % data.isms.length]);
Expand Down
2 changes: 1 addition & 1 deletion app/examples/ml5-handpose.recho.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ handPose.detectStart(video, (hands) => {
for (let j = 0; j < width; ++j) output += buffer[i * width + j];
output += i === height - 1 ? "" : "\n";
}
clear();
echo.clear();
echo(output);
});

Expand Down
2 changes: 1 addition & 1 deletion editor/completion.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const builtinFunctions = [
type: "function",
detail: "()",
info: "Clears the output of the current block.",
apply: toApplyCompletion("clear()"),
apply: toApplyCompletion("echo.clear()"),
},
{
label: "invalidation",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@observablehq/runtime": "^6.0.0",
"@uiw/codemirror-theme-github": "^4.25.2",
"acorn": "^8.15.0",
"acorn-walk": "^8.3.4",
"cheerio": "^1.1.2",
"codemirror": "^6.0.2",
"d3-array": "^3.2.4",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

132 changes: 70 additions & 62 deletions runtime/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import {Runtime} from "@observablehq/runtime";
import {parse} from "acorn";
import {group, max} from "d3-array";
import {dispatch as d3Dispatch} from "d3-dispatch";
import * as stdlib from "./stdlib.js";
import * as stdlib from "./stdlib/index.js";
import {Inspector} from "./stdlib/inspect.js";
import {OUTPUT_MARK, ERROR_MARK} from "./constant.js";
import {Inspector} from "./inspect.js";
import {transpileRechoJavaScript} from "./transpile.js";

const OUTPUT_PREFIX = `//${OUTPUT_MARK}`;

Expand All @@ -23,18 +24,20 @@ function isError(value) {
return value instanceof Error;
}

function safeEval(code, inputs) {
function safeEval(code, inputs, __setEcho__) {
const create = (code) => {
const body = `const foo = ${code}; return foo(${inputs.join(",")})`;
const fn = new Function(...inputs, body);
return fn;
// Ensure the current echo function is bound for the executing cell.
const body = `__setEcho__(echo); const __foo__ = ${code}; const v = __foo__(${inputs.join(",")}); __setEcho__(null); return v;`;
const fn = new Function("__setEcho__", ...inputs, body);
return (...args) => fn(__setEcho__, ...args);
};
try {
return create(code);
} catch (error) {
// For non-function statements, such as for statement, we need to wrap the code in a function.
// For example, `for (let i = 0; i < 10; i++) { echo(i); }` will be wrapped in
// `() => { for (let i = 0; i < 10; i++) { echo(i); } }` then return the function.
// Wrap non-function statements in an arrow function for proper evaluation.
// Example:
// Input: `for (let i = 0; i < 10; i++) { echo(i); }`
// Output: `() => { for (let i = 0; i < 10; i++) { echo(i); } }`
const wrapped = `() => {${code}}`;
return create(wrapped);
}
Expand Down Expand Up @@ -88,15 +91,31 @@ export function createRuntime(initialCode) {
let prevCode = null;
let isRunning = false;

const runtime = new Runtime(BUILTINS);
// Echo context management system for proper output routing.
// Execution flow:
// 1. Before execution: __setEcho__(echo) stores the current cell's echo function
// 2. During execution: __getEcho__() retrieves the stored echo function
// 3. After execution: __setEcho__(null) restores the original echo context
// This ensures nested function calls output to the executing cell rather than
// the cell where the function was originally defined.
let __echo__ = null;
const __getEcho__ = () => __echo__;
const __setEcho__ = (echo) => (__echo__ = echo);

const runtime = new Runtime({
...BUILTINS,
__getEcho__: () => __getEcho__,
__setEcho__: () => __setEcho__,
});

const main = runtime.module();
const nodesByKey = new Map();
const dispatcher = d3Dispatch("changes");

const refresh = debounce((code) => {
const changes = removeChanges(code);

// Insert new outputs
// Process and format output for all execution nodes
const nodes = Array.from(nodesByKey.values()).flat(Infinity);
for (const node of nodes) {
const start = node.start;
Expand All @@ -109,8 +128,8 @@ export function createRuntime(initialCode) {
const n = line.length;
const formatted = line.map((v) => {
if (isError(v)) error = true;
// If there are multiple values, we don't want to quote the string values.
// such as echo("a =", 1) results in "a = 1" instead of "a = "1"".
// Disable string quoting for multi-value outputs to improve readability.
// Example: echo("a =", 1) produces "a = 1" instead of "a = "1""
const options = n === 1 ? {} : {quote: false};
const inspector = v instanceof Inspector ? v : new Inspector(v, options);
return inspector.format();
Expand Down Expand Up @@ -150,15 +169,12 @@ export function createRuntime(initialCode) {

function observer(state) {
return {
pending() {
// clear(state);
// if (state.doc) echo(state, "Pending…", {quote: false});
},
pending() {},
fulfilled() {
// Before blocks are fulfilled, their position might be changed or
// they might be removed. Run `run` to make sure the position of blocks are updated.
// The better way is to sync the position by applying all the changes, from both the
// output and the user edits. But it's not easy to implement.
// Re-execute code to synchronize block positions after state changes.
// Note: A more robust solution would involve applying changes from both
// output generation and user edits, but this approach provides adequate
// synchronization for the current implementation.
if (isRunning) rerun(code);
},
rejected(error) {
Expand All @@ -175,7 +191,7 @@ export function createRuntime(initialCode) {
return parse(code, {ecmaVersion: "latest", sourceType: "module"}).body;
} catch (error) {
console.error(error);
// Display the error at the start of the line where the error occurred.
// Calculate error position and display syntax error at the appropriate location.
const loc = error.loc;
const prevLine = code.split("\n").slice(0, loc.line - 1);
const offset = loc.line === 1 ? 0 : 1;
Expand All @@ -190,8 +206,9 @@ export function createRuntime(initialCode) {

function transpile(cell) {
try {
return transpileJavaScript(cell);
return transpileJavaScript(transpileRechoJavaScript(cell));
} catch (error) {
console.error(error);
return {body: cell, inputs: [], outputs: [], error};
}
}
Expand Down Expand Up @@ -269,7 +286,7 @@ export function createRuntime(initialCode) {
const oldNodes = preNodes.slice(n);
remove.push(...oldNodes);
}
// Pass states to new nodes.
// Transfer state from previous nodes to updated nodes.
for (let i = 0; i < Math.min(n, pn); i++) {
nodes[i].state = preNodes[i].state;
}
Expand All @@ -290,50 +307,41 @@ export function createRuntime(initialCode) {
for (const variable of variables) variable.delete();
}

// @ref https://github.com/observablehq/notebook-kit/blob/02914e034fd21a50ebcdca08df57ef5773864125/src/runtime/define.ts#L33
// Derived from Observable Notebook Kit's define.
// https://github.com/observablehq/notebook-kit/blob/02914e034fd21a50ebcdca08df57ef5773864125/src/runtime/define.ts#L33
for (const node of enter) {
const vid = uid();
const {inputs, body, outputs, error = null} = node.transpiled;
const state = {values: [], variables: [], error: null, syntaxError: error, doc: false};
node.state = state;
const v = main.variable(observer(state), {shadow: {}});
if (inputs.includes("echo")) {
let docVersion = -1;
const vd = new v.constructor(2, v._module);
vd.define(
inputs.filter((i) => i !== "echo" && i !== "clear"),
() => {
const version = v._version; // Capture version on input change.
return (value, ...args) => {
if (version < docVersion) throw new Error("stale echo");
else if (state.variables[0] !== v) throw new Error("stale echo");
else if (version > docVersion) clear(state);
docVersion = version;
echo(state, value, ...args);
return args.length ? [value, ...args] : value;
};
},
);
v._shadow.set("echo", vd);
}
if (inputs.includes("clear")) {
let clearVersion = -1;
const vc = new v.constructor(2, v._module);
vc.define(
inputs.filter((i) => i !== "clear" && i !== "echo"),
() => {
const version = v._version;
return () => {
if (version < clearVersion) throw new Error("stale clear");
else if (state.variables[0] !== v) throw new Error("stale clear");
clearVersion = version;
clear(state);
};
},
);
v._shadow.set("clear", vc);
}
state.variables.push(v.define(vid, inputs, safeEval(body, inputs)));

// Create echo variable for every node to support internal echo calls.
// This ensures echo functionality works even when not explicitly used in code,
// such as `add(1, 2)`, because the evaluated code may call echo internally.
let echoVersion = -1;
const vd = new v.constructor(2, v._module);
vd.define(
inputs.filter((i) => i !== "echo"),
() => {
const version = v._version; // Capture version on input change.
const __echo__ = (value, ...args) => {
if (version < echoVersion) throw new Error("stale echo");
else if (state.variables[0] !== v) throw new Error("stale echo");
else if (version > echoVersion) clear(state);
echoVersion = version;
echo(state, value, ...args);
return args.length ? [value, ...args] : value;
};
__echo__.clear = () => clear(state);
return __echo__;
},
);
v._shadow.set("echo", vd);
const newInputs = [...inputs, "echo"];
state.variables.push(v.define(vid, newInputs, safeEval(body, newInputs, __setEcho__)));

// Export cell-level variables for external access.
for (const o of outputs) {
state.variables.push(main.variable(true).define(o, [vid], (exports) => exports[o]));
}
Expand Down
Loading