diff --git a/LICENSE.md b/LICENSE.md index 82e110c..ecab0f3 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -129,10 +129,36 @@ Both came with the following license: > OTHER DEALINGS IN THE FONT SOFTWARE. -Javascript MD5 Library -====================== +Go Fonts +======= -Obtained from , which says: +The Go fonts were obtained from +https://go.googlesource.com/image -> The JavaScript MD5 script is released under the -> [MIT license](http://www.opensource.org/licenses/MIT). +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/example-puzzles/example/7/boop.txt b/example-puzzles/example/7/boop.txt new file mode 100644 index 0000000..8eb91f8 --- /dev/null +++ b/example-puzzles/example/7/boop.txt @@ -0,0 +1 @@ +Boop! diff --git a/example-puzzles/example/7/puzzle.md b/example-puzzles/example/7/puzzle.md new file mode 100644 index 0000000..c083655 --- /dev/null +++ b/example-puzzles/example/7/puzzle.md @@ -0,0 +1,17 @@ +--- +authors: + - neale +answers: + - 146 +attachments: + - boop.txt +--- + +Some puzzles can have embedded code. + +Your theme may turn this into a full in-browser development environment! + +```python +print(open("boop.txt").read()) +setanswer(0x58 + 58) +``` diff --git a/theme/basic.css b/theme/basic.css index 1ca2a37..1d5a4ce 100644 --- a/theme/basic.css +++ b/theme/basic.css @@ -1,12 +1,47 @@ /* Color palette: http://paletton.com/#uid=33x0u0klrl-4ON9dhtKtAdqMQ4T */ +:root { + --bg: #010e19; + --bg-main: #000d; + --heading: #cb2408cc; + --bg-heading1: #cb240844; + --fg-link: #b9cbd8; + --bg-input: #ccc4; + --bg-input-hover: #8884; + --bg-notification: #ac8f3944; + --bg-error: #f00; + --fg-error: white; + --bg-category: #ccc4; + --bg-input-invalid: #800; + --fg-input-invalid: white; + --bg-mothball: #ccc; + --bg-debug: #cccc; + --fg-debug: black; + --bg-toast: #333; + --fg-toast: #eee; + --box-toast: #0b0; +} + +@media (prefers-color-scheme: light) { + /* We uses the alpha channel to apply hue tinting to elements, to get a + * similar effect in light or dark mode. That means there aren't a whole lot of + * things to change between light and dark mode. + */ + :root { + --bg: #b9cbd8; + --fg: black; + --bg-main: #fffd; + --fg-link: #092b45; + } +} + body { font-family: sans-serif; - background: #010e19 url("bg.png") center fixed; + background: var(--bg) url("bg.png") center fixed; background-size: cover; background-blend-mode: soft-light; - background-color: #010e19; - color: #edd488; + background-color: var(--bg); + color: var(--fg); } canvas.wallpaper { position: fixed; @@ -24,20 +59,20 @@ main { margin: 1em auto; padding: 1px 3px; border-radius: 5px; - background: #000d; + background: var(--bg-main); } h1, h2, h3, h4, h5, h6 { - color: #cb2408cc; + color: var(--heading); } h1 { - background: #cb240844; + background: var(--bg-heading1); padding: 3px; } p { margin: 1em 0em; } a:any-link { - color: #b9cbd8; + color: var(--fg-link); } form, pre { margin: 1em; @@ -49,11 +84,11 @@ input, select { max-width: 30em; } input { - background-color: #ccc4; + background-color: var(--bg-input); color: inherit; } input:hover { - background-color: #8884; + background-color: var(--bg-input-hover); } input:active { background-color: inherit; @@ -63,11 +98,11 @@ input:active { border-radius: 8px; } .notification { - background: #ac8f3944; + background: var(--bg-notification); } .error { - background: red; - color: white; + background: var(--bg-error); + color: var(--fg-error); } .hidden { display: none; @@ -76,7 +111,7 @@ input:active { /** Puzzles list */ .category { margin: 5px 0; - background: #ccc4; + background: var(--bg-category); } .category h2 { margin: 0 0.2em; @@ -101,7 +136,7 @@ nav li, .category li { float: right; text-decoration: none; border-radius: 5px; - background: #ccc; + background: var(--bg-mothball); padding: 4px 8px; margin: 5px; } @@ -115,8 +150,8 @@ nav li, .category li { max-width: 100%; } input:invalid { - background-color: #800; - color: white; + background-color: var(--bg-input-invalid); + color: var(--fg-input-invalid); } .answer_ok { cursor: help; @@ -128,8 +163,8 @@ input:invalid { padding: 1em; border-radius: 10px; margin: 2em auto; - background: #cccc; - color: black; + background: var(--bg-debug); + color: var(--fg-debug); } .debug dt { font-weight: bold; @@ -173,28 +208,11 @@ li[draggable] { padding: 0.2em 2em; animation: fadeIn ease 1s; margin: 2px auto; - background: #333; - color: #eee; - box-shadow: 0px 0px 8px 0px #0b0; + background: var(--bg-toast); + color: var(--fg-toast); + box-shadow: 0px 0px 8px 0px var(--box-toast); } @keyframes fadeIn { 0% { opacity: 0; } 100% { opacity: 1; } } - -@media (prefers-color-scheme: light) { - /* We uses the alpha channel to apply hue tinting to elements, to get a - * similar effect in light or dark mode. That means there aren't a whole lot of - * things to change between light and dark mode. - */ - body { - background-color: #b9cbd8; - color: black; - } - main { - background-color: #fffd; - } - a:any-link { - color: #092b45; - } -} \ No newline at end of file diff --git a/theme/config.json b/theme/config.json index 8d4e3c4..4605a27 100644 --- a/theme/config.json +++ b/theme/config.json @@ -1,6 +1,10 @@ { "TrackSolved": true, "Titles": false, + "Puzzle": { + "SyntaxHighlighting": true, + "": 0 + }, "Scoreboard": { "DisplayServerURLWhenEnabled": true, "ShowCategoryLeaders": true, @@ -8,7 +12,7 @@ "ReplayFPS": 6, "ReplayDurationMS": 2000, "NoScoresHtml": "

