dom-eater
DOM Eater
A fast, error-tolerant AST parser for markup in HTML templates, JSX/TSX, JavaScript/TypeScript, Razor and Pug.
DOM Eater is built for editor tooling that highlights and completes CSS written in class attributes. Its AST is general enough for other tooling that needs the same information, but its scope and API follow what that tooling needs:
- It finds elements, attributes, text, and the script regions and string literals inside them, with exact source positions.
- It does not parse JavaScript or C#. It scans them the way the TypeScript parser and the Razor compiler read them, to decide what
{,},<,/,@and quotes mean, and skips the rest. If you need a JavaScript AST, use a JavaScript parser. - It never gives up on incomplete code, so the element being typed at the end of the text is still found.
Installation
npm i dom-eater
API
import { createAstSyntaxError, parseHtml, parseJsx, parsePug, parseRazor, parseRazorComponent, parseScript } from "dom-eater"
import type { AstNode, AstSyntaxError, Element } from "dom-eater"
| Function | Input | Finds |
|---|---|---|
parseHtml(text, include_text?) |
HTML and template files: Vue, Svelte, Angular templates, Alpine.js, HTMX, Blade, Jinja | The element tree, with {…} blocks as scripts |
parseJsx(text, include_text?) |
JavaScript or TypeScript with JSX: .jsx, .tsx, .js, Astro |
JSX elements, and the markup of HTML templates |
parsePug(text, include_text?) |
Pug templates: .pug and .jade |
The element tree, with code, #{…} interpolations and logic such as if and each as scripts |
parseRazor(text, include_text?) |
Razor views and pages: .cshtml in ASP.NET Core MVC and Razor Pages |
The element tree, with @… code as scripts |
parseRazorComponent(text, include_text?) |
Razor components: .razor in Blazor |
The element tree, with @… code as scripts and directive attributes such as @onclick |
parseScript(text, include_text?) |
JavaScript or TypeScript without JSX: .ts, where <T>value is a type assertion |
The markup of HTML templates |
createAstSyntaxError(message, start, end) |
A message and a range | An error in the shape of those in errors, for tools that report their own problems next to them |
Each parser returns { ast, errors } and never throws: syntax errors are reported in errors while parsing goes on. A text that is not a string gives no nodes and the error "The input is not a string.".
ast: forparseHtml,parsePug,parseRazorandparseRazorComponent, the top-level nodes, typedElement["children"]; forparseJsxandparseScript, the top-level elements found in the script, typedElement[].errors:AstSyntaxErrorobjects, which areinstanceof Error, withname: "AstSyntaxError",messageand thestartandendof the problem as own properties and nostack.JSON.stringify,structuredCloneandpostMessagekeep all four, so errors can be sent from a worker or a language server; the copiesstructuredCloneandpostMessagemake are plain objects.include_text: passtrueto addtext, the source slice, to every node.
Positions are UTF-16 offsets into text: start is inclusive and end exclusive, so text.slice(node.start, node.end) is the node's source.
HTML templates in scripts
parseJsx and parseScript parse the content of these template literals as HTML:
- Tagged with
htmlorsvg, as in Lit:html`<p class="a">${b}</p>` - Marked with a comment:
/* html */ `<p>…</p>` - The value of a
templateproperty, as in Angular and Vue components:@Component({ template: `<p>…</p>` })
Each ${…} becomes a Script node in the markup, starting at its $. It holds the strings and elements in its code, so templates nested in substitutions stay in place.
AST
Every node has type, start, end, and text when include_text is set.
| Node | Fields |
|---|---|
Element |
name; subType: "open", "closed" (self-closing <br/>) or "close" (an unmatched close tag); attributes; children: Element, Text, Comment, Script and Style nodes. In parseHtml and HTML templates, <!DOCTYPE html> is an empty "open" element named !DOCTYPE with the attribute html, without an error |
Attribute |
name ("" for a spread block such as {...props}, and for Pug's &attributes(…) and mixin arguments); value: true without a value, a String ("double", "single" or "unquoted"), or a Script for name={…}, Razor code in place of an attribute, and Pug values other than a quoted string |
String |
subType: "double", "single", "backtick" or "unquoted" (also Pug's .class and #id literals); scripts: the script regions inside it |
Script |
subType: "block" ({…} in parseHtml and in HTML templates), "content" (of a <script> element), "template" (${…}), "jsx" ({…} in JSX), "pug" (code, #{…}, logic lines and attribute values in parsePug) or "razor" (@… in parseRazor and parseRazorComponent); strings: the string literals in its code; elements: the elements in its code, on "jsx", "razor" and "template" Scripts except a "template" in a "backtick" String, the ${…} of a template literal in code |
Text |
Text between the other nodes |
Comment |
<!-- … -->, bogus comments such as <?xml … ?>, Razor comments @* … *@ and declarations such as <!DOCTYPE html>, and Pug comments // and //- with their blocks |
Style |
The content of a <style> element |
The types are exported from dom-eater itself: import type { AstNode, Element } from "dom-eater". The type and subType fields narrow them: a "template" Script among an element's children has elements and one in a "backtick" String has none, so on any AstNode check "elements" in node.
Examples
parseHtml
import { parseHtml } from "dom-eater"
parseHtml(`<p class="note {tone}">Hi {name}</p>`)
{
"ast": [
{
"attributes": [
{
"end": 22,
"name": "class",
"start": 3,
"type": "Attribute",
"value": {
"end": 22,
"scripts": [
{
"end": 21,
"start": 15,
"strings": [],
"subType": "block",
"type": "Script"
}
],
"start": 9,
"subType": "double",
"type": "String"
}
}
],
"children": [
{
"end": 26,
"start": 23,
"type": "Text"
},
{
"end": 32,
"start": 26,
"strings": [],
"subType": "block",
"type": "Script"
}
],
"end": 36,
"name": "p",
"start": 0,
"subType": "open",
"type": "Element"
}
],
"errors": []
}
parseRazor
import { parseRazor } from "dom-eater"
parseRazor(`<li class="@(done ? "done" : "")">@item.Name</li>`)
{
"ast": [
{
"attributes": [
{
"end": 33,
"name": "class",
"start": 4,
"type": "Attribute",
"value": {
"end": 33,
"scripts": [
{
"elements": [],
"end": 32,
"start": 11,
"strings": [
{
"end": 26,
"scripts": [],
"start": 20,
"subType": "double",
"type": "String"
},
{
"end": 31,
"scripts": [],
"start": 29,
"subType": "double",
"type": "String"
}
],
"subType": "razor",
"type": "Script"
}
],
"start": 10,
"subType": "double",
"type": "String"
}
}
],
"children": [
{
"elements": [],
"end": 44,
"start": 34,
"strings": [],
"subType": "razor",
"type": "Script"
}
],
"end": 49,
"name": "li",
"start": 0,
"subType": "open",
"type": "Element"
}
],
"errors": []
}
parseJsx, parseScript and parsePug produce the same shapes: {…} in JSX is a "jsx" Script with its elements, ${…} in an HTML template is a "template" Script, and Pug code and attribute expressions are "pug" Scripts.
Frameworks
| Framework | Parser |
|---|---|
| React, Preact, Solid, Qwik, Stencil | parseJsx |
| Astro | parseJsx |
Vue (single-file components, including <template lang="pug">) |
parseHtml |
Svelte, including Svelte 5 snippets and {@render} |
parseHtml |
Angular templates, including control flow, @let and ICU expressions |
parseHtml |
| Angular inline templates, Lit | parseScript, or parseJsx for .js |
| Alpine.js, HTMX | parseHtml |
ASP.NET Core MVC and Razor Pages (.cshtml) |
parseRazor |
Blazor (.razor) |
parseRazorComponent |
Pug (.pug, .jade), as in Express and Hexo themes |
parsePug |
| Server templates: PHP, Blade, ERB, Twig, Jinja, Liquid, Handlebars, Hugo; Markdown | parseHtml |
How markup is parsed
parseHtml
- Close tags match the nearest open element with the same name, case-insensitively.
- End tags that HTML makes optional (
p,li,td,option, …) are implied the way browsers imply them. Any other element that is never closed is reported, and the nodes after it stay its siblings. script,style,textarea,title,iframe,noembed,noframesandxmphold raw text that ends at the first matching close tag, as in browsers.plaintextruns to the end of the input.- Inside
svgandmath, onlyscriptandstylehold raw text, as in Svelte, and<![CDATA[…]]>is Text. HTML integration points such asforeignObjectswitch back to HTML. {…}in text and attribute values is aScript. A leading Svelte tag such as{#if,{:else}or{@htmlis skipped.- Angular block headers such as
@if (a) {and} @else {,@letdeclarations, and the syntax of ICU expressions such as{count, plural, =0 {…}}stay Text, so the markup inside them is parsed. - Attribute values have no backslash escapes.
- The content of
<template lang="pug">is parsed as Pug, after removing its common indentation as Vue does.
parsePug
It follows the Pug lexer and parser, and is checked against them on real templates.
- Tags,
.classand#idliterals,(…)attributes,&attributes(…),#{…}tags and+mixin(…)calls are elements and attributes..classis anAttributenamedclasswhose value is an unquotedString. - Attribute values end where the Pug lexer ends them: at a comma or at whitespace after a complete JavaScript expression. A value that is one quoted string is a
String; any other value is aScriptwith the string literals in it. -,=and!=code,#{…}and!{…}interpolations, and the lines ofif,else,unless,case,when,default,each,whileandmixinareScriptnodes. The nodes in their blocks stay in the enclosing element, as with Svelte blocks.- Piped text, text after tags,
.blocks, filters and lines starting with<are Text, with#[…]tags inside them. Unbuffered//-and buffered//comments areCommentnodes, including their blocks. - Inconsistent indentation is reported, and the line becomes a sibling of the more deeply indented lines before it.
parseRazor and parseRazorComponent
They follow the Razor compiler of the .NET SDK rather than browsers, and are checked against it on real views, pages and components.
- Close tags match the nearest open element with the same name. No end tags are implied, and only
scriptholds text, which may still contain@code. @name.Member,@(…),@{…}, control flow such as@if,@foreachand@switch, and directives such as@model,@sectionand@codeareScriptnodes. The markup inside code blocks,@<p>…</p>templates and<text>elements is in theirelements, and the C# string literals, including interpolated, verbatim and raw strings, in theirstrings.@@and email addresses such asa@b.comstay Text.parseRazorreads@where an attribute name goes as C# code, anAttributewith the name"".parseRazorComponentreads it as a directive attribute such as@onclickor@bind-Value.
Scripts
parseJsx, parseScript, and the script regions of parseHtml are scanned rather than parsed. To tell a tag from a comparison, a regular expression from a division, and the end of a statement, the scanner follows the rules of the TypeScript parser:
- Operands and keywords:
return <p/>has a tag, whilea < bandf<T>(x)do not.x = a / 2divides, whileif (a) /re/.test(b)has a regular expression. - Automatic semicolon insertion, such as a line break after
var a,break, a function overload orimport … from "x". - Where
awaitandyieldare keywords: async functions, generators, methods, arrow functions, class bodies, and the top level of modules. - TypeScript: type parameters such as
<T,>, type assertions withasandsatisfies, type annotations,typealiases, interfaces, overloads and decorators.
The scanner is checked against the TypeScript parser on real code from node_modules, on the same code with probes inserted at every statement and operand, and on generated programs.
Limitations
- Syntax that depends on the file type is decided without it. At the top level of
parseHtmlscripts,awaitis a keyword, as in modules.a < b > /c/follows JavaScript, where TypeScript reads an instantiation expression. parseJsxreads<T>valueas a JSX tag, as the TypeScript parser does in.tsxfiles. UseparseScriptfor.tsfiles.- Razor code inside
<!-- … -->,<!…>,<?…?>and<![CDATA[…]]>is part of theCommentnode, although the Razor compiler still reads it as code. In code the compiler rejects,#defineis not applied to later#ifdirectives, and a#directive right after markup on the same line can be read differently. - In Pug attributes and
#{…}, a/inside a regular expression's character class ends the regular expression, as in Pug:p(a=/[/]/.test(x))fails in Pug with "Mismatched Bracket: ]", andparsePugreports the same error. Write/[\/]/instead. Where that misreading still leaves the brackets balanced, Pug checks the value with a JavaScript parser whileparsePugjudges it from its last token, so the value can end at a different place. - Very deep nesting, such as thousands of nested blocks, is reported as "The input is nested too deeply." instead of parsed. The TypeScript parser itself overflows at a smaller depth.
Related Packages
Organize attributes automatically in your HTML like files with Prettier
Auto-generate framework wrappers (React, Vue, Svelte, Angular, Solid, Preact) an...
Comark is an open-source Markdown parser and renderer with components, attribute...
Version History
| Version | Released | License |
|---|---|---|
| 1.0.1 | MIT | |
| 1.0.0 | MIT | |
| 0.2.5 | MIT |