|$ curl https://forge-ai.dev/api/markdown?path=docs/html/parsing
$cat docs/html-parsing-&-the-dom-tree.md
updated Today·36 min read·published

HTML Parsing & the DOM Tree

HTMLParsingDOMAdvancedAdvanced🎯Free Tools
Introduction

HTML is not XML. The browser runs a forgiving, stateful parser defined by the WHATWG HTML Standard. It tokenizes bytes into start tags, end tags, text, comments, and DOCTYPEs, then builds a DOM tree with detailed rules for mis-nested markup, scripts, tables, and foreign content (SVG/MathML).

Understanding parsing helps you debug “why DevTools shows a different tree than my source,” use innerHTML safely, sanitize untrusted markup, and validate documents. This guide is practical: enough algorithm detail to reason about bugs, without reprinting the entire spec.

The parsing pipeline

Bytes → character decoding → tokenizer → tree builder → DOM. Encoding detection uses BOM, HTTP Content-Type, and early meta charset. After the tree exists, scripts may block and re-enter the parser.

StageInputOutput
DecodeBytesCode points
TokenizeCode pointsTokens
Tree constructTokensDOM nodes
ScriptingDOM + external JSPossible document.write reentry
untitled.text
TEXT
1HTTP response (text/html; charset=utf-8)
2
3
4 Encoding sniffer / decoder
5
6
7 Tokenizer (state machine)
8
9
10 Tree construction (insertion modes)
11
12
13 Document + Element + Text nodes
📝

note

application/xhtml+xml uses an XML parser instead — different error handling (fatal) and no HTML recovery. Most sites use text/html.
Tokenization

The tokenizer is a state machine. Important states include data, RCDATA (title/textarea), RAWTEXT (script/style historically nuanced), plaintext, tag open, attribute states, and character reference states.

Token typeMeaning
DOCTYPEDocument mode switch
Start tagElement open + attributes
End tagElement close
CommentComment node
CharacterText (may be batched)
EOFEnd of stream
tokenize-notes.html
HTML
1<!-- Character references resolved in many states -->
2<title>Fish &amp; Chips</title>
3
4<!-- Script data does not process entities the same way as body text -->
5<script>
6 // const x = '&lt;'; // is NOT converted to < inside classic script text
7</script>

info

When debugging unexpected text, check whether you are in a RAWTEXT/RCDATA element — rules differ from normal body text.
Tree construction

Tree construction maintains a stack of open elements and an insertion mode. Start tags create elements and push them; end tags pop; some tags imply closing others (p before block, li before li, etc.).

tree.html
HTML
1<!-- Author -->
2<p>One<div>Two</div>Three
3
4<!-- Typical result -->
5<p>One</p><div>Two</div>Three
6
7<!-- List re-parenting -->
8<ul><li>A<li>B</ul>
9<!-- → two li children, implied end tags -->

best practice

Write the tree you want. Treat parser repair as emergency recovery, not an authoring language.
Foster parenting (practical)

Table content models are strict. When the parser finds nodes that cannot live where they were written inside a table context, it foster parents them — typically inserting the node as a sibling before the table (or into a foster parent element). This is why random <div> tags inside <table> “jump out” in DevTools.

foster.html
HTML
1<table>
2 <div class="note">This text is not a table cell.</div>
3 <tr>
4 <td>Cell</td>
5 </tr>
6</table>
7
8<!-- DevTools often shows roughly:
9<div class="note">...</div>
10<table>
11 <tbody><tr><td>Cell</td></tr></tbody>
12</table>
13-->
foster-text.html
HTML
1<!-- Also surprising: text nodes directly in table -->
2<table>
3 Hello
4 <tr><td>X</td></tr>
5</table>
preview

warning

CSS frameworks and CMS templates that wrap rows in divs break tables. Use valid table children (caption, colgroup, thead/tbody/tfoot, tr) only.
🔥

pro tip

Foster parenting is also why some XSS filters that only scan inside <table> miss markup that escapes to the parent.
Script parsing

Classic <script> elements switch the tokenizer into a script data state. The content is not parsed as HTML tags (except the script end tag algorithm). External scripts with src still create a script element; fetching/execution interacts with defer/async/module.

KindParser behavior
Inline classicScript data until </script>; then execute (legacy sync rules)
src + defaultFetch, blocking depending on position/attrs
deferOrder-preserving, runs after document parse
asyncRuns when ready, unordered
type=moduleDeferred module graph; CORS for cross-origin
document.writeCan inject tokens into the open stream (avoid)
script.html
HTML
1<script>
2 // To include </script> in a string, break it up:
3 const s = '</' + 'script>';
4</script>
5
6<script type="module" src="/app.js"></script>
document-write.js
JavaScript
1// document.write during parse injects into the stream
2// After parse, document.write clears/overwrites in many cases — do not use it
3document.write('<p>bad idea after load</p>');

