<!doctype html>
<html>
<head>
<title>HTML containment</title>
<script>
if (!Date.now) { Date.now = function () { return +new Date; }; }
</script>
<script src="html-containment.js"></script>
<script>
// Extract URL query parameters into options
var opts = {
// use a short list for quick iteration and debugging
shortlist: false,
rerun: false
};
var cannedData;
(function () {
location.search.replace(
/[?&]([^&=]*)(?:=(?:false|no|([^&]*))(?![^&]))?/ig,
function (_, keyEncoded, valueEncoded) {
var key = decodeURIComponent(keyEncoded);
var value = valueEncoded == null ? "true"
: decodeURIComponent(valueEncoded);
opts[key] = value;
});
if (opts.rerun) {
cannedData = newBlankObject();
} else {
document.write('<script src="canned-data.js"><\/script>');
}
})();
</script>
<script>
// Includes both conforming and obsolete elements from
// http://dev.w3.org/html5/html-author/#index-of-elements
// It does not include foreign content.
var elementNames =
opts.shortlist
? [
'a', 'font', 'form', 'frameset', 'h1', 'h2', 'iframe',
'img', 'li', 'ol', 'plaintext', 'script', 'select', 'table', 'tbody',
'textarea', 'td', 'tr', 'video', 'xmp'
]
: [
'a', 'abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside',
'audio', 'b', 'base', 'basefont', 'bb', 'bdo', 'bgsound', 'big', 'blink',
'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite',
'code', 'col', 'colgroup', 'command', 'datagrid', 'datalist', 'dd', 'del',
'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'em', 'embed',
'fieldset', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1',
'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hr', 'html', 'i', 'iframe',
'img', 'input', 'ins', 'isindex', 'kbd', 'label', 'legend', 'li', 'link',
'listing', 'map', 'mark', 'marquee', 'menu', 'meta', 'meter', 'nav', 'nobr',
'noembed', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option',
'output', 'p', 'param', 'plaintext', 'pre', 'progress', 'q', 'rp', 'rt',
'ruby', 's', 'samp', 'script', 'section', 'select', 'small', 'source',
'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'sup', 'table',
'tbody', 'td', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr',
'tt', 'u', 'ul', 'var', 'video', 'wbr', 'xmp',
'xcustom'
];
</script>
<style>
pre.json { white-space: pre-wrap }
.json-kw { color: #800 }
.json-str { color: #080 }
.json-val { color: #008 }
.json-sep { background: white }
.json-ell { color: blue } /* ellipses are linky */
/* Collapse inner blocks except on roll-over. */
.json-int { display: none }
.json-ext.json-expanded > .json-int,
.json-ext.json-nocollapse > .json-int { display: inline }
.json-ext.json-nocollapse > .json-ell { display: none }
.json-ext.json-expanded > .json-ell { color: transparent }
#experiment-progress-counter:empty { display: none }
#experiment-progress-counter {
width: 25em;
display: block;
list-style-type: none;
-webkit-padding-start: 0;
}
div #experiment-progress-counter:empty {
border-width: 0px solid black;
padding: 0 0 0 0;
}
div #experiment-progress-counter {
border:1px solid black;
padding: 0 0 2px 2px;
}
#experiment-progress-counter li {
display: block;
border: 1px solid black;
padding: 2px;
margin-top: 2px;
height: 1em;
background: #ddf;
white-space: nowrap;
font-size:8pt;
}
#experiment-iframes iframe {
visibility:hidden;
width:40em;
height:1em;
}
em { color: #fff; font-weight: bold; background: #800; border: 1px solid #800; padding: 1px }
</style>
</head>
<body>
<p>
This page tries to exhaustively combine tags for all pairings of HTML elements
to answer the following questions about how HTML browsers parse tag soup:</p>
<ul>
<li><a href="#nests-in-body">Which elements can appear directly in the body of an HTML document?</ad></li>
<li><a href="#can-contain">Which elements can nest directly in which other elements?</a></li>
<li><a href="#text-content-model">Which elements can contain text content, comments, entities?</a></li>
<li><a href="#containment-stack-json">Which elements can be introduced between the body and an element
to allow it to nest properly?</a></li>
<li>Which elements are implied by which tags? (TODO)</li>
<li><a href="#explicit-closers">Which open tags close which other elements?</a></li>
<li><a href="#closed-by-close">Which close tags close which elements?</a></li>
<li><a href="#closed-by-open">Which open tags close which elements?</a></li>
</ul>
<p>A <a href="#result-dump">JSON dump</a>
of the results is available at the end once running is done.</p>
<div><ul id="experiment-progress-counter"></ul></div>
<p>A few query parameters affect the behavior of this page:</p>
<ul>
<li><a href="?rerun"><tt><span class="basename"></span>?rerun</tt></a> —
<em style="font-size:66%">¡VERY SLOW!</em>
Rerun experiments on the browser intead of using the canned results from Chrome.
<li><a href="?rerun&shortlist"><tt><span class="basename"></span>?rerun&shortlist</tt></a> —
Rerun experiments on the browser instead of using the canned results from Chrome,
but with a short list of elements instead of the full 128+ HTML elements
which speeds debugging.</li>
<li><a href="?"><tt><span class="basename"></span>?</tt></a> —
Quick browsing of canned results from Chrome.</li>
</ul>
<script>(function () {
var basename = location.pathname.replace(/^[\s\S]*\//, '');
function toCss(s) {
return ('\x22'
+ s.replace(/[^\w\-.]/g, function (c) {
return '\\' + c.charCodeAt(0).toString(16) + ' ';
})
+ '\x22');
}
document.write('<style>.basename:after { content: ' + toCss(basename) + ' }<\/style>');
}());</script>
<!-- Contains iframes that are used to parse HTML since innerHTML parsing differs
from regular parsing in many respects. -->
<div id="experiment-iframes"></div>
<h2 id="nests-in-body">Nests in body</h2>
<p>Does a tag <tt><X></tt> directly inside
<tt><body>…</body></tt> parse to an element named X
directly inside the document body?</p>
<pre id="nests-in-body-json" class="json"></pre>
<script>
var canAppearInBody = getOwn(cannedData, 'canAppearInBody') || new Promise();
(function () {
// Generates HTML for the experiment.
function nestInBody(elementName) {
return '<' + elementName + '></' + elementName + '>';
}
// Examines the resulting body to fold a single experiment into the result.
function isNestedInBody(elementName, body, result) {
result[elementName] = !!(
body.firstChild && body.firstChild.nodeName.toLowerCase() === elementName
);
return result;
}
// When the experiment is finished, replace the promise so that we can
// kick off experiments that depend on the result of this experiment.
function finish(result) {
var toSatisfy = canAppearInBody;
if (toSatisfy instanceof Promise) {
canAppearInBody = result;
toSatisfy.satisfy();
}
displayJson(result, document.getElementById('nests-in-body-json'))
}
if (canAppearInBody instanceof Promise) {
runExperiment(nestInBody, isNestedInBody, newBlankObject(), finish);
} else {
finish(canAppearInBody);
}
}());
</script>
<h2 id="can-contain">Containment</h2>
<p>For each element, what elements can contain it?</p>
<p>E.g., <code>canAppearIn['x'].indexOf('y') >= 0</code> when
<code><x><y></y></x></code> parses to
an element <tt>x</tt> that contains an element <tt>y</tt> when embedded
in an element that can contain <code><x></code>.</p>
<h3>Can Contain</h3>
<pre class="json" id="can-contain-json"></pre>
<h3>Can Appear In</h3>
<pre class="json" id="can-appear-in-json"></pre>
<h3>Containment stack</h3>
<pre class="json" id="containment-stack-json"></pre>
<script>
// We use promises to allow experiment chaining where one
// experiment depends on the results of another.
var canContain = getOwn(cannedData, 'canContain') || new Promise();
var canAppearIn = getOwn(cannedData, 'canAppearIn') || new Promise();
// For a given element name, give a stack of elements that can
// be validly embedded in body that have the element at the top.
var containmentStackFor = new Promise();
// HTML for the elements in the with the body HTML inside the
// top-most element.
function tagStackToHtml(stack, body) {
var stackReverse = stack.slice();
stackReverse.reverse();
return (
'<' + stack.join('><') + '>'
+ body
+ '</' + stackReverse.join('></') + '>'
);
}
(function () {
var nNeededLast = Infinity;
// We need a function that tells us which elements we need to have on the
// open element stack so that we can get the outer element on the stack to
// test whether an inner tag leads to an inner element inside it.
// For example, to test whether an <a> tag nestes properly in a <td>, we
// need to construct <table><tbody><tr><td><a>.
//
// Knowing what needs to be on the open element stack for <td> requires
// knowing what needs to be on the open element stack for <tr>.
function containmentStackMaker(canAppearIn) {
var memoTable = newBlankObject();
return function (elementName, opt_exclusions) {
var memoKey = opt_exclusions
? elementName + ' ' + opt_exclusions.join(' / ') : elementName;
if (getOwn(canAppearInBody, elementName)) { return [elementName]; }
var prior = getOwn(memoKey, elementName, void 0);
if (prior !== void 0) { return prior ? prior.slice() : null; }
var empty = [];
function end(e) {
return getOwn(canAppearInBody, e, false);
}
function eq (e, f) { return e === f; }
function neighbors(e) {
var neighbors = getOwn(canAppearIn, e, empty);
if (opt_exclusions) {
var exclusions = makeSet(opt_exclusions);
var included = null;
for (var i = 0, n = neighbors.length; i < n; ++i) {
var neighbor = neighbors[i];
if (inSet(exclusions, neighbor)) {
if (!included) { included = neighbors.slice(0, i); }
} else if (included) {
included.push(neighbor);
}
}
if (included) { neighbors = included; }
}
return neighbors;
}
var result = breadthFirstSearch(elementName, end, eq, neighbors) || null;
memoTable[memoKey] = result;
return result ? result.slice() : null;
};
}
function run(result) {
function makeContainerHtmlString(outer, inner) {
if (neededSet[outer] !== neededSet) { return null; }
// We try to assemble a stack of elements that can contain outer before
// checking whether it can contain inner.
// If we cannot, we punt so that we can retry later after we've fleshed
// out more of canAppearIn.
var stack = containmentStack(outer);
if (!stack) { return null; }
stack.push(inner);
return tagStackToHtml(stack, '');
}
function checkCanContain(outer, inner, body, canContain) {
var outerEls = body.getElementsByTagName(outer);
if (outerEls.length) {
var containees = getOwn(canContain, outer) || [];
canContain[outer] = containees;
var outerEl = outerEls[0];
var firstChild = outerEl.firstChild;
if (((firstChild && firstChild.nodeName.toLowerCase() === inner)
|| outerEl.getElementsByTagName(inner).length)
&& containees.indexOf(inner) < 0) {
containees.push(inner);
}
}
return canContain;
}
var elementNamesNeeded = [];
for (var i = 0, n = elementNames.length; i < n; ++i) {
var elementName = elementNames[i];
if (!Object.hasOwnProperty.call(result, elementName)) {
elementNamesNeeded.push(elementName);
}
}
console.log('nNeededLast=%s, nNeeded=%d, result=%o',
nNeededLast, elementNamesNeeded.length, result);
if (elementNamesNeeded.length === nNeededLast) {
// We made no progress last run.
console.log('cannot place ' + elementNamesNeeded);
elementNamesNeeded.length = 0;
}
var containmentStack = containmentStackMaker(reverseMultiMap(result));
var neededSet = newBlankObject();
for (var i = elementNamesNeeded.length; --i >= 0;) {
neededSet[elementNamesNeeded[i]] = neededSet;
}
if (elementNamesNeeded.length) {
nNeededLast = elementNamesNeeded.length;
return runExperiment(
makeContainerHtmlString, checkCanContain, result, run,
elementNames);
} else {
finishCanContain(result);
return result;
}
}
function finishCanContain(result) {
var toSatisfy = canContain;
if (toSatisfy instanceof Promise) {
canContain = sortedMultiMap(result);
toSatisfy.satisfy();
}
displayJson(canContain, document.getElementById('can-contain-json'));
}
if (canContain instanceof Promise) {
when(function () { run(newBlankObject()); }, canAppearInBody);
} else {
finishCanContain(canContain);
}
function reverseMap() {
var toSatisfy = canAppearIn;
if (toSatisfy instanceof Promise) {
canAppearIn = sortedMultiMap(reverseMultiMap(canContain));
toSatisfy.satisfy();
}
displayJson(canAppearIn, document.getElementById('can-appear-in-json'));
toSatisfy = containmentStackFor;
containmentStackFor = containmentStackMaker(canAppearIn);
toSatisfy.satisfy();
}
when(function () { reverseMap(); }, canContain);
function mapStacks() {
var containmentStackMap = newBlankObject();
for (var i = 0, n = elementNames.length; i < n; ++i) {
var elementName = elementNames[i];
var stack = containmentStackFor(elementName);
if (stack) { --stack.length; }
containmentStackMap[elementName] = stack;
}
displayJson(containmentStackMap,
document.getElementById('containment-stack-json'));
}
when(mapStacks, containmentStackFor);
}());
</script>
<h2 id="text-content-model">Text and comment content</h2>
<p>Tests which elements can contain a non-whitespace text node and which can
contain comments or other non-text elements as a result of parsing.</p>
<p><code>textContentModel['x'].text</code> is true when
<code><x>text</x></code> parses to an X element containing
a text node.</p>
<p><code>textContentModel['x'].comments</code> is true when
<code><x><!--comment--></x></code> parses to an X element
containing a comment node.</p>
<p><code>textContentModel['x'].xml</code> is true when
<code><x>&amp;<![[CDATA&]]>;</x></code> parses to an X
element contains text nodes that normalize to <code>&&</code>.</p>
<p><code>textContentModel['x'].raw</code> is true when
<code><x><br></x></code> parses to an X element
containing a text node.</p>
<p><code>textContentModel['x'].entities</code> is true when
<code><x>&amp;;</x></code> parses to an X element
containing a text node <tt>&amp;</tt>.</p>
<pre class="json" id="text-content-model-json"></pre>
<script>
var textContentModel = getOwn(cannedData, 'textContentModel') || new Promise();
(function () {
function run() {
function makeHtmlStringWithText(elementName) {
var stack = containmentStackFor(elementName);
if (stack == null) { return null; }
return tagStackToHtml(
stack, '/*1&2<![CDATA[&]]>3<!---->4<br>5*/');
}
function checkText(elementName, body, result) {
var el = body.getElementsByTagName(elementName)[0];
var text = innerTextOf(el);
var model = newBlankObject();
switch (text) {
case '':
if (elementContainsComment(el)) { model.comments = true; }
break;
case '/*1&2345*/': // CDATA section treated as "bogus comment"
model.text = model.entities = model.comments = true;
break;
case '/*1&2&345*/': // CDATA section treated as per XML
model.text = model.entities = model.xml = model.comments = true;
break;
case '/*1&2<![CDATA[&]]>3<!---->4<br>5*/': // '<' is raw
model.text = model.entities = model.raw = true;
break;
case '/*1&2<![CDATA[&]]>3<!---->4<br>5*/': // '<' and '&' raw
model.text = model.raw = true;
break;
case '/*1&2<![CDATA[&]]>3<!---->4<br>5*/</' + elementName + '>':
// </plaintext> does not close <plaintext>
model.text = model.raw = model.unended = true;
break;
default:
console.log('unexpected text `%s` in %s', text, elementName);
}
result[elementName] = sortedMultiMap(model);
return result;
}
runExperiment(makeHtmlStringWithText, checkText, newBlankObject(), finish);
}
function finish(result) {
var toSatisfy = textContentModel;
if (toSatisfy instanceof Promise) {
textContentModel = sortedMultiMap(result);
toSatisfy.satisfy();
}
displayJson(textContentModel,
document.getElementById('text-content-model-json'));
}
if (textContentModel instanceof Promise) {
when(run, containmentStackFor);
} else {
finish(textContentModel);
}
}());
</script>
<h2>Tag Closers</h2>
<h3 id="explicit-closers">Explicit closers</h3>
<p>Are there any close tags besides the tag name itself that close the tag?</p>
<pre class="json" id="explicit-closers-json"></pre>
<script>
var explicitClosers = getOwn(cannedData, 'explicitClosers') || new Promise();
(function () {
function run() {
var contentForElement = newBlankObject();
function nestableContent(openTag, excludedTag) {
var content = getOwn(contentForElement, openTag);
if (content === undefined) {
var tcm = getOwn(textContentModel, openTag);
if (tcm && tcm.text) {
content = '#text';
} else {
content = getOwn(canContain, openTag, null);
}
contentForElement[openTag] = content;
}
// arrays are element names
if (content instanceof Array) {
for (var i = 0, n = content.length; i < n; ++i) {
var tag = content[i];
if (tag === openTag || tag === excludedTag) { continue; }
return tag;
}
return null;
} else {
return content;
}
}
function makeHtmlString(openTag, closeTag) {
if (openTag === closeTag) { return null; }
var stack = containmentStackFor(openTag, [closeTag]);
if (stack == null) { return null; }
if (closeTag === 'body' || closeTag === 'html') {
return null;
}
var content = nestableContent(openTag, closeTag);
if (content === null) { return null; }
if (content !== '#text') {
content = '<' + content + '></' + content + '>';
}
return tagStackToHtml(stack, '</' + closeTag + '>' + content);
}
function check(openTag, closeTag, body, result) {
var content = nestableContent(openTag, closeTag);
var element = body.getElementsByTagName(openTag)[0];
if (element) {
var closed = (content === '#text')
? innerTextOf(element) === ''
: !element.getElementsByTagName(content).length;
if (closed) {
var closeTags = getOwn(result, openTag) || [];
closeTags.push(closeTag);
result[openTag] = closeTags;
}
}
return result;
}
runExperiment(makeHtmlString, check, newBlankObject(), finish);
}
function finish(result) {
var toSatisfy = explicitClosers;
if (toSatisfy instanceof Promise) {
explicitClosers = sortedMultiMap(result);
toSatisfy.satisfy();
}
displayJson(explicitClosers,
document.getElementById('explicit-closers-json'));
}
if (explicitClosers instanceof Promise) {
when(run, containmentStackFor, textContentModel);
} else {
finish(explicitClosers);
}
}());
</script>
<h3 id="closed-by-open">Open tags close which elements</h3>
<p>Which open tags close the element when embedded between it and content it
could otherwise contain?</p>
<p>Which <code>C</code> close <code>T</code> in
<code><T><C>X</C></T></code>
leading to X being a sibling of the element T instead of its child as it would
be if parsed as <code><T>X</T></code>.
<pre class="json" id="closed-by-open-json"></pre>
<script>
var closedOnOpen = getOwn(cannedData, 'closedOnOpen') || new Promise();
(function () {
function run() {
function makeHtmlString(outer, inner) {
if (textContentModel[outer] && textContentModel[outer].comments
&& !(textContentModel[inner] && textContentModel[inner].unended)) {
var stack = containmentStackFor(outer, [inner]);
if (stack) {
// <outer><inner></inner><!--After inner --></outer>
return tagStackToHtml(
stack, '<' + inner + '></' + inner + '><!-- After inner -->');
}
}
return null;
}
function check(outer, inner, body, result) {
var outerEl = body.getElementsByTagName(outer)[0];
var hasComment = elementContainsComment(outerEl);
var closers = getOwn(result, outer) || [];
if (!hasComment) {
closers.push(inner);
}
result[outer] = closers;
return result;
}
runExperiment(makeHtmlString, check, newBlankObject(), finish);
}
function finish(result) {
var toSatisfy = closedOnOpen;
if (toSatisfy instanceof Promise) {
closedOnOpen = sortedMultiMap(result);
toSatisfy.satisfy();
}
displayJson(closedOnOpen,
document.getElementById('closed-by-open-json'));
}
if (closedOnOpen instanceof Promise) {
when(run, containmentStackFor, textContentModel, canContain);
} else {
finish(closedOnOpen);
}
}());
</script>
<h3 id="closed-by-close">Close tags close which elements</h3>
<p>Which <code>C</code> close <code>T</code> in
<code><C><T></C>X</T></code>
leading to X being a sibling of the element T instead of its child as it
would be if parsed as <code><T>X</T></code>.
<pre class="json" id="closed-by-close-json"></pre>
<script>
var closedOnClose = getOwn(cannedData, 'closedOnClose') || new Promise();
(function () {
function run() {
function makeHtmlString(outer, inner) {
var outerTc = textContentModel[outer];
var innerTc = textContentModel[inner];
if (outerTc && innerTc && outerTc.comments && innerTc.comments) {
var stack = containmentStackFor(outer, [inner]);
if (stack) {
--stack.length; // strip outer.
// <outer><inner></outer><!--X--></inner>
return tagStackToHtml(
stack,
'<' + outer + '><' + inner + '>'
+ '</' + outer + '><!--X--></' + inner + '>');
}
}
return null;
}
function check(outer, inner, body, result) {
var innerEl = body.getElementsByTagName(inner)[0];
var closers = getOwn(result, inner) || [];
if (!elementContainsComment(innerEl)) {
closers.push(outer);
}
result[inner] = closers;
return result;
}
runExperiment(makeHtmlString, check, newBlankObject(), finish);
}
function finish(result) {
var toSatisfy = closedOnClose;
if (toSatisfy instanceof Promise) {
closedOnClose = sortedMultiMap(result);
toSatisfy.satisfy();
}
displayJson(closedOnClose,
document.getElementById('closed-by-close-json'));
}
if (closedOnClose instanceof Promise) {
when(run, containmentStackFor, textContentModel, canContain);
} else {
finish(closedOnClose);
}
}());
</script>
<h2 id="result-dump">JSON Dump</h2>
<p id="working"><em>working</em></p>
<script>
var fullJson = {
"canAppearInBody": canAppearInBody,
"canContain": canContain,
"canAppearIn": canAppearIn,
"containmentStackFor": containmentStackFor,
"textContentModel": textContentModel,
"explicitClosers": explicitClosers,
"closedOnOpen": closedOnOpen,
"closedOnClose": closedOnClose
};
(function () {
function run() {
for (var k in fullJson) {
if (fullJson.hasOwnProperty(k)) {
fullJson[k] = window[k];
}
}
var textarea = document.createElement('textarea');
textarea.setAttribute('cols', '80');
textarea.setAttribute('rows', '20');
textarea.setAttribute('readonly', 'readonly');
textarea.onclick = function () { textarea.select(); };
textarea.value = JSON.stringify(fullJson);
var resultDumpHeader = document.getElementById('result-dump');
resultDumpHeader.parentNode.insertBefore(
textarea, resultDumpHeader.nextSibling);
var workingNote = document.getElementById('working');
workingNote.parentNode.removeChild(workingNote);
}
var whenArgs = [run];
for (var k in fullJson) {
if (fullJson.hasOwnProperty(k)) {
whenArgs.push(fullJson[k]);
}
}
when.apply(null, whenArgs);
}());
</script>
</body>
</html>