Skip to content

Commit dd41295

Browse files
authored
Merge pull request RustPython#2230 from mainsail-org/mir/notebook-with-md
rustpython notebook, add markdown and math support
2 parents 31780be + 737b1c0 commit dd41295

File tree

7 files changed

+320
-49
lines changed

7 files changed

+320
-49
lines changed

wasm/notebook/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
"main": "index.js",
66
"dependencies": {
77
"codemirror": "^5.42.0",
8+
"file-loader": "^6.1.0",
9+
"katex": "^0.12.0",
810
"local-echo": "^0.2.0",
11+
"marked": "^1.1.1",
912
"xterm": "^3.8.0"
1013
},
1114
"devDependencies": {

wasm/notebook/src/index.ejs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
<head>
55
<meta charset="utf-8" />
66
<meta name="viewport" content="width=device-width, initial-scale=1">
7-
<link defer async href="https://fonts.googleapis.com/css?family=Fira+Sans|Sen:800&display=swap" rel="stylesheet">
8-
<title>🐍 RustPython Notebook</title>
7+
<link defer async href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans&family=Sen:wght@800&display=swap" rel="stylesheet">
98
</head>
109

1110
<body>
@@ -32,7 +31,7 @@
3231
<!-- split view -->
3332
<div class="split-view full-height">
3433
<textarea id="code"></textarea>
35-
<div id="console"></div>
34+
<div id="rp-notebook">loading python in your browser...</div>
3635
</div>
3736

3837
<!-- errors and keyboard shortcuts -->
@@ -41,7 +40,7 @@
4140
<div class="header">Error(s):</div>
4241
<div id="error"></div>
4342
</div>
44-
<div class="mt-1">
43+
<div class="mt-1 gray">
4544
<div class="header">Keyboard Shortcuts:</div>
4645
<div>
4746
<div>Run code: 'Ctrl-Enter' or 'Cmd-Enter'</div>

wasm/notebook/src/index.js

Lines changed: 111 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
11
import './style.css';
2+
// Code Mirror (https://codemirror.net/)
3+
// https://github.com/codemirror/codemirror
24
import CodeMirror from 'codemirror';
35
import 'codemirror/mode/python/python';
46
import 'codemirror/addon/comment/comment';
57
import 'codemirror/lib/codemirror.css';
68

9+
// MarkedJs (https://marked.js.org/)
10+
// Renders Markdown
11+
// https://github.com/markedjs/marked
12+
import marked from 'marked'
13+
14+
// KaTex (https://katex.org/)
15+
// Renders Math
16+
// https://github.com/KaTeX/KaTeX
17+
import katex from "katex";
18+
import "katex/dist/katex.min.css";
19+
20+
// Parses the code and splits it to chunks
21+
// uses %% keyword for separators
22+
// copied from iodide project
23+
// https://github.com/iodide-project/iodide/blob/master/src/editor/iomd-tools/iomd-parser.js
24+
import { iomdParser } from "./parser";
25+
726
let rp;
827

9-
// UI elements
10-
const consoleElement = document.getElementById('console');
11-
const errorElement = document.getElementById('error');
12-
const fetchbtnElement = document.getElementById("fetch-code");
13-
const urlConainerElement = document.getElementById('url-container');
28+
const notebook = document.getElementById('rp-notebook');
29+
const error = document.getElementById('error');
1430

1531
// A dependency graph that contains any wasm must be imported asynchronously.
1632
import('rustpython')
@@ -25,11 +41,11 @@ import('rustpython')
2541
document.getElementById('error').textContent = e;
2642
});
2743

