Add article

This commit is contained in:
Nathan Upchurch 2025-02-04 18:42:36 -06:00
parent a915341e5c
commit 9e62744415

View File

@ -0,0 +1,173 @@
---
title: "Building a Quiz System With Eleventy"
description: "Remember when internet quizzes were a thing? I wanted to bring them to my website."
date: 2025-02-04
tags:
- Site Updates
- Eleventy
synopsis: "Remember when internet quizzes were a thing? I wanted to bring them to my website."
imageURL: ""
imageAlt: ""
mastodon_id: "113948404881440370"
---
You might seen my [recent toot](https://lounge.town/@nathanu/113936929893588739) about the [fancy new "How Much of a Linux Nerd are You?" quiz](/quizzes/how-much-of-a-linux-nerd-are-you/) on my website. Some time ago, I realized that I missed taking fun internet quizzes and decided to implement a quiz system on my own site that would allow me to easily make fun quizzes to share. Here's how I built it with [Eleventy](https://www.11ty.dev/).
## The plan
First, I had to decide what sort of quizzes I wanted to be able to make. Some quizzes are designed to score the quiz-taker in order to place them into a category at the end, like those fun Buzzfeed quizzes that used to be so popular. Other quizzes are designed to test the quiz-takers knowledge of a subject, with each question having a definite right answer. I wanted to be able to do both, and I wanted my quizzes to be fairly flexible.
I decided to arrange things so that the quiz-author can enter any number of questions, answers, and consequences. While any number of answers can be entered for a given question, only one answer can be selected at a time, and every question must be answered. Each answer is assigned a number of points by the quiz author: positive, negative, or zero, and consequences each have a certain points threshold after which they are eligible to appear.
A consequence is a result that appears in a modal when the quiz-taker clicks the "submit" button at the end. It shows text defined by the quiz author, an image if the author chooses to include one, and it contains a "Score Details" dropdown that shows the number of points scored on each question.
I decided that I didn't want to use a global data file, not only because it isn't terribly ergonomic, but also because it's much simpler to take advantage of Eleventy's tag/collection system when possible, and frankly, I hoped to avoid some of the faffing about I had to do when [implementing image galleries](/blog/galleries/).
## Setting up the content directory
As I was going to use markdown files to build my quizzes, I needed to set up a content directory, `/content/quizzes/`, and set some defaults in `/content/quizzes/quizzes.11tydata.js` to make sure that everything I put inside of it was automatically tagged as a quiz, and would use the correct layout.
```javascript
export default {
tags: ["quiz"],
layout: "layouts/quizzes.njk",
};
```
By tagging these files as quizzes, a new [collection](https://www.11ty.dev/docs/collections/) containing all of my quizzes will be created, and I can add this collection to the `filterTagList` filter in my config file that allows me to easily omit everything that isn't a blog post from post-lists on my site, but that's out of scope for this article.
## Quiz Structure
YAML (or in fact any markup or programming language that respects whitespace) is no fun, but at least I won't wind up with a gargantuan JavaScript data file like I have [for my galleries](https://upchur.ch/gitea/n_u/nathanupchurch.com/src/branch/main/_data/galleries.js). Here's what `/content/quizzes/my-quiz.md` might look like:
``` yaml
---
title: ""
description: ""
date: 2025-02-04
imageURL: ""
imageAlt: ""
consequences:
- title: ""
points: 0
spiel: ""
image: ""
imageAlt: ""
questions:
- title: ""
image: ""
imageAlt: ""
imageCaption: ""
answers:
- name: ""
points: 0
---
This is a great quiz that I'm sure you'll have fun taking.
```
This results in a nice JavaScript object we can iterate through. In the body of the markdown document, beneath the front matter, is the text that can be injected via `{% raw %}{{ content }}{% endraw %}`. You'll see in a bit that this will go at the top of the quiz, beneath the title, which is injected with my post layout. This is so that it's easy to use markdown to style this part of the content, include images, et cetera, without worrying about trying to get that working while including it in the YAML.
## The quiz layout
Alright! Now that we have the quiz structure nailed down, we can write `/includes/layouts/quizzes.njk` which will iterate through the data and spit out an HTML form for us. I'm using the loop index number as the question number, which I can also use to set the `name` attribute for each of the answer `<input>` elements related to a given question. By doing this, the browser knows that the answers beneath a question are all related and will only allow the quiz-taker to select one of them.
I'm going to add a link to our yet-to-be-written script here and set the form to call `handleQuizSubmit()` on submit (`return false` prevents the page from refreshing when the submit button is clicked). Don't ask me why I put the script there precisely; as it isn't called until the submit button is clicked, I suppose it could go just about anywhere.
The points threshold for each consequence is stored in the [dataset](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset) `data-points-threshold` so that we can use these numbers in our JavaScript.
The answers are assigned an ID that looks like this: `q[questionNumber]a[answerNumber]`. Beyond using this to also populate the `for` property of their respective labels, you could use this to link to individual answers too.
```html
---
layout: layouts/post.njk
structuredData: none
---{% raw %}
{{ content | safe }}
<section class="quiz">
<form onsubmit="handleQuizSubmit(); return false">
{% for question in questions %}
{% set q = loop.index %}
<div class="questionBox">
<p class="quizQuestion">{{ q }}. {{ question.title }}</p>
{% if question.image %}
<figure>
<a href="{{ question.image }}">
<img src="{{ question.image }}" alt="{{ question.imageAlt }}">
</a>
{% if question.imageCaption %}
<figcaption>{{ question.imageCaption }}</figcaption>
{% endif %}
</figure>
{% endif %}
<div class="answersBox">
{% for answer in question.answers %}
<div class="answerBox">
<input class="answer" type="radio" value="{{ answer.points }}" id="q{{ q }}a{{ loop.index }}" name="{{ q }}" required>
<label for="q{{ q }}a{{ loop.index }}">{{ answer.name }}</label>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
<script src="/js/quiz.js"></script>
<button>Submit</button>
</form>
</section>
{% for consequence in consequences %}
<dialog class="consequence" data-points-threshold="{{ consequence.points }}">
<h2>{{ consequence.title }}</h2>
<p>{{ consequence.spiel }}</p>
{% if consequence.image %}
<img src="{{ consequence.image }}" alt="{{ consequence.imageAlt }}">
{% endif %}
<details>
<summary>Score Details</summary>
<p class="scoreDetails"></p>
</details>
<form method="dialog">
<button>Thanks</button>
</form>
</dialog>
{% endfor %}{% endraw %}
```
All of the consequences are rendered as `<dialog>` elements that we can open as a modal later with our script. And look, I know people have opinions about JavaScript, but I really didn't fancy the extra build time, bandwidth, and effort it would have taken to avoid fourty lines of simple JavaScript, and to be honest, I *like* JavaScript. I think it's useful and fun to write, so there.
## The quiz script
As far as logic goes, in `/js/quiz.js` we first want to calculate the score, and get the data to populate the `<details>` elements in our consequence modals. This is handled by `score()`, which will return an object containing the total number of points scored and an array containing the points scored on each question. When we have that, we'll go ahead and `populateDetails()` and finally use `dishOutConsequences()` to launch the freshly updated `<dialog>` as a modal via `showModal()`.
```javascript
const score = (answers) => {
let total = 0;
let scores = [];
for (let i = 0; i < answers.length; i++) {
const questionNumber = answers[i].name;
if (answers[i].checked) {
total += Number(answers[i].value);
scores.push({
questionNumber: questionNumber,
points: answers[i].value,
});
}
}
return { totalPoints: total, scores: scores };
};
const dishOutConsequences = (consequences, points) => {
for (let i = consequences.length - 1; i >= 0; i--) {
if (points >= Number(consequences[i].dataset.pointsThreshold)) {
consequences[i].showModal();
return;
}
}
};
const populateDetails = (detailsElement, scores, total) => {
detailsElement.innerHTML = `Total Score: ${total} points<br />`;
for (let i = 0; i < scores.length; i++) {
detailsElement.innerHTML += `<br />Question ${scores[i].questionNumber >= 10 ? scores[i].questionNumber : "0" + scores[i].questionNumber}: ${scores[i].points} points`;
}
};
const handleQuizSubmit = () => {
const answers = document.getElementsByClassName("answer");
const consequences = document.getElementsByClassName("consequence");
const details = document.getElementsByClassName("scoreDetails");
const totalPoints = score(answers).totalPoints;
const scoreDetails = score(answers).scores;
for (let i = 0; i < details.length; i++) {
populateDetails(details[i], scoreDetails, totalPoints);
}
dishOutConsequences(consequences, totalPoints);
};
```
And with that, our quiz ought to be operational! After this, I went ahead and listed my latest quiz on my index page, but that's beyond the scope of this article. It took me some time to get around to finishing this, but as you can see, it wasn't terribly difficult at all. I hope you enjoyed reading about how I built my quiz system. Please let me know if you decide to implement something similar!