Lesson 06 / 25
Tags, Attributes, and Entities
Tag and attribute writing rules, character reference resolution, the difference between text and attribute context, and recovery behavior in flawed markup.
Contents
The previous lesson built the document’s outermost shell and used tags without going into
their syntactic detail. This lesson defines that detail: how a tag is written, which forms
attributes accept, how the < and & characters that appear in text are written, and what
the parser does with writing that breaks the rules.
These rules are not a list to memorize. Each one corresponds to a specific transition in the parser’s state machine, and knowing that transition explains the reason behind unexpected results.
Writing a Tag
An opening tag starts with <, continues with the element name, continues with optional
attributes, and ends with >. A closing tag starts with </ and carries only the name.
Element names are case-insensitive: <P> opens the same element as <p>. The parser
lowercases the name and builds it into the tree in lowercase. Not being case-sensitive does not
mean two forms of writing can be used; a single form is used within a document, and lowercase
is the established form.
Every non-void element has a closing tag. As seen in the previous lesson, not writing part of it gives a defined result, but every unwritten closing tag makes it harder for the reader to parse the document in their own head.
Attributes
An attribute is a name–value pair written inside an opening tag that describes the element.
<img src="slope.jpg" alt="Measurement pole" width="960" height="640">
Attribute names are case-insensitive too and get lowercased. Their order carries no meaning; if the same name is written twice on the same element, the first applies, the second is ignored.
There are three forms of writing a value, and all three are valid:
<td colspan="2"> <!-- double quote --> <td colspan='2'> <!-- single quote --> <td colspan=2> <!-- unquoted -->
The unquoted form cannot contain a space, a quote character, =, <, >, or a backtick.
This constraint produces a trap: in the form class=measurement table, table is counted
as a separate attribute, it does not become part of the value. For this reason values are
always quoted; tracking where the constraint begins is more expensive than just always
applying the rule.
Boolean attributes are a separate class. For these, what carries the meaning is not the value, it is the attribute’s presence.
<input type="checkbox" checked> <input type="checkbox" checked=""> <input type="checkbox" checked="checked">
All three forms mean the same thing: checked. Counter to intuition, checked="false" means
the same thing too — the attribute is written, so it is checked. The only way to turn it off
is to not write the attribute at all.
Character References
Inside text, the character < is taken as the start of a tag, & as the start of a
reference. The way to write these characters as ordinary text is a character reference.
It has three forms: named (<), decimal numeric (<), and hexadecimal numeric
(<).
The script below applies the resolution rule. Named references are, in reality, looked up from a table of close to a couple thousand names; a small subset is used here.
// entities.mjs — resolving character references const NAMED = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", reg: "®" }; const PATTERN = /&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*)(;?)/g; function resolve(text, isAttribute) { return text.replace(PATTERN, (full, body, semi, pos) => { if (body.startsWith("#")) { const hex = body[1] === "x"; return String.fromCodePoint(parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10)); } let name = null; for (let n = body.length; n > 0; n -= 1) { if (body.slice(0, n) in NAMED) { name = body.slice(0, n); break; } } if (name === null) return full; const exactMatch = name.length === body.length && semi === ";"; const rest = body.slice(name.length) + semi; if (exactMatch) return NAMED[name]; const next = rest[0] ?? text[pos + full.length] ?? ""; if (isAttribute && /[=a-zA-Z0-9]/.test(next)) return full; return NAMED[name] + rest; }); } const examples = [ "5 < 8 && 8 > 5", "risen −2°C to 6°C", "code: <p>", "&weather; not in table", ]; for (const example of examples) { console.log(JSON.stringify(example), "->", JSON.stringify(resolve(example, false))); } console.log("--- same string, two contexts ---"); const address = "/measure?type=humidity®count=3"; console.log("as text content :", resolve(address, false)); console.log("as attribute value :", resolve(address, true)); console.log("escaped writing :", resolve("/measure?type=humidity&regcount=3", true));
"5 < 8 && 8 > 5" -> "5 < 8 && 8 > 5" "risen −2°C to 6°C" -> "risen −2°C to 6°C" "code: <p>" -> "code: <p>" "&weather; not in table" -> "&weather; not in table" --- same string, two contexts --- as text content : /measure?type=humidity®count=3 as attribute value : /measure?type=humidity®count=3 escaped writing : /measure?type=humidity®count=3
The first line resolves three named references. The second mixes the decimal and hexadecimal forms of numeric references; both refer to the same set of code points — the code points defined in the Character Encodings lesson of the How Computers Work course. The fourth line shows that a name not in the table is not resolved and is left as is.
The Same String, Two Contexts
The second block of the output makes a rule visible that is missed often. The string
/measure?type=humidity®count=3 breaks, producing the character ®, in text content; it
does not break in an attribute value.
The reason for the difference is a separate rule defined for named references that do not end
in a semicolon: in an attribute value, if the reference is followed by = or a letter/digit,
no resolution is done. This rule exists to prevent addresses inside quotes from silently
breaking — but it only applies in attribute context.
The third line shows the correct way to write it. When an address is written into an attribute
value, the parameter separator is escaped in the form &. Even though the attribute-context
rule makes this escaping unnecessary in most cases, escaping gives uniform behavior instead of
relying on the rule working in “most cases.”
The general rule covers three characters: in text content, < and & are escaped; in an
attribute value, the quote character surrounding the value is escaped as well. This has a
security dimension too, and not doing this escaping when text coming from a user is placed
into the document is the source of the most common web vulnerabilities; the subject is detailed
in later courses.
Recovery Rules
The first lesson said “there is no such output as an invalid document.” The script below makes that claim concrete: it prints the open element stack at every token and shows what is done on writing that breaks the rules.
// recovery.mjs — the open element stack's trace in flawed markup const VOID = new Set(["br", "img", "input", "meta", "link", "hr"]); const CLOSES = { li: ["li"], dt: ["dt", "dd"], dd: ["dt", "dd"], p: ["p"], ul: ["p"], h1: ["p"] }; function trace(text) { const stack = []; console.log("source:", text); for (const m of text.matchAll(/<(\/?)([a-z0-9]+)[^>]*>/g)) { const closing = m[1] === "/"; const name = m[2]; let note = ""; if (!closing) { const implicit = CLOSES[name] ?? []; while (stack.length > 0 && implicit.includes(stack[stack.length - 1])) { note = "(open " + stack.pop() + " closed on its own)"; } if (VOID.has(name)) note = "(void element, not pushed to stack)"; else stack.push(name); } else if (VOID.has(name)) { note = "(void element's closing tag is ignored)"; } else { const position = stack.lastIndexOf(name); if (position === -1) note = "(no matching opening, ignored)"; else { if (position !== stack.length - 1) note = "(" + stack.slice(position + 1).join(", ") + " above it also closes)"; stack.length = position; } } console.log((" " + m[0].padEnd(10) + "stack: [" + stack.join(" > ") + "] " + note).trimEnd()); } if (stack.length > 0) console.log(" end of document left open: [" + stack.join(" > ") + "] -> closed"); console.log(); } trace("<p>Measurement <b>stopped</b></p>"); trace("<p>Measurement <b><i>stopped</b></i>"); trace("<ul><li>first<li>second</ul>"); trace("<p>text</span></p>");
source: <p>Measurement <b>stopped</b></p> <p> stack: [p] <b> stack: [p > b] </b> stack: [p] </p> stack: [] source: <p>Measurement <b><i>stopped</b></i> <p> stack: [p] <b> stack: [p > b] <i> stack: [p > b > i] </b> stack: [p] (i above it also closes) </i> stack: [p] (no matching opening, ignored) end of document left open: [p] -> closed source: <ul><li>first<li>second</ul> <ul> stack: [ul] <li> stack: [ul > li] <li> stack: [ul > li] (open li closed on its own) </ul> stack: [] (li above it also closes) source: <p>text</span></p> <p> stack: [p] </span> stack: [p] (no matching opening, ignored) </p> stack: []
Four rules become visible. A closing tag with no matching opening is ignored. When
elements are closed out of order, the elements above the one being closed close too — in the
second example, the i element closes when b closes, and the </i> that comes after falls
flat. Elements left open at the end of the document are closed. Some elements close on their
own when a sibling opens.
What these rules can together produce may not match the author’s intent. In the second
example, the author wanted the b and i elements to span the same text, but in the end the
i element never spans the word stopped at all. The parser reports no error; the document
is built. This is the reason markup-validating tools are necessary: the flaw is not visible at
parse time, it is visible in the result differing from what was expected.
Comments
A comment is a section starting with <!-- and ending with -->, and it produces a comment
node in the tree. It is not painted, but it stays in the document and is readable. Nothing
written inside it is hidden; server addresses, notes about removed features, and temporary
explanations are sent along with the document.
The sequence -- inside a comment can lead to undefined behavior; for this reason it is not
used as a separator.
Summary
- Element and attribute names are case-insensitive and get lowercased; attribute order carries no meaning, and on a repeated name the first applies.
- Attribute values can be written unquoted too, but the unquoted form ends at a space; for this reason values are always quoted.
- For boolean attributes, what carries the meaning is not the value, it is the presence; the
form
checked="false"turns the attribute on too, and the only way to turn it off is to not write it. - There are three forms of character reference; a named reference not ending in a semicolon gives a different result in text content than in an attribute value.
- The parser does not stop on flawed markup: an unmatched closing tag is ignored, closing out of order also closes the elements above it, and elements left open are closed at the end of the document.
- Comments are sent along with the document and are readable; they carry no hidden information.
Next Step
While resolving character references, this lesson assumed one thing: which rule bytes are turned into characters by was already known. That rule, though, is declared in the document itself, and is guessed at when it is not declared. The next lesson takes up the metadata tags in the head section, and starts with the most important of them: how the character-set declaration decides the way bytes are interpreted.
To keep your progress and take notes, Log in
My notes
Log in to take notes.