Skip to content

Commit

Permalink
Improve parsing of HTML response to support combining OOB and table e…
Browse files Browse the repository at this point in the history
…lements
  • Loading branch information
xhaggi committed Sep 15, 2023
1 parent 55c30b5 commit a4d68d9
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 43 deletions.
142 changes: 104 additions & 38 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,20 +235,6 @@ return (function () {
return matchesFunction && matchesFunction.call(elt, selector);
}

/**
* @param {string} str
* @returns {string}
*/
function getStartTag(str) {
var tagMatcher = /<([a-z][^\/\0>\x20\t\r\n\f]*)/i
var match = tagMatcher.exec( str );
if (match) {
return match[1].toLowerCase();
} else {
return "";
}
}

/**
*
* @param {string} resp
Expand All @@ -273,43 +259,123 @@ return (function () {
return responseNode;
}

function aFullPageResponse(resp) {
return resp.match(/<body/);
}
// Regex for start tags, comment start tag and custom elements start tags
// https://html.spec.whatwg.org/multipage/syntax.html#start-tags
// https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
var PCENChar = '[-.0-9_a-z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\uD800-\uDBFF\uDC00-\uDFFF]';
var START_TAG_REGEX = new RegExp('<((?:[a-z]' + PCENChar + '*\-' + PCENChar + '*)|(?:[a-zA-Z][a-zA-Z0-9]+))[^>]*>|<!--', 'g');

/**
*
* @param {string} resp
* @returns {Element}
*/
function makeFragment(resp) {
var partialResponse = !aFullPageResponse(resp);
var partialResponse = !resp.match(/<body/);
if (htmx.config.useTemplateFragments && partialResponse) {
var documentFragment = parseHTML("<body><template>" + resp + "</template></body>", 0);
// @ts-ignore type mismatch between DocumentFragment and Element.
// TODO: Are these close enough for htmx to use interchangeably?
return documentFragment.querySelector('template').content;
} else {
var startTag = getStartTag(resp);
switch (startTag) {
case "thead":
case "tbody":
case "tfoot":
case "colgroup":
case "caption":
return parseHTML("<table>" + resp + "</table>", 1);
case "col":
return parseHTML("<table><colgroup>" + resp + "</colgroup></table>", 2);
case "tr":
return parseHTML("<table><tbody>" + resp + "</tbody></table>", 2);
case "td":
case "th":
return parseHTML("<table><tbody><tr>" + resp + "</tr></tbody></table>", 3);
case "script":
return parseHTML("<div>" + resp + "</div>", 1);
default:
return parseHTML(resp, 0);
var firstTagIndex = resp.search(START_TAG_REGEX);
if (firstTagIndex === -1) {
// text nodes only
return parseHTML(resp, 0);
}

var fragment = document.createElement('body');
var match;
var nextIndex = -1;

// append leading text nodes
if (firstTagIndex > 0) {
fragment.appendChild(parseHTML(resp.substring(0, firstTagIndex), 1));
}

while ((match = START_TAG_REGEX.exec(resp)) !== null) {
var lastIndex = START_TAG_REGEX.lastIndex;
// skip to next start tag to be processed
if (nextIndex != -1 && lastIndex < nextIndex) {
continue;
}

var tagName = match[1];
var startTag = match[0];
var elementStartIndex = lastIndex - startTag.length;
var endTag = startTag === '<!--' ? '-->' : '</' + tagName + '>';
var endTagIndex = resp.indexOf(endTag, lastIndex);

// append text nodes
if (nextIndex !== -1 && nextIndex < elementStartIndex) {
fragment.appendChild(parseHTML(resp.substring(nextIndex, elementStartIndex), 1));
}

// void element e.g. <img>
if (endTagIndex === -1) {
var nextStartTagIndex = resp.slice(lastIndex).search(START_TAG_REGEX);
var elementEndIndex = nextStartTagIndex !== -1 ? lastIndex + nextStartTagIndex : resp.length;
var elementHtml = resp.substring(elementStartIndex, elementEndIndex);
fragment.appendChild(parseHTML(elementHtml, 1));
nextIndex = elementEndIndex;
continue;
}

var elementEndIndex = endTagIndex + endTag.length;
var elementHtml = resp.substring(elementStartIndex, elementEndIndex);

// move end tag index forward as many times as there are nested elements
var nestedCount = 0;
var li = 1;
while ((li = elementHtml.indexOf('<' + tagName, li)) !== -1) {
nestedCount++;
li++;
}
for (var i = 1; i < nestedCount; i++) {
endTagIndex = resp.indexOf(endTag, elementEndIndex);
if (endTagIndex == -1) {
break;
}
elementEndIndex = endTagIndex + endTag.length;
elementHtml = resp.substring(elementStartIndex, elementEndIndex);
}

var element;
switch (tagName) {
case "thead":
case "tbody":
case "tfoot":
case "colgroup":
case "caption":
element = parseHTML('<table>' + elementHtml + '</table>', 2);
break;
case "col":
element = parseHTML("<table><colgroup>" + elementHtml + "</colgroup></table>", 3);
break;
case "tr":
element = parseHTML("<table><tbody>" + elementHtml + "</tbody></table>", 3);
break;
case "td":
case "th":
element = parseHTML("<table><tbody><tr>" + elementHtml + "</tr></tbody></table>", 4);
break;
case "script":
element = parseHTML("<div>" + elementHtml + "</div>", 2);
break;
default:
element = parseHTML(elementHtml, 1);
}

fragment.appendChild(element);
nextIndex = elementEndIndex;
}

// append trailing text nodes
if (elementEndIndex < resp.length) {
fragment.appendChild(parseHTML(resp.substring(elementEndIndex, resp.length), 1));
}

// @ts-ignore
return fragment;
}
}

Expand Down
36 changes: 36 additions & 0 deletions test/attributes/hx-swap-oob.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,41 @@ describe("hx-swap-oob attribute", function () {
this.server.respond();
should.equal(byId("d1"), null);
});

it('oob swap and table body can be combined', function()
{
this.server.respondWith("GET", "/test", '<div hx-swap-oob="true" id="d1">Clicked</div><tbody id="tb"><tr><td>Row1</td></tr><tr><td>Row2</td></tr></tbody>');

var table = make('<table hx-get="/test"><tbody><tr><td></td></tr></tbody></table>');
make('<div id="d1">Foo</div>')
table.click();
this.server.respond();
byId('tb').innerHTML.should.equal('<tr><td>Row1</td></tr><tr><td>Row2</td></tr>');
byId('d1').innerHTML.should.equal('Clicked');
});

it('oob swap and table rows can be combined', function()
{
this.server.respondWith("GET", "/test", '<div hx-swap-oob="true" id="d1"><div>Clicked</div></div><tr><td>Row1</td></tr><tr><td>Row2</td></tr>');

var table = make('<table hx-get="/test" hx-target="#tb"><tbody id="tb"><tr><td></td></tr></tbody></table>');
make('<div id="d1">Foo</div>')
table.click();
this.server.respond();
byId('tb').innerHTML.should.equal('<tr><td>Row1</td></tr><tr><td>Row2</td></tr>');
byId('d1').innerHTML.should.equal('<div>Clicked</div>');
});

it('oob swap and table columns can be combined', function()
{
this.server.respondWith("GET", "/test", '<div hx-swap-oob="true" id="d1"><div>Clicked</div></div><td>Col1</td><td>Col2</td>');

var table = make('<table hx-get="/test" hx-target="#tr"><tbody><tr id="tr"><td></td></tr></tbody></table>');
make('<div id="d1">Foo</div>')
table.click();
this.server.respond();
byId('tr').innerHTML.should.equal('<td>Col1</td><td>Col2</td>');
byId('d1').innerHTML.should.equal('<div>Clicked</div>');
});
});

