Compare commits

...

4 Commits

Author SHA1 Message Date
Neale Pickett a7c2ee0022 Better structured theme config 2024-04-01 17:06:29 -06:00
Neale Pickett 2f7fba2dff Default font fallback 2024-04-01 16:57:02 -06:00
Neale Pickett 285c101bc6 I don't like Go Mono 2024-04-01 16:56:33 -06:00
Neale Pickett 3e629c6859 Re-adds workspaces (python IDE) 2024-04-01 16:46:45 -06:00
12 changed files with 575 additions and 52 deletions

View File

@ -129,10 +129,36 @@ Both came with the following license:
> OTHER DEALINGS IN THE FONT SOFTWARE.
Javascript MD5 Library
======================
Go Fonts
=======
Obtained from <https://github.com/blueimp/JavaScript-MD5>, 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.

View File

@ -0,0 +1 @@
Boop!

View File

@ -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)
```

View File

@ -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;
}
}

View File

@ -1,6 +1,13 @@
{
"TrackSolved": true,
"Titles": false,
"PuzzleList": {
"TrackSolved": true,
"Titles": false,
"": 0
},
"Puzzle": {
"SyntaxHighlighting": true,
"": 0
},
"Scoreboard": {
"DisplayServerURLWhenEnabled": true,
"ShowCategoryLeaders": true,
@ -8,7 +15,7 @@
"ReplayFPS": 6,
"ReplayDurationMS": 2000,
"NoScoresHtml": "<div class='notification'><h2>~ no scores ~</h2></div>",
"": ""
"": 0
},
"Messages": "<!-- Messages can go here (HTML) -->",
"": "this is here so you don't have to remember to take the comma off the last item"

BIN
theme/fonts/Go-Regular.ttf Normal file

Binary file not shown.

View File

@ -88,7 +88,7 @@ class App {
// Update elements with data-track-solved
for (let e of document.querySelectorAll("[data-track-solved]")) {
// Only display if data-track-solved is the same as config.trackSolved
e.classList.toggle("hidden", common.Truthy(e.dataset.trackSolved) != this.config.TrackSolved)
e.classList.toggle("hidden", common.Truthy(e.dataset.trackSolved) != this.config.PuzzleList?.TrackSolved)
}
for (let e of document.querySelectorAll(".login")) {
@ -152,10 +152,10 @@ class App {
a.href = url
a.target = "_blank"
if (this.config.TrackSolved) {
if (this.config.PuzzleList?.TrackSolved) {
a.classList.toggle("solved", this.state.IsSolved(puzzle))
}
if (this.config.Titles) {
if (this.config.PuzzleList?.Titles) {
this.loadTitle(puzzle, i)
}
}

113
theme/puzzle.css Normal file
View File

@ -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, "source code pro", consolas, monospace;
}
.fixed .output, .fixed .editor {
font-family: "source code pro", consolas, 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);
}

View File

@ -6,6 +6,7 @@
<meta charset="utf-8">
<link rel="icon" href="luna-moth.svg">
<link rel="stylesheet" href="basic.css">
<link rel="stylesheet" href="puzzle.css">
<script src="background.mjs" type="module" async></script>
<script src="puzzle.mjs" type="module" async></script>
</head>
@ -30,5 +31,23 @@
</main>
<div class="debug" class="notification"></div>
<div class="toasts"></div>
<template id="workspace">
<div class="editor">
<div class="linenos"></div>
<div class="text"></div>
</div>
<div class="controls">
<button class="run">Run</button>
<button class="font" title="Switch in and out of monospace font">Font</button>
<span class="status">Execution time: 0.03s</span>
<button class="revert" title="Reset code to original">Revert</button>
</div>
<div class="output">
<div class="stdout"></div>
<div class="stderr"></div>
<div class="traceback"></div>
<div class="stdinfo"></div>
</div>
</template>
</body>
</html>

View File

@ -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)

View File

@ -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))

View File

@ -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)
}
}