pupate

Puzzle transpiler
git clone https://git.woozle.org/neale/pupate.git

pupate / variants / sctr / web
Neale Pickett  ·  2025-02-27

sctr.mjs

  1import {Toast} from "./toast.mjs"
  2import * as frontmatter from "./frontmatter.mjs"
  3
  4const confettiPromise = import("https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/+esm")
  5      .catch(e => {})
  6const commonmarkPromise = import("https://cdn.jsdelivr.net/npm/commonmark@0.31.1/+esm")
  7      .catch(e => {})
  8
  9const Millisecond = 1
 10const Second = 1000 * Millisecond
 11const Minute = 60 * Second
 12const Hour = 60 * Minute
 13
 14// fill slot elements
 15function fill(element, obj) {
 16    for (let slot of element.querySelectorAll("slot")) {
 17        let name = slot.name
 18        let val = obj[name]
 19
 20        if (name == "modules") {
 21            let modulesDoc = new DocumentFragment()
 22            for (let module of val) {
 23                let li = modulesDoc.appendChild(document.createElement("li"))
 24                let a = li.appendChild(document.createElement("a"))
 25                a.textContent = `${module.step} - ${module.title}`
 26                a.href = `${location.hash}#${module.step}`
 27            }
 28            slot.replaceWith(modulesDoc)
 29        } else if (name == "content") {
 30            slot.parentElement.innerHTML = val
 31        } else if (Array.isArray(val)) {
 32            let doc = new DocumentFragment()
 33            for (let item of val) {
 34                let li = doc.appendChild(document.createElement("li"))
 35                if (slot.dataset.links !== undefined) {
 36                    let a = li.appendChild(document.createElement("a"))
 37                    a.textContent = item
 38                    a.href = item
 39                } else {
 40                    li.textContent = item
 41                }
 42                
 43            }
 44            slot.replaceWith(doc)
 45        } else if (val) {
 46            slot.replaceWith(new Text(val))
 47        } else {
 48            // Leave the default value
 49        }
 50    }
 51}
 52
 53// Use the MOTH API endpoint to return a list of training IDs.
 54async function TrainingIDs() {
 55    let resp = await fetch(
 56        "-/state/current.json",
 57        {
 58            cache: "no-cache",
 59        },
 60    )
 61    let obj = await resp.json()
 62    return Object.keys(obj.Puzzles)
 63}
 64
 65// Fetch a training description.
 66async function Training(trainingID) {
 67    let resp = await fetch(
 68        `-/puzzles/${trainingID}/training.json`,
 69        {
 70            cache: "no-cache",
 71        },
 72    )
 73    let training = await resp.json()
 74    training.ID = trainingID
 75    return training
 76}
 77
 78// Render trainings
 79async function RenderAllTrainings() {
 80    let trainingsElement = document.querySelector(".traininglist")
 81
 82    let list = trainingsElement.appendChild(document.createElement("dl"))
 83    for (let trainingID of await TrainingIDs()) {
 84        let training = await Training(trainingID)
 85
 86        let dt = list.appendChild(document.createElement("dt"))
 87        let a = dt.appendChild(document.createElement("a"))
 88        a.textContent = training.title
 89        a.href = `#${trainingID}`
 90        list.appendChild(document.createElement("dd")).textContent = training.description
 91    }
 92
 93    trainingsElement.classList.remove("hidden")
 94}
 95
 96async function RenderTraining(trainingID) {
 97    let trainingElement = document.querySelector(".training")
 98
 99    let training = await Training(trainingID)
100
101    fill(trainingElement, training)
102    document.title = training.title
103
104    trainingElement.classList.remove("hidden")
105}
106
107async function RenderModule(trainingID, step) {
108    let moduleElement = document.querySelector(".module")
109
110    let training = await Training(trainingID)
111    let module
112    for (let mod of training.modules) {
113        if (mod.step == step) {
114            module = mod
115        }
116    }
117    module.question = module.knowledge_check.question
118
119    let moduleURL = new URL(`/-/puzzles/${trainingID}/${module.path}`, location)
120    let baseURL = new URL(".", moduleURL)
121
122    let resp = await fetch(moduleURL)
123    if (!resp.ok) {
124        RenderError(`${moduleURL}: ${resp.status} ${resp.statusText}`)
125        return
126    } 
127    let text = await resp.text()
128
129    // Convert markdown
130    let page = new frontmatter.Doc(text)
131    let urlpfx = page.urlpfx()
132    let content = page.content.replaceAll(urlpfx, "")
133
134    // Render as a pre for speed
135    let pre = document.createElement("pre")
136    pre.textContent = content
137    module.content = pre.outerHTML
138    fill(moduleElement, module)
139
140    // Once we have a parser (which may be immediately),
141    // replace the pre element.
142    commonmarkPromise
143        .then(commonmark => {
144            let parser = new commonmark.Parser()
145            let parsed = parser.parse(content)
146            let writer = new commonmark.HtmlRenderer()
147            document.querySelector(".content").innerHTML = writer.render(parsed)
148        })
149        .catch(err => {
150            Toast("Unable to load markdown parser")
151        })
152
153    document.title = module.title
154
155    // Do after everything else has been fetched!
156    let base = document.querySelector("base") || document.head.appendChild(document.createElement("base"))
157    base.href = baseURL
158
159    document.querySelector("form.answer").addEventListener("submit", e => answerSubmit(e, module.knowledge_check.answers))
160
161    moduleElement.classList.remove("hidden")
162}
163
164function answerSubmit(e, answers) {
165    e.preventDefault()
166    let form = e.target
167    let submittedAnswer = form.elements.namedItem("answer").value
168
169    for (let correct of answers) {
170        if (submittedAnswer == correct) {
171            Toast("Correct answer")
172            confettiPromise
173                .then(c => {
174                    c.default({
175                        disableForReducedMotion: true,
176                    })
177                })
178                .catch(err => {})
179            return
180        }
181    }
182    Toast("Wrong answer")
183}
184
185async function RenderError(message) {
186    let topElement = document.querySelector(".error")
187    topElement.classList.remove("hidden")
188    topElement.appendChild(document.createElement("p")).textContent = message
189}
190
191
192async function init() {
193    window.addEventListener("hashchange", () => location.reload())
194
195    let hashparts = location.hash.split("#")
196    let promise
197    switch (hashparts.length) {
198    case 1:
199        await RenderAllTrainings()
200        break
201    case 2:
202        await RenderTraining(hashparts[1])
203        break
204    case 3:
205        await RenderModule(hashparts[1], hashparts[2])
206        break
207    default:
208        await RenderError("Cannot parse this URL!")
209        break
210    }
211
212    for (let el of document.querySelectorAll(".loading")) {
213        el.classList.add("hidden")
214    }
215}
216
217init()