danger

document.write is a historical footgun for performance and security. Prefer DOM APIs or frameworks.
noscript

<noscript> content depends on scripting enabled/disabled and on whether it appears in head or body. In head with scripting disabled, it can contain link/style/meta; in body it contains flow content. Authors use it for fallback UI — keep fallbacks minimal.

noscript.html
HTML
1<head>
2 <noscript>
3 <link rel="stylesheet" href="/no-js.css" />
4 </noscript>
5</head>
6<body>
7 <noscript>
8 <p>This app requires JavaScript for interactive features.</p>
9 </noscript>
10</body>
📝

note

Search engines and accessibility users may still execute or ignore noscript differently — do not hide primary content only in noscript.
innerHTML vs DOM APIs

innerHTML and outerHTML invoke HTML fragment parsing (with context element rules). Creating nodes via createElement, textContent, and append does not parse markup — safer for untrusted strings.

APIParses HTML?Scripts execute?
textContent / createTextNodeNoN/A
innerHTMLYes (fragment)script tags inserted via innerHTML do not run
insertAdjacentHTMLYesSame — scripts do not run
document.writeYes (stream)Can run / reenter
Range.createContextualFragmentYesScripts generally do not run
dom-apis.js
JavaScript
1// Safe text
2el.textContent = '<img src=x onerror=alert(1)>';
3
4// Parses HTML — event handlers in markup can still be dangerous
5// depending on browser + how nodes are used; treat as untrusted sink
6el.innerHTML = userHtml;
7
8// Build safely
9const li = document.createElement('li');
10li.textContent = userName;
11list.append(li);

warning

“Scripts do not run via innerHTML” does not mean innerHTML is safe. Inline event handler attributes and nested browsing contexts can still be exploitable. Sanitize or avoid.
DOMParser

DOMParser.parseFromString parses a full document or SVG/XML depending on MIME type. For HTML, use text/html. Errors in XML mode produce parsererror documents.

domparser.js
JavaScript
1const parser = new DOMParser();
2const doc = parser.parseFromString(
3 '<!DOCTYPE html><title>t</title><p>Hello <b>world</b></p>',
4 'text/html'
5);
6console.log(doc.body.textContent); // Hello world
7
8const xml = parser.parseFromString('<a><b></a></b>', 'application/xml');
9console.log(xml.querySelector('parsererror') ? 'invalid xml' : 'ok');

info

Fragment parsing with innerHTML uses a context element; DOMParser builds a whole document. Pick based on whether you need html/head/body scaffolding.
Sanitize considerations

Untrusted HTML must be sanitized before insertion. Allowlists beat denylists. Keep SVG/MathML disabled unless you understand their attack surface. Combine sanitization with CSP.

sanitize.js
JavaScript
1import DOMPurify from 'dompurify';
2
3const clean = DOMPurify.sanitize(userHtml, {
4 USE_PROFILES: { html: true },
5 FORBID_TAGS: ['style'],
6 FORBID_ATTR: ['style'],
7});
8container.innerHTML = clean;
RiskExample
Event handlersonerror=, onclick=
javascript: URLshref="javascript:..."
SVG script<svg><script>
Mutable nested browsingiframe srcdoc
CSS exfiltrationstyle with urls / attrs
mXSSMutation XSS via parser + sanitize order

danger

Sanitizing then serializing then re-parsing can introduce mutation XSS if the sanitizer and browser disagree. Keep versions updated and prefer well-tested libraries.
Quirks vs standards

DOCTYPE selection sets document.compatMode. Quirks mode changes CSS box sizing for some elements, table layouts, and other legacy behaviors. Always ship <!DOCTYPE html>.

quirks.js
JavaScript
1if (document.compatMode === 'BackCompat') {
2 console.warn('Quirks mode — check DOCTYPE');
3}

best practice

Also ensure templates do not print whitespace/BOM before the DOCTYPE.
Validator workflow

Use the Nu Html Checker as part of CI or pre-release checks. Focus on errors that imply wrong trees: stray end tags, unclosed elements, illegal nesting, duplicate IDs.

untitled.text
TEXT
11. Save representative templates (home, article, form, table-heavy)
22. Validate at https://validator.w3.org/nu/
33. Or: vnu --format text dist/**/*.html
44. Triage:
5 - Illegal nesting → fix source structure
6 - Obsolete attributes → replace with CSS/ARIA
7 - Warnings about sectioning → review outline
85. Re-check DOM in DevTools Elements panel
96. Add regression fixtures for parser surprises (tables, p/div)
validate.sh
Bash
1# Example CI idea
2npx vnu-jar --filterfile .vnuignore public/**/*.html
🔥