28-
// Code Mirror code editor
44+
// Code Editor
2945
const editor = CodeMirror.fromTextArea(document.getElementById('code'), {
3046
extraKeys: {
31-
'Ctrl-Enter': runCodeFromTextarea,
32-
'Cmd-Enter': runCodeFromTextarea,
47+
'Ctrl-Enter': parseCodeFromEditor,
48+
'Cmd-Enter': parseCodeFromEditor,
3349
'Shift-Tab': 'indentLess',
3450
'Ctrl-/': 'toggleComment',
3551
'Cmd-/': 'toggleComment',
@@ -41,61 +57,127 @@ const editor = CodeMirror.fromTextArea(document.getElementById('code'), {
4157
lineNumbers: true,
4258
mode: 'text/x-python',
4359
indentUnit: 4,
44-
autofocus: true
60+
autofocus: true,
61+
lineWrapping: true
4562
});
4663

47-
// Runs the code the the code editor
48-
function runCodeFromTextarea() {
64+
// Parses what is the code editor
65+
// either runs python or renders math or markdown
66+
function parseCodeFromEditor() {
4967
// Clean the console and errors
50-
consoleElement.innerHTML = '';
51-
errorElement.textContent = '';
68+
notebook.innerHTML = '';
69+
error.textContent = '';
70+
71+
// gets the code from code editor
72+
let code = editor.getValue();
73+
74+
/*
75+
Split code into chunks.
76+
Uses %%keyword or %% keyword as separator
77+
Implemented %%py %%md %%math for python, markdown and math.
78+
Returned object has:
79+
- chunkContent, chunkType, chunkId,
80+
- evalFlags, startLine, endLine
81+
*/
82+
let parsed_code = iomdParser(code);
83+
84+
parsed_code.forEach(chunk => {
85+
// For each type of chunk, do somthing
86+
// so far have py for python, md for markdown and math for math ;p
87+
let content = chunk.chunkContent;
88+
switch (chunk.chunkType) {
89+
case 'py':
90+
runPython(content);
91+
break;
92+
case 'md':
93+
notebook.innerHTML += renderMarkdown(content);
94+
break;
95+
case 'math':
96+
notebook.innerHTML += renderMath(content, true);
97+
break;
98+
case 'math-inline':
99+
notebook.innerHTML += renderMath(content, false )
100+
break;
101+
default:
102+
// by default assume this is python code
103+
// so users don't have to type py manually
104+
runPython(code);
105+
}
106+
});
107+
108+
}
52109

53-
const code = editor.getValue();
110+
// Run Python code
111+
function runPython(code) {
54112
try {
55113
rp.pyExec(code, {
56114
stdout: output => {
57-
consoleElement.innerHTML += output;
115+
notebook.innerHTML += output;
58116
}
59117
});
60118
} catch (err) {
61119
if (err instanceof WebAssembly.RuntimeError) {
62120
err = window.__RUSTPYTHON_ERROR || err;
63121
}
64-
errorElement.textContent = err;
65-
console.error(err);
122+
error.textContent = err;
66123
}
67124
}
68125

126+
// Render Markdown with imported marked compiler
127+
function renderMarkdown(md) {
128+
// TODO: add error handling and output sanitization
129+
let settings = {
130+
headerIds: true,
131+
breaks: true
132+
}
133+
134+
return marked(md , settings );
135+
}
136+
137+
// Render Math with Katex
138+
function renderMath(math , display_mode) {
139+
// TODO: definetly add error handling.
140+
return katex.renderToString(math, {
141+
displayMode: display_mode,
142+
"macros": { "\\f": "#1f(#2)" }
143+
});
144+
}
145+
69146
function onReady() {
70-
document
71-
.getElementById('run-btn')
72-
.addEventListener('click', runCodeFromTextarea);
73147

74-
// so that the test knows that we're ready
148+
/* By default the notebook has the keyword "loading"
149+
once python and doc is ready:
150+
create an empty div and set the id to 'rp_loaded'
151+
so that the test knows that we're ready */
75152
const readyElement = document.createElement('div');
76153
readyElement.id = 'rp_loaded';
77154
document.head.appendChild(readyElement);
155+
// set the notebook to empty
156+
notebook.innerHTML = "";
78157
}
79158

159+
// on click, parse the code
160+
document.getElementById('run-btn').addEventListener('click', parseCodeFromEditor);
161+
80162
// import button
81163
// show a url input + fetch button
82164
// takes a url where there is raw code
83-
fetchbtnElement.addEventListener("click", function () {
165+
document.getElementById("fetch-code").addEventListener("click", function () {
84166
let url = document
85167
.getElementById('snippet-url')
86168
.value;
87169
// minimal js fetch code
88-
// needs better error handling
170+
// TODO: better error handling
89171
fetch(url)
90-
.then( response => {
172+
.then(response => {
91173
if (!response.ok) { throw response }
92-
return response.text()
174+
return response.text()
93175
})
94176
.then(text => {
95177
// set the value of the code editor
96178
editor.setValue(text);
97179
// hide the ui
98-
urlConainerElement.classList.add("d-none");
180+
document.getElementById('url-container').classList.add("d-none");
99181
}).catch(err => {
100182
// show the error as is for troubleshooting.
101183
document
@@ -105,6 +187,8 @@ fetchbtnElement.addEventListener("click", function () {
105187

106188
});
107189

190+
// UI for the fetch button
191+
// after clicking fetch, hide the UI
108192
document.getElementById("snippet-btn").addEventListener("click", function () {
109-
urlConainerElement.classList.remove("d-none");
193+
document.getElementById('url-container').classList.remove("d-none");
110194
});

wasm/notebook/src/parser.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// The parser is from Mozilla's iodide project:
2+
// https://github.com/iodide-project/iodide/blob/master/src/editor/iomd-tools/iomd-parser.js
3+
4+
function hashCode(str) {
5+
// this is an implementation of java's hashcode method
6+
// https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
7+
let hash = 0;
8+
let chr;
9+
if (str.length !== 0) {
10+
for (let i = 0; i < str.length; i++) {
11+
chr = str.charCodeAt(i);
12+
hash = (hash << 5) - hash + chr; // eslint-disable-line
13+
hash |= 0; // eslint-disable-line
14+
}
15+
}
16+
return hash.toString();
17+
}
18+
19+
export function iomdParser(fullIomd) {
20+
const iomdLines = fullIomd.split("\n");
21+
const chunks = [];
22+
let currentChunkLines = [];
23+
let currentEvalType = "";
24+
let evalFlags = [];
25+
let currentChunkStartLine = 1;
26+
27+
const newChunkId = str => {
28+
const hash = hashCode(str);
29+
let hashNum = "0";
30+
for (const chunk of chunks) {
31+
const [prevHash, prevHashNum] = chunk.chunkId.split("_");
32+
if (hash === prevHash) {
33+
hashNum = (parseInt(prevHashNum, 10) + 1).toString();
34+
}
35+
}
36+
return `${hash}_${hashNum}`;
37+
};
38+
39+
const pushChunk = endLine => {
40+
const chunkContent = currentChunkLines.join("\n");
41+
chunks.push({
42+
chunkContent,
43+
chunkType: currentEvalType,
44+
chunkId: newChunkId(chunkContent),
45+
evalFlags,
46+
startLine: currentChunkStartLine,
47+
endLine
48+
});
49+
};
50+
51+
for (const [i, line] of iomdLines.entries()) {
52+
const lineNum = i + 1; // uses 1-based indexing
53+
if (line.slice(0, 2) === "%%") {
54+
// if line start with '%%', a new chunk has started
55+
// push the current chunk (unless it's on line 1), then reset
56+
if (lineNum !== 1) {
57+
// DON'T push a chunk if we're only on line 1
58+
pushChunk(lineNum - 1);
59+
}
60+
// reset the currentChunk state
61+
currentChunkStartLine = lineNum;
62+
currentChunkLines = [];
63+
evalFlags = [];
64+
// find the first char on this line that isn't '%'
65+
let lineColNum = 0;
66+
while (line[lineColNum] === "%") {
67+
lineColNum += 1;
68+
}
69+
const chunkFlags = line
70+
.slice(lineColNum)
71+
.split(/[ \t]+/)
72+
.filter(s => s !== "");
73+
if (chunkFlags.length > 0) {
74+
// if there is a captured group, update the eval type
75+
[currentEvalType, ...evalFlags] = chunkFlags;
76+
}
77+
} else {
78+
// if there is no match, then the line is not a
79+
// chunk delimiter line, so add the line to the currentChunk
80+
currentChunkLines.push(line);
81+
}
82+
}
83+
// this is what's left over in the final chunk
84+
pushChunk(iomdLines.length);
85+
return chunks;
86+
}
87+

0 commit comments

Comments
 (0)