~ no scores ~

", - "": "" + "": 0 }, "Messages": "", "": "this is here so you don't have to remember to take the comma off the last item" diff --git a/theme/fonts/Go-Mono.ttf b/theme/fonts/Go-Mono.ttf new file mode 100644 index 0000000..7cf65e0 --- /dev/null +++ b/theme/fonts/Go-Mono.ttf @@ -0,0 +1 @@ +font/gofont/ttfs/Go-Mono.ttf - image - Git at Google
blob: 853d473bebdf6d27eea9ddcc1f1d310483704229 [file] [log] [blame]
173248-byte binary file
\ No newline at end of file diff --git a/theme/fonts/Go-Regular.ttf b/theme/fonts/Go-Regular.ttf new file mode 100644 index 0000000..1b6ba86 Binary files /dev/null and b/theme/fonts/Go-Regular.ttf differ diff --git a/theme/puzzle.css b/theme/puzzle.css new file mode 100644 index 0000000..f1a4a17 --- /dev/null +++ b/theme/puzzle.css @@ -0,0 +1,113 @@ +@font-face { + font-family: "Go"; + src: url("fonts/Go-Regular.ttf"); +} + +@font-face { + font-family: "Go-Mono"; + src: url("fonts/Go-Mono.ttf"); +} + +/** Workspace + * + * Tools for this puzzle: shows up in content. + * Right now this is just a Python interpreter. + */ + .workspace { + background-color: rgba(255, 240, 220, 0.3); + white-space: normal; + padding: 0; +} + +.output { + background-color: #555; + color: #fff; + margin: 0.5em 0; + padding: 0.5em; + flex-grow: 1; + flex-shrink: 1; + min-height: 3em; + max-height: 24em; + overflow: scroll; +} + +.output, .editor { + font-family: Go, monospace; +} + +.fixed .output, .fixed .editor { + font-family: Go-Mono, monospace; +} + +.controls { + display: flex; + align-items: center; + gap: 0.5em; +} +.controls .status { + font-size: 9pt; + flex-grow: 2; +} + +.stdout, +.stderr, +.stdinfo, +.traceback { + white-space: pre-wrap; +} +.stderr { + color: #f88; +} +.traceback { + background-color: #222; +} +.stdinfo { + font-style: italic; +} + +.editor { + border: 1px solid black; + overflow-y: scroll; + max-height: 24em; + display: flex; + flex-grow: 1; + flex-shrink: 1; + font-size: 12pt; + line-height: 1.2rem; +} +.editor .linenos { + background-color: #eee; + white-space: pre; + min-width: 2em; + padding: 0 4px; + text-align: right; + height: fit-content; +} +.editor .text { + background-color: #fff; + flex-grow: 1; + flex-shrink: 1; + white-space: nowrap; + overflow-x: scroll; + overflow-y: hidden; + padding: 0 4px; + height: fit-content; + min-height: 8em; +} + +/* Some things that crop up in puzzles */ +[draggable] { + padding-left: 1em; + background-image: url(../images/drag-handle.svg); + background-position: 0 center; + background-size: 1em 1em; + background-repeat: no-repeat; + background-color: rgba(255, 255, 255, 0.4); + margin: 2px 0px; + cursor: move; +} + +[draggable].over, +[draggable].moving { + background-color: rgba(127, 127, 127, 0.5); +} diff --git a/theme/puzzle.html b/theme/puzzle.html index 27879d1..908f335 100644 --- a/theme/puzzle.html +++ b/theme/puzzle.html @@ -6,6 +6,7 @@ + @@ -30,5 +31,23 @@
+ diff --git a/theme/puzzle.mjs b/theme/puzzle.mjs index 62fc689..f29a809 100644 --- a/theme/puzzle.mjs +++ b/theme/puzzle.mjs @@ -3,6 +3,7 @@ */ import * as moth from "./moth.mjs" import * as common from "./common.mjs" +import * as workspace from "./workspace/workspace.mjs" const server = new moth.Server(".") @@ -129,7 +130,7 @@ function writeObject(e, obj) { * @param {string} category * @param {number} points */ -async function loadPuzzle(category, points) { +async function loadPuzzle(category, points) { console.groupCollapsed("Loading puzzle:", category, points) let contentBase = new URL(`content/${category}/${points}/`, common.BaseURL) @@ -179,15 +180,41 @@ async function loadPuzzle(category, points) { } console.info("Listing attached files...") + let attachmentUrls = [] for (let fn of (puzzle.Attachments || [])) { let li = document.createElement("li") let a = document.createElement("a") - a.href = new URL(fn, contentBase) + let url = new URL(fn, contentBase) + attachmentUrls.push(url) + a.href = url a.innerText = fn li.appendChild(a) document.getElementById("files").appendChild(li) } + let codeBlocks = document.querySelectorAll("code[class^=language-]") + for (let i = 0; i < codeBlocks.length; i++) { + console.info(`Loading workspace ${i}...`) + let codeBlock = codeBlocks[i] + let language = "unknown" + let sourceCode = codeBlock.textContent + for (let c of codeBlock.classList) { + let parts = c.split("-") + if ((parts.length == 2) && parts[0].startsWith("lang")) { + language = parts[1] + } + } + + let id = category + "#" + points + "#" + i + let element = document.createElement("div") + let template = document.querySelector("template#workspace") + element.classList.add("workspace") + element.appendChild(template.content.cloneNode(true)) + element.workspace = new workspace.Workspace(element, id, sourceCode, language, attachmentUrls) + + // Now swap it in for the pre + codeBlock.parentElement.replaceWith(element) + } console.info("Filling debug information...") for (let e of document.querySelectorAll(".debug")) { @@ -199,7 +226,7 @@ async function loadPuzzle(category, points) { } window.app.puzzle = puzzle - console.info("window.app.puzzle =", window.app.puzzle) + console.info("window.app.puzzle:", window.app.puzzle) console.groupEnd() @@ -220,6 +247,9 @@ async function init() { // There isn't a more graceful way to "unload" scripts attached to the current puzzle window.addEventListener("hashchange", () => location.reload()) + // Workspaces may trigger a "this is the answer" event + document.addEventListener("setAnswer", e => SetAnswer(e.detail.value)) + // Make all links absolute, because we're going to be changing the base URL for (let e of document.querySelectorAll("[href]")) { e.href = new URL(e.href, common.BaseURL) diff --git a/theme/workspace/python.mjs b/theme/workspace/python.mjs new file mode 100644 index 0000000..50936d2 --- /dev/null +++ b/theme/workspace/python.mjs @@ -0,0 +1,78 @@ +import * as pyodide from "https://cdn.jsdelivr.net/npm/pyodide@0.25.1/pyodide.mjs" // v0.16.1 known good + +const HOME = "/home/web_user" + +async function createInstance() { + let instance = await pyodide.loadPyodide() + instance.runPython("import sys") + self.postMessage({type: "loaded"}) + return instance +} +const initialized = createInstance() + +class Buffer { + constructor() { + this.buf = [] + } + + write(s) { + this.buf.push(s) + } + + value() { + return this.buf.join("") + } +} + +async function handleMessage(event) { + let data = event.data + + let instance = await initialized + let fs = instance._module.FS + + let ret = { + result: null, + answer: null, + stdout: null, + stderr: null, + traceback: null, + } + + switch (data.type) { + case "nop": + // You might want to do nothing in order to display to the user that a run can now be handled + break + case "run": + let sys = instance.globals.get("sys") + sys.stdout = new Buffer() + sys.stderr = new Buffer() + instance.globals.set("setanswer", (s) => {ret.answer = s}) + + try { + ret.result = await instance.runPythonAsync(data.code) + } catch (err) { + ret.traceback = err + } + ret.stdout = sys.stdout.value() + ret.stderr = sys.stderr.value() + break + case "wget": + let url = data.url + let dir = data.directory || fs.cwd() + let filename = url.split("/").pop() + let path = dir + "/" + filename + + if (fs.analyzePath(path).exists) { + fs.unlink(path) + } + fs.createLazyFile(dir, filename, url, true, false) + break + default: + ret.result = "Unknown message type: " + data.type + break + } + if (data.channel) { + data.channel.postMessage(ret) + } +} +self.addEventListener("message", e => handleMessage(e)) diff --git a/theme/workspace/workspace.mjs b/theme/workspace/workspace.mjs new file mode 100644 index 0000000..b3ce2f7 --- /dev/null +++ b/theme/workspace/workspace.mjs @@ -0,0 +1,214 @@ +import {Toast} from "../common.mjs" +import "https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js" + +var workers = {} + +// loadWorker returns an existing worker if one exists, otherwise, it starts a new worker +function loadWorker(language) { + let worker = workers[language] + if (!worker) { + let url = new URL(language + ".mjs", import.meta.url) + worker = new Worker(url, { + type: "module", + }) + console.info("Loading worker", url, worker) + workers[language] = worker + } + return worker +} + +export class Workspace { + /** + * + * @param element {HTMLElement} Element to populate with the workspace + * @param id {string} A unique identifier of this workspace + * @param code {string} The "pristine" source code for this workspace + * @param language {string} The language for this workspace + * @param attachmentUrls {URL[]} List of attachment URLs + */ + constructor(element, id, code, language, attachmentUrls) { + this.element = element + this.originalCode = code + this.language = language + this.attachmentUrls = attachmentUrls + this.storageKey = "code:" + id + + // Get our document and window + this.document = this.element.ownerDocument + this.window = this.document.defaultView + + // Load user modifications, if there are any + this.code = localStorage[this.storageKey] || this.originalCode + + this.status = this.element.querySelector(".status") + this.linenos = this.element.querySelector(".editor .linenos") + this.editor = this.element.querySelector(".editor .text") + this.stdout = this.element.querySelector(".stdout") + this.stderr = this.element.querySelector(".stderr") + this.traceback = this.element.querySelector(".traceback") + this.stdinfo = this.element.querySelector(".stdinfo") + this.runButton = this.element.querySelector("button.run") + this.revertButton = this.element.querySelector("button.revert") + this.fontButton = this.element.querySelector("button.font") + + this.runButton.disabled = true + + // Load in the editor + this.editor.classList.add("language-" + language) + import("https://cdn.jsdelivr.net/npm/codejar@4.2.0").then((module) => this.editorReady(module)) + + // Load the interpreter + this.initLanguage(language) + + this.runButton.addEventListener("click", () => this.run()) + this.revertButton.addEventListener("click", () => this.revert()) + this.fontButton.addEventListener("click", () => this.font()) + } + + async initLanguage(language) { + let start = performance.now() + this.status.textContent = "Initializing..." + this.status.appendChild(document.createElement("progress")) + this.worker = loadWorker(language) + await this.workerReady() + + let runtime = performance.now() - start + let duration = new Date(runtime).toISOString().slice(11, -1) + this.status.textContent = "Loaded in " + duration + this.runButton.disabled = false + + for (let a of this.attachmentUrls) { + let filename = a.pathname.split("/").pop() + this.workerWget(a) + .then(ret => { + this.stdinfo.appendChild(this.document.createElement("div")).textContent = "Downloaded " + filename + }) + + } + } + + workerMessage(message) { + let chan = new MessageChannel() + message.channel = chan.port2 + this.worker.postMessage(message, [chan.port2]) + let p = new Promise( + (resolve, reject) => { + chan.port1.addEventListener("message", e => resolve(e.data), {once: true}) + } + ) + chan.port1.start() + return p + } + + workerReady() { + return this.workerMessage({type: "nop"}) + } + + workerWget(url) { + return this.workerMessage({ + type: "wget", + url: url.href || url, + }) + } + + /** + * highlight provides a code highlighter for CodeJar + * + * It calls Prism.highlightElement, then updates line numbers + */ + highlight(editor) { + if (Prism) { + // Sometimes it loads slowly + Prism.highlightElement(editor) + } else { + console.warn("No highlighter!", Prism, this.window.document.scripts) + } + + // Create a line numbers column + if (true) { + const code = editor.textContent || "" + const lines = code.split("\n") + let linesCount = lines.length + if (lines[linesCount-1]) { + linesCount += 1 + } + + let ltxt = "" + for (let i = 1; i < linesCount; i++) { + ltxt += i + "\n" + } + this.linenos.textContent = ltxt + } + } + + /** + * Called when the editor has imported + * + */ + editorReady(module) { + this.jar = module.CodeJar(this.editor, (editor) => this.highlight(editor), {window: this.window}) + this.jar.updateCode(this.code) + switch (this.language) { + case "python": + this.jar.updateOptions({ + tab: " ", + indentOn: /:$/, + }) + break + } + } + + setAnswer(answer) { + let evt = new CustomEvent("setAnswer", {detail: {value: answer}, bubbles: true, cancelable: true}) + this.element.dispatchEvent(evt) + + this.stdinfo.appendChild(this.document.createTextNode("Set answer to ")) + this.stdinfo.appendChild(this.document.createElement("code")).textContent = answer + } + + async run() { + let start = performance.now() + this.runButton.disabled = true + this.status.textContent = "Running..." + + // Save first. Always save first. + let program = this.jar.toString() + if (program != this.originalCode) { + localStorage[this.storageKey] = program + } + + let result = await this.workerMessage({ + type: "run", + code: program, + }) + + this.stdout.textContent = result.stdout + this.stderr.textContent = result.stderr + this.traceback.textContent = result.traceback + while (this.stdinfo.firstChild) this.stdinfo.firstChild.remove() + if (result.answer) { + this.setAnswer(result.answer) + } + + let runtime = performance.now() - start + let duration = new Date(runtime).toISOString().slice(11, -1) + this.status.textContent = "Ran in " + duration + this.runButton.disabled = false + } + + revert() { + let currentCode = this.jar.toString() + let savedCode = localStorage[this.storageKey] + if ((currentCode == this.originalCode) && savedCode) { + this.jar.updateCode(savedCode) + Toast("Re-loaded saved code") + } else { + this.jar.updateCode(this.originalCode) + Toast("Reverted to original code") + } + } + + font(force) { + this.element.classList.toggle("fixed", force) + } +}