17 changes: 12 additions & 5 deletions test/core/internals.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,17 @@ describe("Core htmx internals Tests", function() {

//NB - the tag name should be the *parent* element hosting the HTML since we use the fragment children
// for the swap
htmx._("makeFragment")("<td></td>").tagName.should.equal("TR");
htmx._("makeFragment")("<thead></thead>").tagName.should.equal("TABLE");
htmx._("makeFragment")("<col></col>").tagName.should.equal("COLGROUP");
htmx._("makeFragment")("<tr></tr>").tagName.should.equal("TBODY");
htmx._("makeFragment")("<td></td>").firstElementChild.tagName.should.equal("TD");
htmx._("makeFragment")("<thead></thead>").firstElementChild.tagName.should.equal("THEAD");
htmx._("makeFragment")("<col></col>").firstElementChild.tagName.should.equal("COL");
htmx._("makeFragment")("<tr></tr>").firstElementChild.tagName.should.equal("TR");
})

it("makeFragment works with elements before table elements", function(){
var fragment = htmx._("makeFragment")("<div></div><td></td>");
fragment.children[0].tagName.should.equal("DIV");
fragment.children[1].tagName.should.equal("BR");
fragment.children[2].tagName.should.equal("TD");
})

it("makeFragment works with template wrapping", function(){
Expand Down Expand Up @@ -141,4 +148,4 @@ describe("Core htmx internals Tests", function() {
(value instanceof FormData).should.equal(true);
})

});
});

0 comments on commit a4d68d9

Please sign in to comment.