pro tip

When the validator and browser disagree visually, trust the DOM tree for runtime behavior — but still fix validity for maintainability.
Debugging recipe
untitled.text
TEXT
1Symptom: styles apply to wrong element
2→ Compare source vs Elements panel; look for implied closes
3
4Symptom: table layout broken
5→ Search for div/p inside table; foster parenting
6
7Symptom: extra wrapper or missing wrapper
8→ Check p around blocks; a around interactive; form around form
9
10Symptom: script string broken at </script>
11→ Split string or use module/JSON technique
12
13Symptom: XSS after “escaping”
14→ Wrong context encoder; switch to textContent/sanitizer
preview
Parsing checklist
untitled.text
TEXT
1[ ] DOCTYPE present → standards mode
2[ ] Source nesting matches intended DOM
3[ ] No div/text foster-parented out of tables
4[ ] Scripts are modules/defer; no document.write
5[ ] Untrusted HTML sanitized with allowlist
6[ ] Prefer textContent for plain text
7[ ] Validate key templates in CI
8[ ] Know when to open DevTools and diff trees
Foreign content: SVG and MathML

When the parser encounters <svg> or <math>, it enters foreign content rules. Attribute casing and namespaces differ from HTML. Integration points allow certain HTML elements inside SVG (like foreignObject) to break back into HTML parsing.

foreign.html
HTML
1<div>
2 <svg width="40" height="40" viewBox="0 0 40 40" aria-hidden="true">
3 <circle cx="20" cy="20" r="16" fill="#00FF41" />
4 </svg>
5 <math>
6 <mi>x</mi><mo>=</mo><mn>2</mn>
7 </math>
8</div>
9
10<svg>
11 <foreignObject width="100" height="40">
12 <div xmlns="http://www.w3.org/1999/xhtml">HTML inside SVG</div>
13 </foreignObject>
14</svg>
📝

note

Copy-pasting SVG from design tools sometimes includes XML-only constructs. Re-serialize for HTML or serve as an image if invalid under HTML parsing.
template contents parsing

The <template> element parses its contents into a separate document fragment associated with the template, not as live children of the template element in the usual sense. Scripts inside templates do not run until the content is cloned into a live document.

template-parse.js
JavaScript
1const tpl = document.querySelector('#row');
2console.log(tpl.children.length); // 0
3console.log(tpl.content.childNodes.length); // > 0
4document.querySelector('tbody').append(tpl.content.cloneNode(true));
Misnested formatting: adoption agency

Misnested formatting elements (<b><i></b></i>) trigger the adoption agency algorithm — a recovery strategy that reshuffles formatting elements so the DOM remains usable. You should still fix the source; do not depend on the recovered shape for CSS or JS selectors.

adoption.html
HTML
1<!-- Misnested -->
2<p><b>Bold <i>bold-italic</b> still?</i></p>
3
4<!-- Inspect the DOM — it will not match the source pairing exactly -->

warning

Contenteditable and WYSIWYG editors often emit misnested formatting. Sanitize/normalize on save.
Document stream vs fragment parsing

Loading a page parses a full document stream with DOCTYPE and html/head/body creation. Setting innerHTML uses fragment parsing with a context element — for example, parsing inside a tr allows td tokens that would be illegal at the document root.

fragment-context.js
JavaScript
1const tr = document.createElement('tr');
2tr.innerHTML = '<td>A</td><td>B</td>';
3// Works because context is tr
4
5const div = document.createElement('div');
6div.innerHTML = '<td>A</td>';
7// td may be ignored or repaired — not a reliable cell
🔥

pro tip

Libraries that parse HTML for SSR must choose a context carefully when hydrating partial table markup.
Mapping bugs to spec concepts

When you file a bug or write a regression test, name the concept. Reviewers understand “foster parenting” faster than “div jumped out of table.” Use this map:

Observed bugSpec conceptFix direction
Block breaks out of paragraphImplied end tagsDo not wrap blocks in p
Node appears before tableFoster parentingUse valid table children
Formatting tags reshuffledAdoption agencyNormalize editor HTML
Head tags end up in bodyInsertion mode switchFix early body content
SVG attributes lowercased oddlyForeign contentAdjust SVG authoring

info

Keep a fixtures folder of intentional invalid HTML and the expected DOM snapshot from your target browser engine for regression tests.
$Blueprint — Engineering Documentation·Section ID: HTML-PARSING·Revision: 1.0

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.