first commit

This commit is contained in:
Christian Lawson-Perfect 2025-02-09 20:17:15 +00:00
commit 24fa9a2d77
15 changed files with 1870 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.make.*
elm-stuff/
error.txt
*.exam
app.js
numbas.js

2
.watchmakerc Normal file
View file

@ -0,0 +1,2 @@
extensions:
- .elm

11
Makefile Normal file
View file

@ -0,0 +1,11 @@
DIRNAME=$(notdir $(CURDIR))
ELMS=$(wildcard src/*.elm)
app.js: src/App.elm $(ELMS)
-elm make $< --output=$@ 2> error.txt
@cat error.txt
upload: app.js index.html style.css
rsync -avz . clpland:~/domains/somethingorotherwhatever.com/html/$(DIRNAME)
@echo "Uploaded to https://somethingorotherwhatever.com/$(DIRNAME)"

1
TODO Normal file
View file

@ -0,0 +1 @@
* Correctly do MathJax inside custom elements so the sizing is correct and the CSS is copied properly.

4
demo.mjs Normal file
View file

@ -0,0 +1,4 @@
import './load-app.js';
numbas_embedder.add_exam('lots-of-instances', 'exam-1009-lots-of-instances-theme.exam');
numbas_embedder.add_exam('logarithms', 'https://numbas.mathcentre.ac.uk/exam/5754/logarithms.exam');

25
elm.json Normal file
View file

@ -0,0 +1,25 @@
{
"type": "application",
"source-directories": [
"src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"NoRedInk/elm-json-decode-pipeline": "1.0.1",
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/json": "1.1.3"
},
"indirect": {
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.3"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

45
index.html Normal file
View file

@ -0,0 +1,45 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Numbas Elm theme</title>
<link rel="stylesheet" href="style.css">
<script src="mathjax-config.js"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>
<!-- Numbas runtime -->
<script src="numbas.js"></script>
<!-- JS Numbas display code -->
<script src="numbas-elm-display.js"></script>
<!-- Elm Numbas display code -->
<script src="app.js"></script>
<!-- Load exams -->
<script src="demo.mjs" type="module"></script>
</head>
<body>
<main>
<section>
<h1>Logarithms exam</h1>
<numbas-part exam="logarithms" mode="part" question="0" part="p0"></numbas-part>
</section>
<section>
<h1>Lots of instances exam</h1>
<h2>Question 1</h2>
<numbas-part exam="lots-of-instances" mode="question" question="1" part="p0"></numbas-part>
</section>
</main>
<footer>
<p>Numbas??</p>
</footer>
</body>
</html>

136
load-app.js Normal file
View file

@ -0,0 +1,136 @@
import show_error from './show-error.mjs';
console.clear();
(async () => {
const compilation_error = await show_error;
if(compilation_error) {
return;
}
});
class NumbasEmbedder {
constructor() {
this.exams = {};
this.numbas_promise = new Promise(resolve => {
Numbas.queueScript('go', ['start-exam', 'display'], async () => {
Numbas.display.init();
resolve(Numbas);
})
})
}
get_exam(id) {
if(!this.exams[id]) {
const exam_ref = this.exams[id] = {}
exam_ref.promise = new Promise((resolve,reject) => {
exam_ref.resolve = resolve;
exam_ref.reject = reject;
});
}
return this.exams[id];
}
async add_exam(id, source_url) {
console.log('add exam',id, source_url);
await this.numbas_promise;
console.log('numbas loaded', id);
const res = await fetch(source_url);
const exam_file = await res.text();
const exam_json = JSON.parse(exam_file.slice(exam_file.indexOf('\n')))
const exam = window.exam = Numbas.createExamFromJSON(exam_json);
exam.display_id = id;
exam.init();
exam.signals.on(['ready', 'display question list initialised'], () => {
console.info('exam ready', id);
Numbas.signals.trigger('exam ready');
exam.begin();
const exam_promise = this.get_exam(id);
console.log("LOADED", id);
exam_promise.resolve(exam);
});
}
}
const numbas_embedder = window.numbas_embedder = new NumbasEmbedder();
class ElmHTMLElement extends HTMLElement {
constructor() {
super();
this.attachShadow({mode:'open'});
}
static get observedAttributes() { return ['html'] };
attributeChangedCallback(name, oldValue, newValue) {
if(name == 'html') {
this.html = newValue;
}
}
set html(value) {
if(typeof value == 'string') {
this.shadowRoot.innerHTML = value;
} else {
this.shadowRoot.innerHTML = '';
this.shadowRoot.append(value);
}
}
}
customElements.define('elm-html', ElmHTMLElement);
class NumbasPartElement extends HTMLElement {
constructor() {
super();
this.attachShadow({mode:'open'});
const style = document.createElement('link');
style.rel = 'stylesheet';
style.href = 'numbas-part.css';
this.shadowRoot.append(style);
this.init_app();
}
async init_app() {
const exam_id = this.getAttribute('exam');
const mode = this.getAttribute('mode');
const questionNumber = parseInt(this.getAttribute('question'));
const partPath = this.getAttribute('part');
const exam = await numbas_embedder.get_exam(exam_id).promise;
const container = document.createElement('div');
this.shadowRoot.append(container);
const app = Elm.App.init({
node: container,
flags: {exam: exam, view_mode: {mode, questionNumber, partPath}}
});
const message_handlers = {
'question': ({questionNumber, msg}) => {
const question = exam.questionList[questionNumber];
question.display.handle_message(msg);
}
}
app.ports.sendMessage.subscribe(data => {
const fn = message_handlers[data.msgtype];
if(fn) {
fn(data);
}
})
exam.events.on('update app', ({type, arg}) => {
//type != 'showTiming' && console.log('update app', {type,arg});
app.ports.receiveMessage.send({type, arg});
});
}
}
customElements.define('numbas-part', NumbasPartElement);

10
mathjax-config.js Normal file
View file

@ -0,0 +1,10 @@
MathJax = {
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
packages: {'[+]': ['numbas']}
},
loader: {
load: ['[custom]/numbas-mathjax.js'],
paths: {custom: '.'}
}
};

1060
numbas-elm-display.js Normal file

File diff suppressed because it is too large Load diff

64
numbas-mathjax.js Normal file
View file

@ -0,0 +1,64 @@
/**
* An extension to MathJax which adds \var and \simplify commands.
*/
(function() {
var NumbasMap = new MathJax._.input.tex.SymbolMap.CommandMap(
'numbasMap',
{
var: ['numbasVar', 'var'],
simplify: ['numbasToken', 'simplify']
},
{
numbasVar: function mmlToken(parser, name, type) {
const {jme} = Numbas;
try {
const settings_string = parser.GetBrackets(name); // The optional argument to the command, in square brackets.
const settings = {};
if(settings_string!==undefined) {
settings_string.split(/\s*,\s*/g).forEach(function(v) {
var setting = jme.normaliseRulesetName(v.trim());
settings[setting] = true;
});
}
const expr = parser.GetArgument(name);
const {scope} = parser.configuration.packageData.get('numbas');
const tok = jme.evaluate(expr, scope);
const tex = jme.display.texify({tok}, settings, scope);
const mml = new MathJax._.input.tex.TexParser.default(tex, parser.stack.env, parser.configuration).mml();
parser.Push(mml);
} catch(e) {
console.error(e);
throw(new Numbas.Error('mathjax.math processing error',{message:e.message,expression:expr}));
}
}
}
);
function saveJMEScope(arg) {
const scope = Numbas.display.find_jme_scope(arg.math.start.node);
arg.data.packageData.set('numbas',{scope});
}
var NumbasConfiguration = MathJax._.input.tex.Configuration.Configuration.create('numbas', {
handler: {
macro: ['numbasMap']
},
preprocessors: [
[saveJMEScope, 1]
],
});
})();

3
numbas-part.css Normal file
View file

@ -0,0 +1,3 @@
form {
display: inline;
}

21
show-error.mjs Normal file
View file

@ -0,0 +1,21 @@
export default fetch('/error.txt').then(r=>{
if(r.ok) {
return r.text();
} else {
throw('');
}
}).then(text => {
if(!text) {
return false;
}
document.body.innerHTML = '';
const error_show = document.createElement('pre');
error_show.setAttribute('id','build-error');
error_show.style.background = 'black';
error_show.style.color = 'white';
error_show.style.padding = '1em';
error_show.style['font-size'] = '16px';
error_show.textContent = text;
document.body.appendChild(error_show);
return true;
}).catch(e => false);

479
src/App.elm Normal file
View file

@ -0,0 +1,479 @@
port module App exposing (..)
import Browser
import Dict exposing (Dict)
import Html as H exposing (Html)
import Html.Keyed
import Html.Attributes as HA
import Html.Events as HE
import Json.Decode as JD
import Json.Decode.Pipeline as JDP exposing (required, requiredAt)
import Json.Encode as JE exposing (Value)
import Tuple exposing (pair, first, second)
port sendMessage : Value -> Cmd msg
port receiveMessage : (Value -> msg) -> Sub msg
port receiveClick : (Value -> msg) -> Sub msg
main = Browser.element
{ init = init
, update = update
, subscriptions = subscriptions
, view = view
}
type alias Exam =
{ object : JE.Value
, currentQuestion : Int
, questions : List Question
}
type alias Question =
{ isDirty : Bool
, score : Float
, object : JE.Value
, name : String
, parts : List Part
, number : Int
, statement : JE.Value
}
type alias QuestionMessage =
{ questionNumber : Int
, type_ : String
, msg : JE.Value
}
type PartType
= InformationOnlyPart
| NumberEntryPart
| MathematicalExpressionPart
| PatternMatchPart
| GapFillPart
| CustomPart String
type alias Part =
{ object : JE.Value
, name : String
, type_ : PartType
, path : String
, score : Float
, marks: Float
, prompt : JE.Value
, marking_feedback : List FeedbackMessage
, gaps : Gaps
}
type Gaps = Gaps (List Part)
type alias FeedbackMessage =
{ op : String
, credit : Maybe Float
, message : JE.Value
, reason : Maybe String
, credit_change : Maybe String
}
type alias PartMessage =
{ partPath : String
, type_ : String
, msg : JE.Value
}
type ViewMode
= ViewWholeExam
| ViewQuestion Int
| ViewPart Int String
| ViewPartAnswer Int String
decode_default : JD.Decoder a -> a -> JE.Value -> a
decode_default decoder default =
JD.decodeValue decoder >> Result.withDefault default
decode_view_mode =
JD.field "mode" JD.string
|> JD.andThen (\mode -> case mode of
"exam" -> JD.succeed ViewWholeExam
"question" -> JD.field "questionNumber" JD.int |> JD.map ViewQuestion
"part" ->
JD.map2
ViewPart
(JD.field "questionNumber" JD.int)
(JD.field "partPath" JD.string)
"part_answer" ->
JD.map2
ViewPartAnswer
(JD.field "questionNumber" JD.int)
(JD.field "partPath" JD.string)
_ -> JD.fail <| "Unrecognised view mode: "++mode
)
type alias Model =
{ exam : Maybe Exam
, messages : List String
, view_mode : ViewMode
}
init_model : JE.Value -> Model
init_model flags =
let
exam =
JD.decodeValue (JD.field "exam" JD.value) flags
|> Result.toMaybe
|> Maybe.map (\e ->
{ object = e
, currentQuestion = -1
, questions = get_questions e
}
)
view_mode =
JD.decodeValue
(JD.field "view_mode" decode_view_mode)
flags
|> Result.withDefault ViewWholeExam
in
{ exam = exam
, messages = []
, view_mode = view_mode
}
type Msg
= ReceiveMessage Value
| SendPartMessage Question Part String (List (String, JE.Value))
init : JE.Value -> (Model, Cmd msg)
init flags = (init_model flags, Cmd.none)
listGet : Int -> List a -> Maybe a
listGet i = List.drop i >> List.head
decode_message =
JD.map2 pair
(JD.field "type" JD.string)
(JD.field "arg" JD.value)
update msg model = case msg of
ReceiveMessage value ->
case JD.decodeValue decode_message value of
Ok (kind,arg) -> ({ model | messages = if kind /= "showTiming" then kind::model.messages else model.messages } |> handle_exam_message kind arg, Cmd.none)
Err s -> ({ model | messages = (Debug.toString s)::model.messages }, Cmd.none)
SendPartMessage question part msgtype extras ->
(model, sendMessage (encode_part_message question part msgtype extras))
encode_message msgtype extras =
JE.object
([("msgtype", JE.string msgtype)]++extras)
encode_question_message question msgtype extras =
encode_message "question" [ ("msg", JE.object ([("msgtype", JE.string msgtype)]++extras)), ("questionNumber", JE.int question.number) ]
encode_part_message question part msgtype extras =
encode_question_message question "part" [ ("msg", JE.object ([("msgtype", JE.string msgtype)]++extras)), ("partPath", JE.string part.path)]
{- update the ith item in a list using the given map function -}
update_list : Int -> (a -> a) -> List a -> List a
update_list i fn list = list |> List.indexedMap pair |> List.map (\(j,a) -> if i==j then fn a else a)
map_exam : (Exam -> Exam) -> Model -> Model
map_exam fn model = { model | exam = Maybe.map fn model.exam }
map_question : Int -> (Question -> Question) -> Model -> Model
map_question i fn model = model |> map_exam (\exam -> { exam | questions = update_list i fn exam.questions })
map_part : String -> (Part -> Part) -> Question -> Question
map_part path fn question = { question | parts = List.map (\p -> if p.path==path then fn p else p) question.parts }
show_question : Model -> Model
show_question model =
case model.exam of
Nothing -> model
Just exam -> case JD.decodeValue (JD.field "currentQuestionNumber" JD.int) exam.object of
Ok n ->
let
nexam = { exam | currentQuestion = n }
in
{model | exam = Just nexam}
Err _ -> model
update_question_list = map_exam (\e -> {e | questions = get_questions e.object })
type alias MessageHandler m = String -> Value -> m -> m
handle_message : Dict String (Value -> m -> m) -> MessageHandler m
handle_message message_handlers kind value model =
let
handler = Dict.get kind message_handlers |> Maybe.withDefault (\_ -> identity)
in
handler value model
handle_exam_message : MessageHandler Model
handle_exam_message = handle_message <| Dict.fromList
[ ("showQuestion", \_ -> show_question)
, ("updateQuestionList", \_ -> update_question_list)
, ("question", \value model ->
let
mexam : Maybe Exam
mexam = model.exam
q = Debug.log "question message" minfo
minfo = value |> JD.decodeValue
(JD.map3 QuestionMessage
(JD.field "questionNumber" JD.int)
(JD.field "type" JD.string)
(JD.field "arg" JD.value)
)
|> Result.toMaybe
mquestion : Maybe Question
mquestion = Maybe.map2 get_question mexam (Maybe.map .questionNumber minfo) |> Maybe.andThen identity
in
case (minfo, mexam) of
(Just info, Just exam) -> map_question info.questionNumber (handle_question_message info.type_ info.msg) model
_ -> model
)
]
handle_question_message : MessageHandler Question
handle_question_message = handle_message <| Dict.fromList
[ ("isDirty", \v q ->
v |> JD.decodeValue (JD.bool) |> Result.map (\dirty -> {q | isDirty = dirty}) |> Result.withDefault q
)
, ("showScore", \_ q -> {q | score = JD.decodeValue (JD.field "score" JD.float) q.object |> Result.withDefault q.score })
, ("part", \value question ->
let
minfo = value |> JD.decodeValue
(JD.map3 PartMessage
(JD.field "partPath" JD.string)
(JD.field "type" JD.string)
(JD.field "arg" JD.value)
)
|> Result.toMaybe
q = Debug.log "part message" minfo
mpart = Maybe.map (.partPath >> get_part question) minfo
in
case (minfo, mpart) of
(Just info, Just part) -> map_part info.partPath (handle_part_message info.type_ info.msg) question
_ -> question
)
]
handle_part_message : MessageHandler Part
handle_part_message = handle_message <| Dict.fromList
[ ("showScore", \_ p ->
let
score = decode_default (JD.field "score" JD.float) 0 p.object
marks = decode_default (JD.field "marks" JD.float) 0 p.object
marking_feedback = decode_default (JD.field "markingFeedback" (JD.list decode_feedback_message)) [] p.object
in
{p | score = score, marks = marks, marking_feedback = marking_feedback })
]
subscriptions model =
Sub.batch
[ receiveMessage ReceiveMessage
]
get_exam_name : Exam -> String
get_exam_name = .object >> JD.decodeValue (JD.at ["settings","name"] JD.string) >> Result.withDefault "Unnamed exam"
get_questions : JE.Value -> List Question
get_questions = JD.decodeValue (JD.at ["questionList"] (JD.list decode_question)) >> Result.withDefault []
get_question : Exam -> Int -> Maybe Question
get_question exam n = listGet n exam.questions
get_part : Question -> String -> Maybe Part
get_part question path = question.parts |> List.filter (\p -> p.path == path) |> List.head
decode_question : JD.Decoder Question
decode_question =
JD.succeed Question
|> JDP.hardcoded False
|> JDP.hardcoded 0
|> JDP.custom JD.value
|> required "name" JD.string
|> requiredAt ["display", "allParts"] (JD.list decode_part)
|> required "number" JD.int
|> JDP.optionalAt ["display", "statement"] JD.value (JE.string "")
decode_part : JD.Decoder Part
decode_part =
JD.succeed Part
|> JDP.custom JD.value
|> required "name" JD.string
|> required "type" decode_part_type
|> required "path" JD.string
|> required "score" JD.float
|> required "marks" JD.float
|> JDP.optionalAt ["display", "prompt"] JD.value (JE.string "")
|> JDP.optional "markingFeedback" (JD.list decode_feedback_message) []
|> JDP.optional "gaps" (JD.map Gaps <| JD.list (JD.lazy (\_ -> decode_part))) (Gaps [])
part_types : Dict String PartType
part_types = Dict.fromList
[ ("information", InformationOnlyPart)
, ("numberentry", NumberEntryPart)
, ("jme", MathematicalExpressionPart)
, ("patternmatch", PatternMatchPart)
]
decode_part_type : JD.Decoder PartType
decode_part_type =
JD.string |> JD.map (\t -> Dict.get t part_types |> Maybe.withDefault (CustomPart t))
decode_feedback_message =
JD.succeed FeedbackMessage
|> required "op" JD.string
|> JDP.optional "credit" (JD.maybe JD.float) Nothing
|> required "message" JD.value
|> JDP.optional "reason" (JD.maybe JD.string) Nothing
|> JDP.optional "credit_change" (JD.maybe JD.string) Nothing
external_html : JE.Value -> Html Msg
external_html html =
H.node "elm-html"
[ HA.property "html" html ]
[]
format_score : Float -> Float -> String
format_score score marks = (String.fromFloat score)++"/"++(String.fromFloat marks)
view model = case model.exam of
Nothing -> view_loading
Just exam ->
case model.view_mode of
ViewWholeExam -> view_exam model exam
ViewQuestion n -> case get_question exam n of
Just question -> view_question question
Nothing -> H.text "That question doesn't exist"
ViewPart n path -> case get_question exam n |> Maybe.andThen (\q -> get_part q path |> Maybe.map (pair q)) of
Just (question, part) -> view_part question part
Nothing -> H.text "That part doesn't exist"
ViewPartAnswer n path -> case get_question exam n |> Maybe.andThen (\q -> get_part q path |> Maybe.map (pair q)) of
Just (question, part) -> view_part_answer_input question part
Nothing -> H.text <| "That part (q"++(String.fromInt n)++(path)++") doesn't exist"
view_loading = H.text "It's loading"
view_exam : Model -> Exam -> Html Msg
view_exam model exam =
let
exam_name = get_exam_name exam
questions = exam.questions
in
H.div
[]
[ H.h1 [] [H.text <| exam_name]
{-
, H.ul
[]
(List.map (\msg -> H.li [] [H.text msg]) (List.take 5 model.messages))
-}
, H.ul
[]
(List.map (\q -> H.li [] [view_question q]) questions)
]
view_question question =
H.div
[]
[ H.h2 [] [H.text question.name, H.text <| if question.isDirty then " (dirty)" else " (clean)"]
, H.p [] [H.text "Score: ", H.text <| String.fromFloat question.score]
, external_html question.statement
, H.ul
[]
(List.map (\p -> H.li [] [view_part question p]) question.parts)
]
view_part : Question -> Part -> Html Msg
view_part question part =
let
gaps = case part.gaps of
Gaps g -> g
in
H.div
[]
[ H.h3 [] [H.text part.name]
, H.p [] [H.text <| Debug.toString part.type_]
, H.p [] [H.text <| "Score: " ++ (format_score part.score part.marks)]
, external_html part.prompt
, view_part_answer_input question part
, H.ul
[]
(List.map (\f -> view_feedback_message f) part.marking_feedback)
]
view_part_answer_input question part =
let
msgs =
{ store_answer =
pair "answer"
>> List.singleton
>> SendPartMessage question part "part_answer"
, submit_answer = SendPartMessage question part "submit" []
}
in
case part.type_ of
GapFillPart -> H.text ""
InformationOnlyPart -> H.text ""
_ ->
H.form
[ HE.onSubmit msgs.submit_answer
, HA.attribute "part" "answer-input-form"
, HE.on "focusout" (JD.succeed msgs.submit_answer)
]
[ case part.type_ of
NumberEntryPart -> number_entry_input msgs
MathematicalExpressionPart -> mathematical_expression_input msgs
_ -> H.text ""
]
type alias AnswerMessages =
{ store_answer : JE.Value -> Msg
, submit_answer : Msg
}
type alias AnswerInput = AnswerMessages -> Html Msg
number_entry_input : AnswerInput
number_entry_input msgs =
H.input
[ HE.onInput (
JE.string
>> msgs.store_answer
)
]
[]
mathematical_expression_input : AnswerInput
mathematical_expression_input msgs =
H.input
[ HE.onInput (
JE.string
>> msgs.store_answer
)
]
[]
view_feedback_message f =
let
feedback_icon = case f.credit_change of
Just "positive" -> H.text "+"
Just "negative" -> H.text "-"
_ -> H.text ""
in
H.li
[HA.class "feedback-message"]
[ feedback_icon
, external_html f.message
]

3
style.css Normal file
View file

@ -0,0 +1,3 @@
section {
margin-bottom: 20vh;
}