diff --git a/wasm/notebook/package.json b/wasm/notebook/package.json
new file mode 100644
index 000000000..46c6aa340
--- /dev/null
+++ b/wasm/notebook/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "app",
+ "version": "1.0.0",
+ "description": "Bindings to the RustPython library for WebAssembly",
+ "main": "index.js",
+ "dependencies": {
+ "codemirror": "^5.42.0",
+ "local-echo": "^0.2.0",
+ "xterm": "^3.8.0"
+ },
+ "devDependencies": {
+ "@wasm-tool/wasm-pack-plugin": "^1.1.0",
+ "clean-webpack-plugin": "^3.0.0",
+ "css-loader": "^3.4.1",
+ "html-webpack-plugin": "^3.2.0",
+ "mini-css-extract-plugin": "^0.9.0",
+ "raw-loader": "^4.0.0",
+ "serve": "^11.0.2",
+ "webpack": "^4.16.3",
+ "webpack-cli": "^3.1.0",
+ "webpack-dev-server": "^3.1.5"
+ },
+ "scripts": {
+ "dev": "webpack-dev-server -d",
+ "build": "webpack",
+ "dist": "webpack --mode production",
+ "test": "webpack --mode production && cd ../tests && pipenv run pytest"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/RustPython/RustPython.git"
+ },
+ "author": "Ryan Liddle",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/RustPython/RustPython/issues"
+ },
+ "homepage": "https://github.com/RustPython/RustPython#readme"
+}
diff --git a/wasm/notebook/src/index.ejs b/wasm/notebook/src/index.ejs
new file mode 100644
index 000000000..a50890271
--- /dev/null
+++ b/wasm/notebook/src/index.ejs
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+ 🐍 RustPython Notebook
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Keyboard Shortcuts:
+
+
Run code: 'Ctrl-Enter' or 'Cmd-Enter'
+
Decrease Indent: 'Shift-Tab'
+
Toggle comment: 'Ctrl-/' or 'Cmd-/'
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wasm/notebook/src/index.js b/wasm/notebook/src/index.js
new file mode 100644
index 000000000..7931331b4
--- /dev/null
+++ b/wasm/notebook/src/index.js
@@ -0,0 +1,114 @@
+import './style.css';
+import CodeMirror from 'codemirror';
+import 'codemirror/mode/python/python';
+import 'codemirror/addon/comment/comment';
+import 'codemirror/lib/codemirror.css';
+
+let rp;
+
+// UI elements
+const consoleElement = document.getElementById('console');
+const errorElement = document.getElementById('error');
+const fetchbtnElement = document.getElementById("fetch-code");
+const urlConainerElement = document.getElementById('url-container');
+
+// A dependency graph that contains any wasm must be imported asynchronously.
+import('rustpython')
+ .then(rustpy => {
+ rp = rustpy;
+ // so people can play around with it
+ window.rp = rustpy;
+ onReady();
+ })
+ .catch(e => {
+ console.error('Error importing `rustpython`:', e);
+ document.getElementById('error').textContent = e;
+ });
+
+// Code Mirror code editor
+const editor = CodeMirror.fromTextArea(document.getElementById('code'), {
+ extraKeys: {
+ 'Ctrl-Enter': runCodeFromTextarea,
+ 'Cmd-Enter': runCodeFromTextarea,
+ 'Shift-Tab': 'indentLess',
+ 'Ctrl-/': 'toggleComment',
+ 'Cmd-/': 'toggleComment',
+ Tab: editor => {
+ var spaces = Array(editor.getOption('indentUnit') + 1).join(' ');
+ editor.replaceSelection(spaces);
+ }
+ },
+ lineNumbers: true,
+ mode: 'text/x-python',
+ indentUnit: 4,
+ autofocus: true
+});
+
+// Runs the code the the code editor
+function runCodeFromTextarea() {
+ // Clean the console and errors
+ consoleElement.innerHTML = '';
+ errorElement.textContent = '';
+
+ const code = editor.getValue();
+ try {
+ rp.pyExec(code, {
+ stdout: output => {
+ consoleElement.innerHTML += output;
+ }
+ });
+ } catch (err) {
+ if (err instanceof WebAssembly.RuntimeError) {
+ err = window.__RUSTPYTHON_ERROR || err;
+ }
+ errorElement.textContent = err;
+ console.error(err);
+ }
+}
+
+function onReady() {
+ // snippets.addEventListener('change', updateSnippet);
+ document
+ .getElementById('run-btn')
+ .addEventListener('click', runCodeFromTextarea);
+
+ // so that the test knows that we're ready
+ const readyElement = document.createElement('div');
+ readyElement.id = 'rp_loaded';
+ document.head.appendChild(readyElement);
+}
+
+// when clicking the import code button
+// show a UI with a url input + fetch button
+// only accepts api.github.com urls (for now)
+// add another function to parse a regular url
+fetchbtnElement.addEventListener("click", function () {
+ // https://developer.github.com/v3/repos/contents/#get-repository-content
+ // Format:
+ // https://api.github.com/repos/username/reponame/contents/filename.py
+ let url = document
+ .getElementById('snippet-url')
+ .value;
+ // minimal js fetch code
+ // needs better error handling
+ fetch(url)
+ .then(res => res.json())
+ .then(data => {
+ // The Python code is in data.content
+ // it is encoded with Base64. Use atob to decode it.
+ //https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/atob
+ var decodedData = atob(data.content);
+ // set the value of the code editor
+ editor.setValue(decodedData);
+ urlConainerElement.classList.add("d-none");
+ }).catch(err => {
+ document
+ .getElementById("errors")
+ .innerHTML = "Couldn't fetch code. Make sure the link is public."
+ });
+
+});
+
+document.getElementById("snippet-btn").addEventListener("click", function () {
+ urlConainerElement.classList.remove("d-none");
+});
\ No newline at end of file
diff --git a/wasm/notebook/src/style.css b/wasm/notebook/src/style.css
new file mode 100644
index 000000000..c0d776a8a
--- /dev/null
+++ b/wasm/notebook/src/style.css
@@ -0,0 +1,110 @@
+.header {
+ font-family: 'Sen', sans-serif;
+ ;
+}
+
+.d-flex {
+ display: flex;
+}
+
+.d-none {
+ display: none;
+}
+
+.mr-1 {
+ margin-right: 1rem;
+}
+
+.mt-1 {
+ margin-top: 1rem;
+}
+
+.text-small {
+ font-size: 0.75rem;
+}
+
+mark {
+ background-color: #000;
+ color: #fff;
+ font-size: 0.75rem;
+}
+
+.nav-bar {
+ border-bottom: 2px solid #F74C00;
+ display: flex;
+ justify-content: space-between;
+ position: sticky;
+ top: 0px;
+ background-color: #fff;
+ height: 20px;
+ z-index: 99;
+}
+
+.split-view {
+ display: flex;
+ justify-content: space-between;
+}
+
+.split-view div {
+ flex-basis: 50%;
+}
+
+.full-height {
+ height: calc(90vh - 60px);
+}
+
+#console {
+ padding: 1rem;
+}
+
+.CodeMirror {
+ height: 90% !important;
+ border-right: 1px solid gray;
+}
+
+#run-btn,
+.secondary-btn {
+ border: 0px;
+ color: white;
+ cursor: pointer;
+}
+
+#run-btn {
+ background-color: #F74C00;
+}
+
+input[type="url"] {
+ border: 1px solid black;
+ border-radius: 0px;
+ width: 55%;
+ margin: 5px 0px 5px 0px;
+ font-size: 0.7rem;
+ padding: 4px;
+ font-family: monospace;
+}
+
+.url-container {
+ padding: 10px;
+ background-color: white;
+ font-size: smaller;
+}
+
+.secondary-btn {
+ background-color: #000;
+}
+
+.secondary-btn.active,
+.secondary-btn:hover {
+ background-color: #F74C00;
+}
+
+#error {
+ color: tomato;
+ margin-top: 10px;
+ font-family: monospace;
+ white-space: pre;
+}
+
+#code-wrapper {
+ position: relative;
+}
\ No newline at end of file
diff --git a/wasm/notebook/webpack.config.js b/wasm/notebook/webpack.config.js
new file mode 100644
index 000000000..67e2428f6
--- /dev/null
+++ b/wasm/notebook/webpack.config.js
@@ -0,0 +1,72 @@
+const HtmlWebpackPlugin = require('html-webpack-plugin');
+const MiniCssExtractPlugin = require('mini-css-extract-plugin');
+const WasmPackPlugin = require('@wasm-tool/wasm-pack-plugin');
+const { CleanWebpackPlugin } = require('clean-webpack-plugin');
+
+const path = require('path');
+const fs = require('fs');
+
+const interval = setInterval(() => console.log('keepalive'), 1000 * 60 * 5);
+
+module.exports = (env = {}) => {
+ const config = {
+ entry: './src/index.js',
+ output: {
+ path: path.join(__dirname, 'dist'),
+ filename: 'index.js'
+ },
+ mode: 'development',
+ resolve: {
+ alias: {
+ rustpython: path.resolve(
+ __dirname,
+ env.rustpythonPkg || '../lib/pkg'
+ )
+ }
+ },
+ module: {
+ rules: [
+ {
+ test: /\.css$/,
+ use: [MiniCssExtractPlugin.loader, 'css-loader']
+ }
+ ]
+ },
+ plugins: [
+ new CleanWebpackPlugin(),
+ new HtmlWebpackPlugin({
+ filename: 'index.html',
+ template: 'src/index.ejs',
+ templateParameters: {
+ snippets: fs
+ .readdirSync(path.join(__dirname, 'snippets'))
+ .map(filename =>
+ path.basename(filename, path.extname(filename))
+ ),
+ defaultSnippetName: 'fibonacci',
+ defaultSnippet: fs.readFileSync(
+ path.join(__dirname, 'snippets/fibonacci.py')
+ )
+ }
+ }),
+ new MiniCssExtractPlugin({
+ filename: 'styles.css'
+ }),
+ {
+ apply(compiler) {
+ compiler.hooks.done.tap('clearInterval', () => {
+ clearInterval(interval);
+ });
+ }
+ }
+ ]
+ };
+ if (!env.noWasmPack) {
+ config.plugins.push(
+ new WasmPackPlugin({
+ crateDirectory: path.join(__dirname, '../lib')
+ })
+ );
+ }
+ return config;
+};