treehouse/static/js/components/literate-programming.js

283 lines
9.7 KiB
JavaScript
Raw Normal View History

2024-02-16 22:01:19 +01:00
import { CodeJar } from "../vendor/codejar.js";
2024-02-17 18:01:17 +01:00
import { compileSyntax, highlight } from "./literate-programming/highlight.js";
2024-02-16 22:01:19 +01:00
let literatePrograms = new Map();
function getLiterateProgram(name) {
if (literatePrograms.get(name) == null) {
literatePrograms.set(name, {
2024-02-17 14:56:17 +01:00
frames: [],
2024-02-16 22:01:19 +01:00
onChanged: [],
2024-02-17 14:56:17 +01:00
outputCount: 0,
nextOutputIndex() {
2024-02-18 23:37:31 +01:00
return this.outputCount++;
},
2024-02-16 22:01:19 +01:00
});
}
return literatePrograms.get(name);
}
2024-02-20 23:30:36 +01:00
function getLiterateProgramWorkerCommands(name, count) {
2024-02-17 14:56:17 +01:00
let commands = [];
2024-02-16 22:01:19 +01:00
let literateProgram = getLiterateProgram(name);
2024-02-20 23:30:36 +01:00
for (let i = 0; i < count; ++i) {
let frame = literateProgram.frames[i];
2024-02-17 14:56:17 +01:00
if (frame.mode == "input") {
commands.push({ kind: "module", source: frame.textContent });
} else if (frame.mode == "output") {
2024-02-18 23:37:31 +01:00
commands.push({ kind: "output" });
2024-02-17 14:56:17 +01:00
}
2024-02-16 22:01:19 +01:00
}
2024-02-20 23:30:36 +01:00
2024-02-17 14:56:17 +01:00
return commands;
2024-02-16 22:01:19 +01:00
}
2024-02-17 14:56:17 +01:00
class InputMode {
2024-02-17 18:01:17 +01:00
static JAVASCRIPT = compileSyntax({
patterns: [
{ regex: /\/\/.*/, as: "comment" },
{ regex: /\/\*.*?\*\//ms, as: "comment" },
{ regex: /[A-Z_][a-zA-Z0-9_]*/, as: "keyword2" },
{ regex: /[a-zA-Z_][a-zA-Z0-9_]*(?=\()/, as: "function" },
{ regex: /[a-zA-Z_][a-zA-Z0-9_]*/, as: "identifier" },
{ regex: /0[bB][01_]+n?/, as: "literal" },
{ regex: /0[oO][0-7_]+n?/, as: "literal" },
{ regex: /0[xX][0-9a-fA-F_]+n?/, as: "literal" },
{ regex: /[0-9_]+n/, as: "literal" },
{ regex: /[0-9_]+(\.[0-9_]*([eE][-+]?[0-9_]+)?)?/, as: "literal" },
{ regex: /'(\\'|[^'])*'/, as: "string" },
{ regex: /"(\\"|[^"])*"/, as: "string" },
{ regex: /`(\\`|[^"])*`/, as: "string" },
// TODO: RegExp literals?
2024-02-18 23:37:31 +01:00
{ regex: /[+=/*^%<>!~|&\.?:-]+/, as: "operator" },
2024-02-17 18:01:17 +01:00
{ regex: /[,;]/, as: "punct" },
],
keywords: new Map([
["as", { into: "keyword1", onlyReplaces: "identifier" }],
["async", { into: "keyword1", onlyReplaces: "identifier" }],
["await", { into: "keyword1" }],
["break", { into: "keyword1" }],
["case", { into: "keyword1" }],
["catch", { into: "keyword1" }],
["class", { into: "keyword1" }],
["const", { into: "keyword1" }],
["continue", { into: "keyword1" }],
["debugger", { into: "keyword1" }],
["default", { into: "keyword1" }],
["delete", { into: "keyword1" }],
["do", { into: "keyword1" }],
["else", { into: "keyword1" }],
["export", { into: "keyword1" }],
["extends", { into: "keyword1" }],
["finally", { into: "keyword1" }],
["for", { into: "keyword1" }],
["from", { into: "keyword1", onlyReplaces: "identifier" }],
["function", { into: "keyword1" }],
["get", { into: "keyword1", onlyReplaces: "identifier" }],
["if", { into: "keyword1" }],
["import", { into: "keyword1" }],
["in", { into: "keyword1" }],
["instanceof", { into: "keyword1" }],
["let", { into: "keyword1" }],
["new", { into: "keyword1" }],
["of", { into: "keyword1", onlyReplaces: "identifier" }],
["return", { into: "keyword1" }],
["set", { into: "keyword1", onlyReplaces: "identifier" }],
["static", { into: "keyword1" }],
["switch", { into: "keyword1" }],
["throw", { into: "keyword1" }],
["try", { into: "keyword1" }],
["typeof", { into: "keyword1" }],
["var", { into: "keyword1" }],
["void", { into: "keyword1" }],
["while", { into: "keyword1" }],
["with", { into: "keyword1" }],
["yield", { into: "keyword1" }],
["super", { into: "keyword2" }],
["this", { into: "keyword2" }],
["false", { into: "literal" }],
["true", { into: "literal" }],
["undefined", { into: "literal" }],
["null", { into: "literal" }],
]),
})
2024-02-17 14:56:17 +01:00
constructor(frame) {
this.frame = frame;
2024-02-16 22:01:19 +01:00
2024-02-17 18:01:17 +01:00
InputMode.highlight(frame);
2024-02-17 14:56:17 +01:00
this.codeJar = CodeJar(frame, InputMode.highlight);
2024-02-16 22:01:19 +01:00
this.codeJar.onUpdate(() => {
2024-02-17 14:56:17 +01:00
for (let handler of frame.program.onChanged) {
handler(frame.programName);
2024-02-16 22:01:19 +01:00
}
})
2024-02-17 14:56:17 +01:00
frame.addEventListener("click", event => event.preventDefault());
2024-02-16 22:01:19 +01:00
}
2024-02-17 14:56:17 +01:00
static highlight(frame) {
2024-02-17 21:03:45 +01:00
highlight(frame, InputMode.JAVASCRIPT, (token, span) => {
if (token.kind == "keyword1" && token.string == "export") {
// This is something a bit non-obvious about the treehouse's literate programs
// so let's document it.
span.classList.add("export");
span.title = "This item is exported and visible in code blocks that follow";
}
});
2024-02-16 22:01:19 +01:00
}
}
2024-02-18 23:37:31 +01:00
function messageOutputArrayToString(output) {
return output
.map(x => {
if (typeof x === "object") return JSON.stringify(x);
else return x + "";
})
.join(" ");
}
2024-02-17 14:56:17 +01:00
class OutputMode {
constructor(frame) {
this.frame = frame;
2024-02-16 22:01:19 +01:00
2024-02-17 14:56:17 +01:00
this.outputIndex = this.frame.program.nextOutputIndex();
2024-02-16 22:01:19 +01:00
2024-02-18 23:37:31 +01:00
this.console = document.createElement("pre");
this.console.classList.add("console");
this.frame.appendChild(this.console);
this.clearConsoleOnNextOutput = false;
2024-02-16 22:01:19 +01:00
2024-02-18 23:37:31 +01:00
this.error = document.createElement("pre");
this.error.classList.add("error");
this.frame.appendChild(this.error);
2024-02-16 22:01:19 +01:00
2024-02-18 23:37:31 +01:00
this.iframe = document.createElement("iframe");
this.iframe.classList.add("hidden");
this.iframe.src = `${TREEHOUSE_SITE}/sandbox`;
this.frame.appendChild(this.iframe);
this.iframe.contentWindow.treehouseSandboxInternals = { outputIndex: this.outputIndex };
2024-02-16 22:01:19 +01:00
2024-02-18 23:37:31 +01:00
this.iframe.contentWindow.addEventListener("message", event => {
2024-02-16 22:01:19 +01:00
let message = event.data;
2024-02-18 23:37:31 +01:00
if (message.kind == "ready") {
this.evaluate();
} else if (message.kind == "resize" && message.outputIndex == this.outputIndex) {
this.resize();
2024-02-17 14:56:17 +01:00
} else if (message.kind == "output" && message.outputIndex == this.outputIndex) {
2024-02-18 23:37:31 +01:00
if (message.output.kind == "error") {
this.error.textContent = messageOutputArrayToString(message.output.message);
this.iframe.classList.add("hidden");
} else {
this.addOutput(message.output);
}
} else if (message.kind == "evalComplete") {
this.error.textContent = "";
this.flushConsoleClear();
2024-02-16 22:01:19 +01:00
}
});
2024-02-20 23:32:54 +01:00
if (this.frame.placeholderImage != null) {
2024-02-21 23:17:19 +01:00
this.frame.placeholderImage.classList.add("js");
2024-02-20 23:32:54 +01:00
this.frame.placeholderImage.classList.add("loading");
}
2024-02-20 23:30:36 +01:00
2024-02-18 23:37:31 +01:00
this.frame.program.onChanged.push(_ => this.evaluate());
}
evaluate() {
this.requestConsoleClear();
this.iframe.contentWindow.postMessage({
2024-02-16 22:01:19 +01:00
action: "eval",
2024-02-20 23:30:36 +01:00
input: getLiterateProgramWorkerCommands(this.frame.programName, this.frame.frameIndex + 1),
2024-02-16 22:01:19 +01:00
});
2024-02-17 14:56:17 +01:00
}
2024-02-16 22:01:19 +01:00
2024-02-18 23:37:31 +01:00
clearConsole() {
this.console.replaceChildren();
}
requestConsoleClear() {
this.clearConsoleOnNextOutput = true;
}
flushConsoleClear() {
if (this.clearConsoleOnNextOutput) {
this.clearConsole();
this.clearConsoleOnNextOutput = false;
2024-02-16 22:01:19 +01:00
}
2024-02-18 23:37:31 +01:00
}
2024-02-16 22:01:19 +01:00
2024-02-18 23:37:31 +01:00
addOutput(output) {
this.flushConsoleClear();
2024-02-16 22:01:19 +01:00
let line = document.createElement("code");
line.classList.add("output");
line.classList.add(output.kind);
2024-02-17 14:56:17 +01:00
// One day this will be more fancy. Today is not that day.
line.textContent = output.message
.map(x => {
if (typeof x === "object") return JSON.stringify(x);
else return x + "";
})
.join(" ");
2024-02-16 22:01:19 +01:00
2024-02-18 23:37:31 +01:00
this.console.appendChild(line);
2024-02-18 12:10:02 +01:00
}
2024-02-18 00:29:58 +01:00
2024-02-18 23:37:31 +01:00
resize() {
// iframe cannot be `display: none` to get its scrollWidth/scrollHeight.
this.iframe.classList.remove("hidden");
2024-02-18 00:29:58 +01:00
2024-02-20 23:30:36 +01:00
if (this.frame.placeholderImage != null) {
// Fade the iframe in after it becomes visible, and remove the image.
setTimeout(() => this.iframe.classList.add("loaded"), 0);
this.frame.removeChild(this.frame.placeholderImage);
} else {
// If there is no image, don't do the fade in.
this.iframe.classList.add("loaded");
}
2024-02-18 23:37:31 +01:00
let width = this.iframe.contentDocument.body.scrollWidth;
let height = this.iframe.contentDocument.body.scrollHeight;
2024-02-18 00:29:58 +01:00
2024-02-18 23:37:31 +01:00
if (width == 0 || height == 0) {
this.iframe.classList.add("hidden");
} else {
this.iframe.width = width;
this.iframe.height = height;
}
2024-02-18 00:29:58 +01:00
}
}
2024-02-17 14:56:17 +01:00
class LiterateProgram extends HTMLElement {
connectedCallback() {
this.programName = this.getAttribute("data-program");
2024-02-20 23:30:36 +01:00
this.frameIndex = this.program.frames.length;
2024-02-17 14:56:17 +01:00
this.program.frames.push(this);
2024-02-20 23:30:36 +01:00
this.placeholderImage = this.getElementsByClassName("placeholder")[0];
2024-02-17 14:56:17 +01:00
this.mode = this.getAttribute("data-mode");
if (this.mode == "input") {
this.modeImpl = new InputMode(this);
} else if (this.mode == "output") {
this.modeImpl = new OutputMode(this);
}
}
get program() {
return getLiterateProgram(this.programName);
2024-02-16 22:01:19 +01:00
}
}
2024-02-17 14:56:17 +01:00
customElements.define("th-literate-program", LiterateProgram);