3
\$\begingroup\$

I wrote an HTML parser in JavaScript, where it takes an a string that represents the HTML

const html = 
`<div>
<div>
foo
</div>
<div>
bar
</div>
</div>`

And it should output an object like this

{
    children: [
      { children: ['foo'], value: 'div' },
      { children: ['bar'], value: 'div' },
    ],
    value: 'div',
  }

here is my attempt

function parser(html) {
  const tokens = html.split('\n')
  const stack = []
  tokens.forEach((token) => {
    if (token === '</div>') {
      const children = []
      while (stack[stack.length - 1] !== '<div>') {
        children.unshift(stack.pop()) 
      }
      stack.pop()
      stack.push({
        children,
        value: 'div',
      })
    } else {
      stack.push(token)
    }
  })
  return stack.pop()
}

const html =
`<div>
<div>
foo
</div>
<div>
bar
</div>
</div>`

function parser(html) {
  const tokens = html.split('\n')
  const stack = []
  tokens.forEach((token) => {
    if (token === '</div>') {
      const children = []
      while (stack[stack.length - 1] !== '<div>') {
        children.unshift(stack.pop())
      }
      stack.pop()
      stack.push({
        children,
        value: 'div',
      })
    } else {
      stack.push(token)
    }
  })
  return stack.pop()
}

console.log(parser(html))

My questions are:

  1. How can I modify the code so it can become easily expandable for other HTML tag body, div, span etc.
  2. Right now the HTML string it takes has to be valid. How can I modify the code so it can detect invalid HTML
  3. Right now the HTML string it takes doesn't have any indentation. How can I modify the code so it can allow indentations in the input strings?
\$\endgroup\$
3
+50
\$\begingroup\$

General review points

  • Use semicolons
  • Try to avoid Array iterator functions in favor of for of. Generally use array iterators when you want the iteration to be an expression.

Apart from that the code looks good and is an elegant solution to parsing simple markup. It can be used as a base to provide the additional functionality you mention.

Questions

"Right now the HTML string it takes doesn't have any indentation. How can I modify the code so it can allow indentations in the input strings?"

After you split the markup string you can remove white spaces using String.trim

In the example parser (bottom of answer), the first expression uses Array.map and Array.filter to trim white spaces and then remove empty lines.

If you want to only remove white spaces using the same rules as HTML then you can use a RegExp and String.replace to remove white spaces.

Token validator

"How can I modify the code so it can become easily expandable for other HTML tag body, div, span etc."

"Right now the HTML string it takes has to be valid. How can I modify the code so it can detect invalid HTML"

For the other questions you will need to create an object that can validate tokens. The validators role is to recognize, valid tags, invalid tags, and content.

It does this

  • by keeping a Set of tag names
  • providing a function to check a token. If the token is a valid closing tag it returns the token representing the opening tag.
  • keeps the state of the token tested. If the token is unknown or a tag the semaphore are set appropriately.

Your function has been modified to use the validator and check for various problems related to invalid markup.

The function will return a simple error object containing the error description if the markup is invalid

If the token validator checks a token that is unknown (not in name list and starts or ends with < or >) unknown is set true.

If the token validator checks a known opening tag the semaphore tag is set true.

The semaphore are reset on each token checked.

The Validator

Add the tags you wish to recognize to the names Set.

Call tokenValidator.token to validate a token.

It will return the opening tag as a token or undefined.

If undefined the check the semaphores tokenValidator.tag and / or tokenValidator.unknown to determine what to do with the current token.

const tokenValidator = (() => {
    const names = new Set(["div", "span", "p"]);
    var unknown = false, tag = false;
    return {
        get unknown() { return unknown },
        get tag() { return tag },
        token(token) {
            tag = unknown = false;
            const hasEnd = token.endsWith(">");
            if (token.startsWith("<")) {
                if (token.startsWith("</") && hasEnd) {
                    const tokenName = token.slice(2,-1);
                    if (names.has(tokenName)) { return "<" + tokenName + ">" }
                    tag = unknown = true;
                } else if (hasEnd) {
                    const tokenName = token.slice(1,-1);
                    unknown = !names.has(tokenName);
                    tag = true;
                    return;
                }
                unknown = true;
            } else if (hasEnd) { tag = unknown = true }
        },
    };
})();

The Parser

The parses is based on your original code.

It will either return the parsed markup or an error.

Usage example

const result = parser(HTML);
if (result.error) { console.log(result.message) }
else { console.log(result) }

To check for hanging tags (eg missing closing tags) the function counts up on opening tags and down on closing tag. The countOpen must be 0 when done or there is an error.

Example Code

function parser(html) {
    const tokens = html.split('\n').map(token => token.trim()).filter(token => token !== "");
    const error = message => ({error: true, message: "Invalid HTML. " + message});
    const stack = [];
    var countOpen = 0;
    for (const token of tokens) {
        const openToken = tokenValidator.token(token);
        if (tokenValidator.unknown) { return error("Unknown tag: " + token) }
        if (openToken) {
            const children = [];
            let stackToken = stack.pop();
            while (stackToken?.value !== openToken) {
                if (!stackToken) { return error("Tag order: " + token) }
                children.unshift(stackToken);
                stackToken = stack.pop();
            }
            stack.push({value: openToken.slice(1, -1), children});
            countOpen--;
        } else if (tokenValidator.tag) {
            stack.push({value: token});
            countOpen ++;
        } else { stack.push(token) }
    }
    return countOpen ? error("Missing tag") : stack.pop();
}

Note that the code does not cover all invalid markup. It is just an example intended to demonstrate how validation can be addressed.

\$\endgroup\$
4
  • 1
    \$\begingroup\$ why are you still using var? \$\endgroup\$
    – Joji
    Mar 26 at 17:54
  • \$\begingroup\$ @Joji I use function scoped variables when the (var)iable is scoped to the function, var is the better option, Why if you understand scope and closure would you not use them? \$\endgroup\$
    – Blindman67
    Mar 26 at 19:58
  • \$\begingroup\$ but it seems like let would also work here? \$\endgroup\$
    – Joji
    Mar 26 at 21:23
  • \$\begingroup\$ @Joji Use let if you prefer. \$\endgroup\$
    – Blindman67
    Mar 26 at 22:46

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.