Using web dev skills to test web dev skills
Last year or so, I worked on a platform where I would publish video courses. The first one is on web fundamentals (in Bulgarian), and it's almost ready. You can check it out here. But this article is not about that. It's about a platform feature I built - an in-house validator to exercise the gained HTML, CSS, and JavaScript knowledge. After each lesson, I give a task to the students, and they can work on it right in the browser. The small application became an inspiration for another project - iloveweb.dev. So, I decided to share how the validation works.
The editor
If you are in my position and you want the users to type code you have two options:
- You use something like CodeMirror. Which is a full-blown code editor.
- You assemble something small by using either contenteditable or the good old
<textarea>
.
For daskalo.dev I used the first option. It's because I had more space and wanted to provide a close-to-real programming experience to the students. For iloveweb.dev I first tried to use contenteditable
but had some CSS problems, so I ended up with <textarea>
.
The bottom line is that we need a way to get input from the user. Code that we validate against the given task.
Validating answers on HTML questions
To get a better idea of what we are talking about, here is an example of a question:
Let's say that we have a page with header and navigation containing 3
<a>
tags. Write a semantically correct HTML.
The idea here is to test the knowledge about semantics in HTML. The correct answer for this task is something like:
<header>
<nav>
<a href="">A</a>
<a href="">B</a>
<a href="">C</a>
</nav>
</header>
(By the way, you can see the actual question here and try solving it yourself.)
So, the user starts typing code, and we have to see if the code is valid HTML, containing <header>
element that has nested <nav>
that has three <a>
links. The first thing is to parse the text and convert it to some sort of AST (abstract syntax tree), so we can traverse it. We must rely on something other than regular expressions because it will be a nightmare for more complex tasks. I landed on this reasonably small parser here written by Sam Blowes. It's ~4Kb minified. Its input is HTML and outputs a tree of objects we can loop over. For example:
<p><span>Text</span></p>
is processed to:
{
"node": "root",
"child": [
{
"node": "element",
"tag": "p",
"child": [
{
"node": "element",
"tag": "span",
"child": [
{
"node": "text",
"text": "Text"
}
]
}
]
}
]
}
I wrote a simple function that traverses the structure and gives me access to each node.
function walkHTML(item, cb) {
cb(item);
if (item.child) {
item.child.forEach(i => {
walkHTML(i, cb);
});
}
}
The HTML parser and walkHTML
are enough to write a validator for the task above. It's a matter of recognizing a specific structure of tags and counting how many <a>
elements we have.
function validator(tree) {
let numOfLinks = 0;
walkHTML(tree, (el) => {
if (el.tag === 'header') {
walkHTML(el, (headerChild) => {
if (headerChild.tag === 'nav') {
walkHTML(headerChild, (navChild) => {
if (navChild.tag === 'a') {
numOfLinks += 1;
}
});
}
});
}
});
return numOfLinks === 3;
}
validator(html2json(code));
Validating answers on CSS questions
To validate CSS, we can use the same approach. We can get the code, transform it into a tree and analyze it. Similarly to HTML, there are a lot of parsers out there. I decided to use cssparser. It's pretty much the following:
const parser = new cssparser.Parser();
const tree = parser.parse(code).toJSON('simple');
For body { font-size: 20px; }
we'll get back:
{
"type": "stylesheet",
"level": "simple",
"value": [
{
"type": "rule",
"selectors": [
"body"
],
"declarations": {
"font-size": "20px"
}
}
]
}
Instead of "simple," we can use "deep" or "atomic," which results in a lot more detailed tree. For me, "simple" was good enough.
That was good, but the CSS questions could go beyond syntax and structure. Two things require a bit more work - selector specificity and selector matching. Those we can't validate by using a tree.
For the specificity, I looked here. A small utility that accepts a selector and returns the calculated specificity. For example:
let value = SPECIFICITY.calculate('#foo .bar .moo p')
// value ->
/*
[
{
"selector": "#foo .bar .moo p",
"specificity": "0,1,2,1",
"specificityArray": [0, 1, 2, 1],
"parts": [ ...]
}
]
*/
Then with a little transformation we get a number like 121
.
Number(SPECIFICITY.calculate('#foo .bar .moo p')[0].specificity.replace(/,/g, '')); // 121
Then we can ask a question like
Write a valid CSS rule which's selector has a specificity equal to 130.
and validate it with
function validator(ast) {
const tree = ast.toJSON('simple');
let success = false;
tree.value.forEach(({ type, selectors, declarations }) => {
selectors.forEach((selector) => {
const specificity = Number(SPECIFICITY.calculate(selector)[0].specificity.replace(/,/g, ''));
if (specificity === 130) {
success = true;
}
});
});
return success;
}
const parser = new cssparser.Parser();
const ast = parser.parse(code);
validator(ast);
Play with the question here.
The more interesting problem is to validate a selector matching. Or in other words, to see if a particular selector matches a specific HTML element. After a couple of iterations, I found that there IS an API in the browser done specifically for this purpose - element.matches(). If we have a valid DOM somewhere, we can select an element and execute .matches(selector)
. However, at that point, I have already implemented something that works. It's a bit hacky, but I like that I found it myself, so I decided to go with it. Let's use the following question to illustrate the solution:
Set a color of the second
<span>
element in the following HTML snippet: <section>
<p>
<span class="xxx">A</span>
<span>B</span>
</p>
</section>
The first thing we have to do is add that HTML to a page. We need a real DOM element. We want to keep this out of the current page because this may affect the existing content. The way to do that is to can an iframe dynamically. In that newly created iframe, we can use document.write
to place in the <section>
and its nested elements. From there, we can also add a <script>
tag that runs some logic. And in our case, that logic will be document.querySelector
(or element.matches
) to verify if the given by the user selector works. In the end, if the selector matches, we want access to the matched element to see if it is what we wanted. Here is the final version of my function:
// Note: on the current page we have <div id="exerciseFrame"></div>
function matchSelectorToHTML(selector, html) {
const iframe = document.querySelector('#exerciseFrame');
let result = false;
const iframeDoc = iframe.contentDocument || iframeWin.document;
const funcName = `iframeMatching${new Date().getTime()}`;
window[funcName] = function(r) {
result = r;
}
iframeDoc.open();
iframeDoc.write(html);
iframeDoc.write(`
<script>
el = document.querySelector("${selector}");
parent.${funcName}(el);
</script>
`);
iframeDoc.close();
delete window[funcName];
return result;
}
Notice how we create a function in the global scope (funcName
); then, from inside the iframe, we use parent.<func name>
to call it with the matched DOM element (if any). I know it's tricky, but it works. You can see the question live here.
Validating answers on JavaScript questions
Initially, I thought validating JavaScript tasks would be the most challenging part. It happened that it was the easiest. All I had to use was the Function
constructor. Let's look at the following question:
Write a JavaScript function with name "test" that returns "iloveweb".
And the validator that I'm using:
function validator(code) {
const f = new Function(`
${code};
return test();
`);
return f() === 'iloveweb';
}
In JavaScript, we can use the Function constructor to create a function out of a string. So, all we have to do is to assume that the user will type the correct JavaScript. Then we plug in the code in some context created by us. In this case, we expect to have a function with the name test
. We call that function and return its result. It needs to be "iloveweb".
The more complex question, of course, requires more context from our side. For example:
Write a JavaScript function "render" that updates the HTML content of the following tag:
<div id="app"></div>
We want to see if the developer knows how to select a DOM element and update its content. We can't allow access to the actual DOM APIs. So, we have to mock them like so:
function validator(code) {
const f = new Function(`
let domEl = {};
const document = {
querySelector(sel) {
return sel === '#app' ? domEl : null;
},
querySelectorAll(sel) {
return sel === '#app' ? [domEl] : [];
},
getElementById(sel) {
return sel === 'app' ? domEl : null;
}
}
${code};
render();
return domEl;
`);
return typeof f().innerHTML !== 'undefined';
}
(Have in mind that the Function
constructor could be dangerous. You are giving a lot of power to the user. Use it wisely.)
Final words
All the techniques above are bundled in iloveweb.dev. Go check it out, and if you want to contribute with more questions, visit the project's GitHub repo here.