diff --git a/docs/demo/elements.md b/docs/demo/elements.md
index 5334fab..82e6bd5 100644
--- a/docs/demo/elements.md
+++ b/docs/demo/elements.md
@@ -41,6 +41,34 @@ This solution is expanded by default.
This solution has a different color, and no drop-down.
```
+## Quizzes
+
+The helpers in the
+[`quizz.js`](https://github.com/t-doc-org/common/blob/main/tdoc/common/static/tdoc/quizz.js)
+module enable the creation of quizzes as dynamic page elements.
+
+
+
+1.
+2.
+3. The input field of quizz questions without a prompt uses the whole line.
+
+
## IFrames
The [YouTube](https://youtube.com/) video below is embedded with the
diff --git a/tdoc/common/ext/__init__.py b/tdoc/common/ext/__init__.py
index 82a4e9e..6b8bb56 100644
--- a/tdoc/common/ext/__init__.py
+++ b/tdoc/common/ext/__init__.py
@@ -59,6 +59,7 @@ def build_tag(app):
def setup(app):
+ app.set_html_assets_policy('always') # Ensure MathJax is always available
app.add_event('tdoc-html-page-config')
app.add_config_value('license', '', 'html')
diff --git a/tdoc/common/static/tdoc/core.js b/tdoc/common/static/tdoc/core.js
index 5739802..a15167a 100644
--- a/tdoc/common/static/tdoc/core.js
+++ b/tdoc/common/static/tdoc/core.js
@@ -22,11 +22,18 @@ export function text(value) {
return document.createTextNode(value);
}
+// Create an DocumentFragment node.
+export function html(html) {
+ const el = document.createElement('template');
+ el.innerHTML = html;
+ return el.content;
+}
+
// Create an element node.
export function element(html) {
- const t = document.createElement('template');
- t.innerHTML = html.trim();
- return t.content.firstChild;
+ const el = document.createElement('template');
+ el.innerHTML = html.trim();
+ return el.content.firstChild;
}
// Return true iff the given element is within the root viewport.
@@ -37,12 +44,93 @@ export function isVisible(el) {
rect.right <= document.documentElement.clientWidth;
}
+// Return a containing inline math. The element must be typeset after
+// being added to the DOM.
+export function inlineMath(value) {
+ const el = element('');
+ const [start, end] = MathJax.tex?.inlineMath ?? ['\\(', '\\)'];
+ el.appendChild(text(`${start}${value}${end}`));
+ return el;
+}
+
+// Return a
containing display math. The element must be typeset after
+// being added to the DOM.
+export function displayMath(value) {
+ // The formatting of the content corresponds to what spinx.ext.mathjax does.
+ const parts = [];
+ for (const p of value.split('\n\n')) {
+ if (p.trim()) parts.push(p);
+ }
+ const out = [];
+ if (parts.length > 1) out.push(' \\begin{align}\\begin{aligned}');
+ for (const [i, p] of parts.entries()) {
+ const nl = p.includes('\\\\');
+ if (nl) out.push('\\begin{split}');
+ out.push(p);
+ if (nl) out.push('\\end{split}');
+ if (i < parts.length - 1) out.push('\\\\');
+ }
+ if (parts.length > 1) out.push('\\end{aligned}\\end{align} ');
+ const el = element('');
+ const [start, end] = MathJax.tex?.displayMath ?? ['\\[', '\\]'];
+ el.appendChild(text(`${start}${out.join('')}${end}`));
+ return el;
+}
+
+let typeset = globalThis.MathJax?.startup?.promise;
+if (!typeset && globalThis.MathJax) {
+ if (!MathJax.startup) MathJax.startup = {};
+ typeset = new Promise(resolve => {
+ MathJax.startup.pageReady = () => {
+ return MathJax.startup.defaultPageReady().then(resolve);
+ }
+ });
+}
+
+// Typeset the math contained in one or more elements.
+export function typesetMath(...args) {
+ typeset = typeset.then(() => MathJax.typesetPromise(args))
+ .catch(e => console.error(`Math typesetting failed: ${e}`));
+ return typeset;
+}
+
// Focus an element if it is visible and no other element has the focus.
export function focusIfVisible(el) {
const active = document.activeElement;
if ((!active || active.tagName === 'BODY') && isVisible(el)) el.focus();
}
+const cMinus = '-'.charCodeAt(0), cPlus = '+'.charCodeAt(0);
+const c0 = '0'.charCodeAt(0), cA = 'A'.charCodeAt(0);
+
+// Convert a string to an integer. Returns undefined if the string is not a
+// valid integer.
+export function strToInt(s, radix = 10) {
+ if (!(2 <= radix && radix <= 36)) return;
+ s = s.toUpperCase();
+ let i = 0, sign = 1, res = 0;
+ while (i < s.length) {
+ const c = s.charCodeAt(i++);
+ if (i === 1) {
+ if (c === cPlus) {
+ continue;
+ } else if (c === cMinus) {
+ sign = -1;
+ continue;
+ }
+ }
+ let d = 36;
+ if (c0 <= c && c < c0 + 10) {
+ d = c - c0;
+ } else if (cA <= c && c < cA + 26) {
+ d = 10 + c - cA;
+ }
+ if (d >= radix) return;
+ res = res * radix + d;
+ }
+ return sign * res;
+}
+
// Convert binary data to base64.
export function toBase64(data) {
return new Promise((resolve, reject) => {
diff --git a/tdoc/common/static/tdoc/early.js b/tdoc/common/static/tdoc/early.js
index 1f64938..66e9387 100644
--- a/tdoc/common/static/tdoc/early.js
+++ b/tdoc/common/static/tdoc/early.js
@@ -15,6 +15,14 @@
};
}
+ const script = document.currentScript;
+ const staticUrl = new URL('..', script.src).toString();
+
+ // Import a module specified relative to the _static directory.
+ tdoc.import = async (module, options) => {
+ return await import(new URL(module, staticUrl), options);
+ }
+
// Set data-* attributes on the tag.
Object.assign(document.documentElement.dataset, tdoc.html_data);
@@ -30,8 +38,7 @@
// Set up the SharedArrayBuffer workaround as configured.
const workers = navigator.serviceWorker;
const enableSAB = tdoc['enable_sab'];
- const script = document.currentScript;
- const url = new URL(`../tdoc-worker.js?sab=${enableSAB}`, script.src)
+ const url = new URL(`../tdoc-worker.js?sab=${enableSAB}`, staticUrl)
.toString();
if (enableSAB === 'no') {
return (async () => {
diff --git a/tdoc/common/static/tdoc/exec-python.js b/tdoc/common/static/tdoc/exec-python.js
index ddb67c6..1ee527f 100644
--- a/tdoc/common/static/tdoc/exec-python.js
+++ b/tdoc/common/static/tdoc/exec-python.js
@@ -146,7 +146,7 @@ class PythonExecutor extends Executor {
case 'line': {
const input = div.appendChild(element(`\
`));
+ autocorrect="off" spellcheck="false">`));
const btn = div.appendChild(element(`\
`));
btn.addEventListener('click', () => { resolve(input.value); });
diff --git a/tdoc/common/static/tdoc/quizz.js b/tdoc/common/static/tdoc/quizz.js
new file mode 100644
index 0000000..4dfe1c7
--- /dev/null
+++ b/tdoc/common/static/tdoc/quizz.js
@@ -0,0 +1,39 @@
+// Copyright 2024 Remy Blank
+// SPDX-License-Identifier: MIT
+
+import {element, focusIfVisible, text} from './core.js';
+
+// Add a quizz question with a reply input field after the given node. Return
+// the quizz question container element.
+export function question(node, prompt, check) {
+ const div = element(`\
+