nathanupchurch.com/content/blog/making-quizzes-using-eleventy.md
2025-02-04 18:42:36 -06:00

9.7 KiB

title description date tags synopsis imageURL imageAlt mastodon_id
Building a Quiz System With Eleventy Remember when internet quizzes were a thing? I wanted to bring them to my website. 2025-02-04
Site Updates
Eleventy
Remember when internet quizzes were a thing? I wanted to bring them to my website. 113948404881440370

You might seen my recent toot about the fancy new "How Much of a Linux Nerd are You?" quiz 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.

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.

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.

export default {
	tags: ["quiz"],
	layout: "layouts/quizzes.njk",
};

By tagging these files as quizzes, a new collection 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. Here's what /content/quizzes/my-quiz.md might look like:

---
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 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.

---
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().

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!