mirror of
https://github.com/adobe/brackets.git
synced 2024-11-20 18:02:54 +01:00
Breaks HTMLInstrumentation into three files.
HTMLInstrumentation: handles the interface between editor/document and live HTML. HTMLSimpleDOM: Parses HTML into a simple DOM-like structure with properties useful for HTMLInstrumentation. HTMLDiff: Generates a list of edits to mutate a Simple DOMs into another.
This commit is contained in:
parent
cc42e19659
commit
f2aa82c858
600
src/language/HTMLDiff.js
Normal file
600
src/language/HTMLDiff.js
Normal file
@ -0,0 +1,600 @@
|
||||
/*
|
||||
* Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved.
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
|
||||
/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */
|
||||
/*global define, $, CodeMirror */
|
||||
/*unittests: HTML Instrumentation*/
|
||||
|
||||
define(function (require, exports, module) {
|
||||
"use strict";
|
||||
|
||||
function generateAttributeEdits(edits, oldNode, newNode) {
|
||||
// shallow copy the old attributes object so that we can modify it
|
||||
var oldAttributes = $.extend({}, oldNode.attributes),
|
||||
newAttributes = newNode.attributes;
|
||||
Object.keys(newAttributes).forEach(function (attributeName) {
|
||||
if (oldAttributes[attributeName] !== newAttributes[attributeName]) {
|
||||
var type = oldAttributes.hasOwnProperty(attributeName) ? "attrChange" : "attrAdd";
|
||||
edits.push({
|
||||
type: type,
|
||||
tagID: oldNode.tagID,
|
||||
attribute: attributeName,
|
||||
value: newAttributes[attributeName]
|
||||
});
|
||||
}
|
||||
delete oldAttributes[attributeName];
|
||||
});
|
||||
Object.keys(oldAttributes).forEach(function (attributeName) {
|
||||
edits.push({
|
||||
type: "attrDelete",
|
||||
tagID: oldNode.tagID,
|
||||
attribute: attributeName
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the parent tag ID of a SimpleDOM node.
|
||||
*
|
||||
* @param {Object} node SimpleDOM node for which to look up parent ID
|
||||
* @return {int?} ID or null if there is no parent
|
||||
*/
|
||||
function getParentID(node) {
|
||||
return node.parent && node.parent.tagID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a list of edits that will mutate oldNode to look like newNode.
|
||||
* Currently, there are the following possible edit operations:
|
||||
*
|
||||
* * elementInsert
|
||||
* * elementDelete
|
||||
* * elementMove
|
||||
* * textInsert
|
||||
* * textDelete
|
||||
* * textReplace
|
||||
* * attrDelete
|
||||
* * attrChange
|
||||
* * attrAdd
|
||||
* * rememberNodes (a special instruction that reflects the need to hang on to moved nodes)
|
||||
*
|
||||
* @param {Object} oldNode SimpleDOM node with the original content
|
||||
* @param {Object} newNode SimpleDOM node with the new content
|
||||
* @return {Array.{Object}} list of edit operations
|
||||
*/
|
||||
function domdiff(oldNode, newNode) {
|
||||
var queue = [],
|
||||
edits = [],
|
||||
matches = {},
|
||||
elementInserts = {},
|
||||
textInserts = {},
|
||||
textChanges = {},
|
||||
elementsWithTextChanges = {},
|
||||
currentElement,
|
||||
oldElement,
|
||||
moves = [],
|
||||
elementDeletes = {},
|
||||
oldNodeMap = oldNode ? oldNode.nodeMap : {};
|
||||
|
||||
/**
|
||||
* When the main loop (see below) determines that something has changed with
|
||||
* an element's immediate children, it calls this function to create edit
|
||||
* operations for those changes.
|
||||
*
|
||||
* This adds to the edit list in place and does not return anything.
|
||||
*
|
||||
* @param {Object} currentParent SimpleDOM node for the current state of the element
|
||||
* @param {?Object} oldParent SimpleDOM node for the previous state of this element, undefined if the element is new
|
||||
*/
|
||||
var generateChildEdits = function (currentParent, oldParent) {
|
||||
/*jslint continue: true */
|
||||
|
||||
var currentIndex = 0,
|
||||
oldIndex = 0,
|
||||
currentChildren = currentParent.children,
|
||||
oldChildren = oldParent ? oldParent.children : [],
|
||||
currentChild,
|
||||
oldChild,
|
||||
newEdits = [],
|
||||
newEdit,
|
||||
textAfterID;
|
||||
|
||||
/**
|
||||
* We initially put new edit objects into the `newEdits` array so that we
|
||||
* can fix them up with proper positioning information. This function is
|
||||
* responsible for doing that fixup.
|
||||
*
|
||||
* The `beforeID` that appears in many edits tells the browser to make the
|
||||
* change before the element with the given ID. In other words, an
|
||||
* elementInsert with a `beforeID` of 32 would result in something like
|
||||
* `parentElement.insertBefore(newChildElement, _queryBracketsID(32))`
|
||||
*
|
||||
* Many new edits are captured in the `newEdits` array so that a suitable
|
||||
* `beforeID` can be added to them before they are added to the main edits
|
||||
* list. This function sets the `beforeID` on any pending edits and adds
|
||||
* them to the main list.
|
||||
*
|
||||
* The beforeID set here will then be used as the `afterID` for text edits
|
||||
* that follow.
|
||||
*
|
||||
* @param {int} beforeID ID to set on the pending edits
|
||||
*/
|
||||
var finalizeNewEdits = function (beforeID) {
|
||||
newEdits.forEach(function (edit) {
|
||||
// elementDeletes don't need any positioning information
|
||||
if (edit.type !== "elementDelete") {
|
||||
edit.beforeID = beforeID;
|
||||
}
|
||||
});
|
||||
edits.push.apply(edits, newEdits);
|
||||
newEdits = [];
|
||||
textAfterID = beforeID;
|
||||
};
|
||||
|
||||
/**
|
||||
* If the current element was not in the old DOM, then we will create
|
||||
* an elementInsert edit for it.
|
||||
*
|
||||
* If the element was in the old DOM, this will return false and the
|
||||
* main loop will either spot this element later in the child list
|
||||
* or the element has been moved.
|
||||
*
|
||||
* @return {boolean} true if an elementInsert was created
|
||||
*/
|
||||
var addElementInsert = function () {
|
||||
if (!oldNodeMap[currentChild.tagID]) {
|
||||
newEdit = {
|
||||
type: "elementInsert",
|
||||
tag: currentChild.tag,
|
||||
tagID: currentChild.tagID,
|
||||
parentID: currentChild.parent.tagID,
|
||||
attributes: currentChild.attributes
|
||||
};
|
||||
|
||||
newEdits.push(newEdit);
|
||||
|
||||
// This newly inserted node needs to have edits generated for its
|
||||
// children, so we add it to the queue.
|
||||
queue.push(currentChild);
|
||||
|
||||
// A textInsert edit that follows this elementInsert should use
|
||||
// this element's ID.
|
||||
textAfterID = currentChild.tagID;
|
||||
|
||||
// new element means we need to move on to compare the next
|
||||
// of the current tree with the one from the old tree that we
|
||||
// just compared
|
||||
currentIndex++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* If the old element that we're looking at does not appear in the new
|
||||
* DOM, that means it was deleted and we'll create an elementDelete edit.
|
||||
*
|
||||
* If the element is in the new DOM, then this will return false and
|
||||
* the main loop with either spot this node later on or the element
|
||||
* has been moved.
|
||||
*
|
||||
* @return {boolean} true if elementDelete was generated
|
||||
*/
|
||||
var addElementDelete = function () {
|
||||
if (!newNode.nodeMap[oldChild.tagID]) {
|
||||
newEdit = {
|
||||
type: "elementDelete",
|
||||
tagID: oldChild.tagID
|
||||
};
|
||||
newEdits.push(newEdit);
|
||||
|
||||
// deleted element means we need to move on to compare the next
|
||||
// of the old tree with the one from the current tree that we
|
||||
// just compared
|
||||
oldIndex++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a textInsert edit for a newly created text node.
|
||||
*/
|
||||
var addTextInsert = function () {
|
||||
newEdit = {
|
||||
type: "textInsert",
|
||||
content: currentChild.content,
|
||||
parentID: currentChild.parent.tagID
|
||||
};
|
||||
|
||||
// text changes will generally have afterID and beforeID, but we make
|
||||
// special note if it's the first child.
|
||||
if (textAfterID) {
|
||||
newEdit.afterID = textAfterID;
|
||||
} else {
|
||||
newEdit.firstChild = true;
|
||||
}
|
||||
newEdits.push(newEdit);
|
||||
|
||||
// The text node is in the new tree, so we move to the next new tree item
|
||||
currentIndex++;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds the previous child of the new tree.
|
||||
*
|
||||
* @return {?Object} previous child or null if there wasn't one
|
||||
*/
|
||||
var prevNode = function () {
|
||||
if (currentIndex > 0) {
|
||||
return currentParent.children[currentIndex - 1];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a textDelete edit for text node that is not in the new tree.
|
||||
* Note that we actually create a textReplace rather than a textDelete
|
||||
* if the previous node in current tree was a text node. We do this because
|
||||
* text nodes are not individually addressable and a delete event would
|
||||
* end up clearing out both that previous text node that we want to keep
|
||||
* and this text node that we want to eliminate. Instead, we just log
|
||||
* a textReplace which will result in the deletion of this node and
|
||||
* the maintaining of the old content.
|
||||
*/
|
||||
var addTextDelete = function () {
|
||||
var prev = prevNode();
|
||||
if (prev && !prev.children) {
|
||||
newEdit = {
|
||||
type: "textReplace",
|
||||
content: prev.content
|
||||
};
|
||||
} else {
|
||||
newEdit = {
|
||||
type: "textDelete"
|
||||
};
|
||||
}
|
||||
|
||||
// When elements are deleted or moved from the old set of children, you
|
||||
// can end up with multiple text nodes in a row. A single textReplace edit
|
||||
// will take care of those (and will contain all of the right content since
|
||||
// the text nodes between elements in the new DOM are merged together).
|
||||
// The check below looks to see if we're already in the process of adding
|
||||
// a textReplace edit following the same element.
|
||||
var previousEdit = newEdits.length > 0 && newEdits[newEdits.length - 1];
|
||||
if (previousEdit && previousEdit.type === "textReplace" &&
|
||||
previousEdit.afterID === textAfterID) {
|
||||
oldIndex++;
|
||||
return;
|
||||
}
|
||||
|
||||
newEdit.parentID = oldChild.parent.tagID;
|
||||
|
||||
// If there was only one child previously, we just pass along
|
||||
// textDelete/textReplace with the parentID and the browser will
|
||||
// clear all of the children
|
||||
if (oldChild.parent.children.length === 1) {
|
||||
newEdits.push(newEdit);
|
||||
} else {
|
||||
if (textAfterID) {
|
||||
newEdit.afterID = textAfterID;
|
||||
}
|
||||
newEdits.push(newEdit);
|
||||
}
|
||||
|
||||
// This text appeared in the old tree but not the new one, so we
|
||||
// increment the old children counter.
|
||||
oldIndex++;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds an elementMove edit if the parent has changed between the old and new trees.
|
||||
* These are fairly infrequent and generally occur if you make a change across
|
||||
* tag boundaries.
|
||||
*
|
||||
* @return {boolean} true if an elementMove was generated
|
||||
*/
|
||||
var addElementMove = function () {
|
||||
|
||||
// This check looks a little strange, but it suits what we're trying
|
||||
// to do: as we're walking through the children, a child node that has moved
|
||||
// from one parent to another will be found but would look like some kind
|
||||
// of insert. The check that we're doing here is looking up the current
|
||||
// child's ID in the *old* map and seeing if this child used to have a
|
||||
// different parent.
|
||||
var possiblyMovedElement = oldNodeMap[currentChild.tagID];
|
||||
if (possiblyMovedElement &&
|
||||
currentParent.tagID !== getParentID(possiblyMovedElement)) {
|
||||
newEdit = {
|
||||
type: "elementMove",
|
||||
tagID: currentChild.tagID,
|
||||
parentID: currentChild.parent.tagID
|
||||
};
|
||||
moves.push(newEdit.tagID);
|
||||
newEdits.push(newEdit);
|
||||
|
||||
// this element in the new tree was a move to this spot, so we can move
|
||||
// on to the next child in the new tree.
|
||||
currentIndex++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* If there have been elementInserts before an unchanged text, we need to
|
||||
* let the browser side code know that these inserts should happen *before*
|
||||
* that unchanged text.
|
||||
*/
|
||||
var fixupElementInsert = function () {
|
||||
newEdits.forEach(function (edit) {
|
||||
if (edit.type === "elementInsert") {
|
||||
edit.beforeText = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Looks to see if the element in the old tree has moved by checking its
|
||||
* current and former parents.
|
||||
*
|
||||
* @return {boolean} true if the element has moved
|
||||
*/
|
||||
var hasMoved = function (oldChild) {
|
||||
var oldChildInNewTree = newNode.nodeMap[oldChild.tagID];
|
||||
|
||||
return oldChild.children && oldChildInNewTree && getParentID(oldChild) !== getParentID(oldChildInNewTree);
|
||||
};
|
||||
|
||||
// Loop through the current and old children, comparing them one by one.
|
||||
while (currentIndex < currentChildren.length && oldIndex < oldChildren.length) {
|
||||
currentChild = currentChildren[currentIndex];
|
||||
|
||||
// Check to see if the currentChild has been reparented from somewhere
|
||||
// else in the old tree
|
||||
if (currentChild.children && addElementMove()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
oldChild = oldChildren[oldIndex];
|
||||
|
||||
// Check to see if the oldChild has been moved to another parent.
|
||||
// If it has, we deal with it on the other side (see above)
|
||||
if (hasMoved(oldChild)) {
|
||||
oldIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// First check: is one an element?
|
||||
if (currentChild.children || oldChild.children) {
|
||||
|
||||
// Current child is an element, old child is a text node
|
||||
if (currentChild.children && !oldChild.children) {
|
||||
addTextDelete();
|
||||
|
||||
// If this element is new, add it and move to the next child
|
||||
// in the current tree. Otherwise, we'll compare this same
|
||||
// current element with the next old element on the next pass
|
||||
// through the loop.
|
||||
addElementInsert();
|
||||
|
||||
// Current child is a text node, old child is an element
|
||||
} else if (oldChild.children && !currentChild.children) {
|
||||
// If the old child has *not* been deleted, we assume that we've
|
||||
// inserted some text and will still encounter the old node
|
||||
if (!addElementDelete()) {
|
||||
addTextInsert();
|
||||
}
|
||||
|
||||
// both children are elements
|
||||
} else {
|
||||
if (currentChild.tagID !== oldChild.tagID) {
|
||||
|
||||
// These are different elements, so we will add an insert and/or delete
|
||||
// as appropriate
|
||||
if (!addElementInsert() && !addElementDelete()) {
|
||||
console.error("HTML Instrumentation: This should not happen. Two elements have different tag IDs and there was no insert/delete. This generally means there was a reordering of elements.");
|
||||
currentIndex++;
|
||||
oldIndex++;
|
||||
}
|
||||
|
||||
// There has been no change in the tag we're looking at.
|
||||
} else {
|
||||
// Since this element hasn't moved, it is a suitable "beforeID"
|
||||
// for the edits we've logged.
|
||||
finalizeNewEdits(oldChild.tagID);
|
||||
currentIndex++;
|
||||
oldIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// We know we're comparing two texts. Just match up their signatures.
|
||||
} else {
|
||||
if (currentChild.textSignature !== oldChild.textSignature) {
|
||||
newEdit = {
|
||||
type: "textReplace",
|
||||
content: currentChild.content,
|
||||
parentID: currentChild.parent.tagID
|
||||
};
|
||||
if (textAfterID) {
|
||||
newEdit.afterID = textAfterID;
|
||||
}
|
||||
newEdits.push(newEdit);
|
||||
} else {
|
||||
// This is a special case: if an element is being inserted but
|
||||
// there is an unchanged text that follows it, the element being
|
||||
// inserted may end up in the wrong place because it will get a
|
||||
// beforeID of the next element when it really needs to come
|
||||
// before this unchanged text.
|
||||
fixupElementInsert();
|
||||
}
|
||||
|
||||
// Either we've done a text replace or both sides matched. In either
|
||||
// case we're ready to move forward among both the old and new children.
|
||||
currentIndex++;
|
||||
oldIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, we've used up all of the children in at least one of the
|
||||
// two sets of children.
|
||||
|
||||
/**
|
||||
* Take care of any remaining children in the old tree.
|
||||
*/
|
||||
while (oldIndex < oldChildren.length) {
|
||||
oldChild = oldChildren[oldIndex];
|
||||
|
||||
// Check for an element that has moved
|
||||
if (hasMoved(oldChild)) {
|
||||
// This element has moved, so we skip it on this side (the move
|
||||
// is handled on the new tree side).
|
||||
oldIndex++;
|
||||
|
||||
// is this an element? if so, delete it
|
||||
} else if (oldChild.children) {
|
||||
if (!addElementDelete()) {
|
||||
console.error("HTML Instrumentation: failed to add elementDelete for remaining element in the original DOM. This should not happen.", oldChild);
|
||||
oldIndex++;
|
||||
}
|
||||
|
||||
// must be text. delete that.
|
||||
} else {
|
||||
addTextDelete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take care of the remaining children in the new tree.
|
||||
*/
|
||||
while (currentIndex < currentChildren.length) {
|
||||
currentChild = currentChildren[currentIndex];
|
||||
|
||||
// Is this an element?
|
||||
if (currentChild.children) {
|
||||
|
||||
// Look to see if the element has moved here.
|
||||
if (!addElementMove()) {
|
||||
// Not a move, so we insert this element.
|
||||
if (!addElementInsert()) {
|
||||
console.error("HTML Instrumentation: failed to add elementInsert for remaining element in the updated DOM. This should not happen.");
|
||||
currentIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// not a new element, so it must be new text.
|
||||
} else {
|
||||
addTextInsert();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize remaining edits. For inserts and moves, we can set the `lastChild`
|
||||
* flag and the browser can simply use `appendChild` to add these items.
|
||||
*/
|
||||
newEdits.forEach(function (edit) {
|
||||
if (edit.type === "textInsert" || edit.type === "elementInsert" || edit.type === "elementMove") {
|
||||
edit.lastChild = true;
|
||||
delete edit.firstChild;
|
||||
delete edit.afterID;
|
||||
}
|
||||
});
|
||||
edits.push.apply(edits, newEdits);
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds elements to the queue for generateChildEdits.
|
||||
* Only elements (and not text nodes) are added. New nodes (ones that aren't in the
|
||||
* old nodeMap), are not added here because they will be added when generateChildEdits
|
||||
* creates the elementInsert edit.
|
||||
*/
|
||||
var queuePush = function (node) {
|
||||
if (node.children && oldNodeMap[node.tagID]) {
|
||||
queue.push(node);
|
||||
}
|
||||
};
|
||||
|
||||
// Start at the root of the current tree.
|
||||
queue.push(newNode);
|
||||
|
||||
do {
|
||||
currentElement = queue.pop();
|
||||
oldElement = oldNodeMap[currentElement.tagID];
|
||||
|
||||
// Do we need to compare elements?
|
||||
if (oldElement) {
|
||||
|
||||
// Are attributes different?
|
||||
if (currentElement.attributeSignature !== oldElement.attributeSignature) {
|
||||
// generate attribute edits
|
||||
generateAttributeEdits(edits, oldElement, currentElement);
|
||||
}
|
||||
|
||||
// Has there been a change to this node's immediate children?
|
||||
if (currentElement.childSignature !== oldElement.childSignature) {
|
||||
generateChildEdits(currentElement, oldElement);
|
||||
}
|
||||
|
||||
// If there's a change farther down in the tree, add the children to the queue.
|
||||
// If not, we can skip that whole subtree.
|
||||
if (currentElement.subtreeSignature !== oldElement.subtreeSignature) {
|
||||
currentElement.children.forEach(queuePush);
|
||||
}
|
||||
|
||||
// This is a new element, so go straight to generating child edits (which will
|
||||
// create the appropriate Insert edits).
|
||||
} else {
|
||||
// If this is the root (html) tag, we need to manufacture an insert for it here,
|
||||
// because it isn't the child of any other node. The browser-side code doesn't
|
||||
// care about parentage/positioning in this case, and will handle just setting the
|
||||
// ID on the existing implied HTML tag in the browser without actually creating it.
|
||||
if (!currentElement.parent) {
|
||||
edits.push({
|
||||
type: "elementInsert",
|
||||
tag: currentElement.tag,
|
||||
tagID: currentElement.tagID,
|
||||
parentID: null,
|
||||
attributes: currentElement.attributes
|
||||
});
|
||||
}
|
||||
|
||||
generateChildEdits(currentElement, null);
|
||||
}
|
||||
} while (queue.length);
|
||||
|
||||
// Special handling for moves: add edits to the beginning of the list so that
|
||||
// moved nodes are set aside to ensure that they remain available at the time of their
|
||||
// move.
|
||||
if (moves.length > 0) {
|
||||
edits.unshift({
|
||||
type: "rememberNodes",
|
||||
tagIDs: moves
|
||||
});
|
||||
}
|
||||
|
||||
return edits;
|
||||
}
|
||||
|
||||
exports.domdiff = domdiff;
|
||||
});
|
File diff suppressed because it is too large
Load Diff
441
src/language/HTMLSimpleDOM.js
Normal file
441
src/language/HTMLSimpleDOM.js
Normal file
@ -0,0 +1,441 @@
|
||||
/*
|
||||
* Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved.
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
|
||||
/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50 */
|
||||
/*global define, $, CodeMirror */
|
||||
/*unittests: HTML Instrumentation*/
|
||||
|
||||
define(function (require, exports, module) {
|
||||
"use strict";
|
||||
|
||||
var DocumentManager = require("document/DocumentManager"),
|
||||
Tokenizer = require("language/HTMLTokenizer").Tokenizer,
|
||||
MurmurHash3 = require("thirdparty/murmurhash3_gc"),
|
||||
PerfUtils = require("utils/PerfUtils");
|
||||
|
||||
var seed = Math.floor(Math.random() * 65535);
|
||||
|
||||
var tagID = 1;
|
||||
|
||||
/**
|
||||
* A list of tags whose start causes any of a given set of immediate parent
|
||||
* tags to close. This mostly comes from the HTML5 spec section on omitted close tags:
|
||||
* http://www.w3.org/html/wg/drafts/html/master/syntax.html#optional-tags
|
||||
* This doesn't handle general content model violations.
|
||||
*/
|
||||
var openImpliesClose = {
|
||||
li : { li: true },
|
||||
dt : { dd: true, dt: true },
|
||||
dd : { dd: true, dt: true },
|
||||
address : { p: true },
|
||||
article : { p: true },
|
||||
aside : { p: true },
|
||||
blockquote : { p: true },
|
||||
dir : { p: true },
|
||||
div : { p: true },
|
||||
dl : { p: true },
|
||||
fieldset: { p: true },
|
||||
footer : { p: true },
|
||||
form : { p: true },
|
||||
h1 : { p: true },
|
||||
h2 : { p: true },
|
||||
h3 : { p: true },
|
||||
h4 : { p: true },
|
||||
h5 : { p: true },
|
||||
h6 : { p: true },
|
||||
header : { p: true },
|
||||
hgroup : { p: true },
|
||||
hr : { p: true },
|
||||
main : { p: true },
|
||||
menu : { p: true },
|
||||
nav : { p: true },
|
||||
ol : { p: true },
|
||||
p : { p: true },
|
||||
pre : { p: true },
|
||||
section : { p: true },
|
||||
table : { p: true },
|
||||
ul : { p: true },
|
||||
rt : { rp: true, rt: true },
|
||||
rp : { rp: true, rt: true },
|
||||
optgroup: { optgroup: true, option: true },
|
||||
option : { option: true },
|
||||
tbody : { thead: true, tbody: true, tfoot: true },
|
||||
tfoot : { tbody: true },
|
||||
tr : { tr: true, th: true, td: true },
|
||||
th : { th: true, td: true },
|
||||
td : { thead: true, th: true, td: true },
|
||||
body : { head: true, link: true, script: true }
|
||||
};
|
||||
|
||||
/**
|
||||
* A list of tags that are self-closing (do not contain other elements).
|
||||
* Mostly taken from http://www.w3.org/html/wg/drafts/html/master/syntax.html#void-elements
|
||||
*/
|
||||
var voidElements = {
|
||||
area: true,
|
||||
base: true,
|
||||
basefont: true,
|
||||
br: true,
|
||||
col: true,
|
||||
command: true,
|
||||
embed: true,
|
||||
frame: true,
|
||||
hr: true,
|
||||
img: true,
|
||||
input: true,
|
||||
isindex: true,
|
||||
keygen: true,
|
||||
link: true,
|
||||
menuitem: true,
|
||||
meta: true,
|
||||
param: true,
|
||||
source: true,
|
||||
track: true,
|
||||
wbr: true
|
||||
};
|
||||
|
||||
function SimpleDOMBuilder(text, startOffset, startOffsetPos) {
|
||||
this.stack = [];
|
||||
this.text = text;
|
||||
this.t = new Tokenizer(text);
|
||||
this.currentTag = null;
|
||||
this.startOffset = startOffset || 0;
|
||||
this.startOffsetPos = startOffsetPos || {line: 0, ch: 0};
|
||||
}
|
||||
|
||||
function _updateHash(node) {
|
||||
if (node.children) {
|
||||
var i,
|
||||
subtreeHashes = "",
|
||||
childHashes = "",
|
||||
child;
|
||||
for (i = 0; i < node.children.length; i++) {
|
||||
child = node.children[i];
|
||||
if (child.children) {
|
||||
childHashes += String(child.tagID);
|
||||
subtreeHashes += String(child.tagID) + child.attributeSignature + child.subtreeSignature;
|
||||
} else {
|
||||
childHashes += child.textSignature;
|
||||
subtreeHashes += child.textSignature;
|
||||
}
|
||||
}
|
||||
node.childSignature = MurmurHash3.hashString(childHashes, childHashes.length, seed);
|
||||
node.subtreeSignature = MurmurHash3.hashString(subtreeHashes, subtreeHashes.length, seed);
|
||||
} else {
|
||||
node.textSignature = MurmurHash3.hashString(node.content, node.content.length, seed);
|
||||
}
|
||||
}
|
||||
|
||||
function _updateAttributeHash(node) {
|
||||
var attributeString = JSON.stringify(node.attributes);
|
||||
node.attributeSignature = MurmurHash3.hashString(attributeString, attributeString.length, seed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a synthetic ID for text nodes. These IDs are only used
|
||||
* for comparison purposes in the SimpleDOM structure, since we can't
|
||||
* apply IDs to text nodes in the browser.
|
||||
*
|
||||
* TODO/Note: When generating a diff, the decision to do a textReplace
|
||||
* edit rather than a textDelete/textInsert hinges on how this ID
|
||||
* is treated (because it is currently based on the previous or
|
||||
* parent node).
|
||||
*
|
||||
* @param {Object} textNode new node for which we are generating an ID
|
||||
* @return {string} ID for the node
|
||||
*/
|
||||
function getTextNodeID(textNode) {
|
||||
var childIndex = textNode.parent.children.indexOf(textNode);
|
||||
if (childIndex === 0) {
|
||||
return textNode.parent.tagID + ".0";
|
||||
}
|
||||
return textNode.parent.children[childIndex - 1].tagID + "t";
|
||||
}
|
||||
|
||||
function addPos(pos1, pos2) {
|
||||
return {line: pos1.line + pos2.line, ch: (pos2.line === 0 ? pos1.ch + pos2.ch : pos2.ch)};
|
||||
}
|
||||
|
||||
function offsetPos(pos, offset) {
|
||||
// Simple character offset. Only safe if the offset doesn't cross a line boundary.
|
||||
return {line: pos.line, ch: pos.ch + offset};
|
||||
}
|
||||
|
||||
SimpleDOMBuilder.prototype.build = function (strict) {
|
||||
var self = this;
|
||||
var token, lastClosedTag, lastTextNode, lastIndex = 0;
|
||||
var stack = this.stack;
|
||||
var attributeName = null;
|
||||
var nodeMap = {};
|
||||
var markCache = {};
|
||||
|
||||
// Start timers for building full and partial DOMs.
|
||||
// Appropriate timer is used, and the other is discarded.
|
||||
var timerBuildFull = "HTMLInstr. Build DOM Full";
|
||||
var timerBuildPart = "HTMLInstr. Build DOM Partial";
|
||||
PerfUtils.markStart([timerBuildFull, timerBuildPart]);
|
||||
|
||||
function closeTag(endIndex, endPos) {
|
||||
lastClosedTag = stack[stack.length - 1];
|
||||
stack.pop();
|
||||
_updateHash(lastClosedTag);
|
||||
|
||||
lastClosedTag.end = self.startOffset + endIndex;
|
||||
lastClosedTag.endPos = addPos(self.startOffsetPos, endPos);
|
||||
}
|
||||
|
||||
while ((token = this.t.nextToken()) !== null) {
|
||||
// lastTextNode is used to glue text nodes together
|
||||
// If the last node we saw was text but this one is not, then we're done gluing.
|
||||
// If this node is a comment, we might still encounter more text.
|
||||
if (token.type !== "text" && token.type !== "comment" && lastTextNode) {
|
||||
lastTextNode = null;
|
||||
}
|
||||
|
||||
if (token.type === "error") {
|
||||
PerfUtils.finalizeMeasurement(timerBuildFull); // discard
|
||||
PerfUtils.addMeasurement(timerBuildPart); // use
|
||||
return null;
|
||||
} else if (token.type === "opentagname") {
|
||||
var newTagName = token.contents.toLowerCase(),
|
||||
newTag;
|
||||
|
||||
if (openImpliesClose.hasOwnProperty(newTagName)) {
|
||||
var closable = openImpliesClose[newTagName];
|
||||
while (stack.length > 0 && closable.hasOwnProperty(stack[stack.length - 1].tag)) {
|
||||
// Close the previous tag at the start of this tag.
|
||||
// Adjust backwards for the < before the tag name.
|
||||
closeTag(token.start - 1, offsetPos(token.startPos, -1));
|
||||
}
|
||||
}
|
||||
|
||||
newTag = {
|
||||
tag: token.contents.toLowerCase(),
|
||||
children: [],
|
||||
attributes: {},
|
||||
parent: (stack.length ? stack[stack.length - 1] : null),
|
||||
start: this.startOffset + token.start - 1,
|
||||
startPos: addPos(this.startOffsetPos, offsetPos(token.startPos, -1)) // ok because we know the previous char was a "<"
|
||||
};
|
||||
newTag.tagID = this.getID(newTag, markCache);
|
||||
|
||||
// During undo in particular, it's possible that tag IDs may be reused and
|
||||
// the marks in the document may be misleading. If a tag ID has been reused,
|
||||
// we apply a new tag ID to ensure that our edits come out correctly.
|
||||
if (nodeMap[newTag.tagID]) {
|
||||
newTag.tagID = this.getNewID();
|
||||
}
|
||||
|
||||
nodeMap[newTag.tagID] = newTag;
|
||||
if (newTag.parent) {
|
||||
newTag.parent.children.push(newTag);
|
||||
}
|
||||
this.currentTag = newTag;
|
||||
|
||||
if (voidElements.hasOwnProperty(newTag.tag)) {
|
||||
// This is a self-closing element.
|
||||
_updateHash(newTag);
|
||||
} else {
|
||||
stack.push(newTag);
|
||||
}
|
||||
} else if (token.type === "opentagend" || token.type === "selfclosingtag") {
|
||||
// TODO: disallow <p/>?
|
||||
if (this.currentTag) {
|
||||
// We're closing an open tag. Record the end of the open tag as the end of the
|
||||
// range. (If we later find a close tag for this tag, the end will get overwritten
|
||||
// with the end of the close tag. In the case of a self-closing tag, we should never
|
||||
// encounter that.)
|
||||
// Note that we don't need to update the signature here because the signature only
|
||||
// relies on the tag name and ID, and isn't affected by the tag's attributes, so
|
||||
// the signature we calculated when creating the tag is still the same. If we later
|
||||
// find a close tag for this tag, we'll update the signature to account for its
|
||||
// children at that point (in the next "else" case).
|
||||
this.currentTag.end = this.startOffset + token.end;
|
||||
this.currentTag.endPos = addPos(this.startOffsetPos, token.endPos);
|
||||
lastClosedTag = this.currentTag;
|
||||
_updateAttributeHash(this.currentTag);
|
||||
this.currentTag = null;
|
||||
}
|
||||
} else if (token.type === "closetag") {
|
||||
// If this is a self-closing element, ignore the close tag.
|
||||
var closeTagName = token.contents.toLowerCase();
|
||||
if (!voidElements.hasOwnProperty(closeTagName)) {
|
||||
// Find the topmost item on the stack that matches. If we can't find one, assume
|
||||
// this is just a dangling closing tag and ignore it.
|
||||
var i;
|
||||
for (i = stack.length - 1; i >= 0; i--) {
|
||||
if (stack[i].tag === closeTagName) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (strict && i !== stack.length - 1) {
|
||||
// If we're in strict mode, treat unbalanced tags as invalid.
|
||||
PerfUtils.finalizeMeasurement(timerBuildFull);
|
||||
PerfUtils.addMeasurement(timerBuildPart);
|
||||
return null;
|
||||
}
|
||||
if (i >= 0) {
|
||||
do {
|
||||
// For all tags we're implicitly closing (before we hit the matching tag), we want the
|
||||
// implied end to be the beginning of the close tag (which is two characters, "</", before
|
||||
// the start of the tagname). For the actual tag we're explicitly closing, we want the
|
||||
// implied end to be the end of the close tag (which is one character, ">", after the end of
|
||||
// the tagname).
|
||||
if (stack.length === i + 1) {
|
||||
closeTag(token.end + 1, offsetPos(token.endPos, 1));
|
||||
} else {
|
||||
closeTag(token.start - 2, offsetPos(token.startPos, -2));
|
||||
}
|
||||
} while (stack.length > i);
|
||||
} else {
|
||||
// If we're in strict mode, treat unmatched close tags as invalid. Otherwise
|
||||
// we just silently ignore them.
|
||||
if (strict) {
|
||||
PerfUtils.finalizeMeasurement(timerBuildFull);
|
||||
PerfUtils.addMeasurement(timerBuildPart);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (token.type === "attribname") {
|
||||
attributeName = token.contents.toLowerCase();
|
||||
// Set the value to the empty string in case this is an empty attribute. If it's not,
|
||||
// it will get overwritten by the attribvalue later.
|
||||
this.currentTag.attributes[attributeName] = "";
|
||||
} else if (token.type === "attribvalue" && attributeName !== null) {
|
||||
this.currentTag.attributes[attributeName] = token.contents;
|
||||
attributeName = null;
|
||||
} else if (token.type === "text") {
|
||||
if (stack.length) {
|
||||
var parent = stack[stack.length - 1];
|
||||
var newNode;
|
||||
|
||||
// Check to see if we're continuing a previous text.
|
||||
if (lastTextNode) {
|
||||
newNode = lastTextNode;
|
||||
newNode.content += token.contents;
|
||||
} else {
|
||||
newNode = {
|
||||
parent: stack[stack.length - 1],
|
||||
content: token.contents
|
||||
};
|
||||
parent.children.push(newNode);
|
||||
newNode.tagID = getTextNodeID(newNode);
|
||||
nodeMap[newNode.tagID] = newNode;
|
||||
lastTextNode = newNode;
|
||||
}
|
||||
|
||||
_updateHash(newNode);
|
||||
}
|
||||
}
|
||||
lastIndex = token.end;
|
||||
}
|
||||
|
||||
// If we have any tags hanging open (e.g. html or body), fail the parse if we're in strict mode,
|
||||
// otherwise close them at the end of the document.
|
||||
if (stack.length) {
|
||||
if (strict) {
|
||||
PerfUtils.finalizeMeasurement(timerBuildFull);
|
||||
PerfUtils.addMeasurement(timerBuildPart);
|
||||
return null;
|
||||
} else {
|
||||
// Manually compute the position of the end of the text (we can't rely on the
|
||||
// tokenizer for this since it may not get to the very end)
|
||||
// TODO: should probably make the tokenizer get to the end...
|
||||
var lines = this.text.split("\n"),
|
||||
lastPos = {line: lines.length - 1, ch: lines[lines.length - 1].length};
|
||||
while (stack.length) {
|
||||
closeTag(this.text.length, lastPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dom = lastClosedTag;
|
||||
if (!dom) {
|
||||
// This can happen if the document has no nontrivial content, or if the user tries to
|
||||
// have something at the root other than the HTML tag. In all such cases, we treat the
|
||||
// document as invalid.
|
||||
return null;
|
||||
}
|
||||
|
||||
dom.nodeMap = nodeMap;
|
||||
PerfUtils.addMeasurement(timerBuildFull); // use
|
||||
PerfUtils.finalizeMeasurement(timerBuildPart); // discard
|
||||
|
||||
return dom;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a new tag ID.
|
||||
*
|
||||
* @return {int} unique tag ID
|
||||
*/
|
||||
SimpleDOMBuilder.prototype.getNewID = function () {
|
||||
return tagID++;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the best tag ID for the new tag object given.
|
||||
* The default implementation just calls `getNewID`
|
||||
* and returns a unique ID.
|
||||
*
|
||||
* @param {Object} newTag tag object to potentially inspect to choose an ID
|
||||
* @return {int} unique tag ID
|
||||
*/
|
||||
SimpleDOMBuilder.prototype.getID = SimpleDOMBuilder.prototype.getNewID;
|
||||
|
||||
function buildSimpleDOM(text, strict) {
|
||||
var builder = new SimpleDOMBuilder(text);
|
||||
return builder.build(strict);
|
||||
}
|
||||
|
||||
function _dumpDOM(root) {
|
||||
var result = "",
|
||||
indent = "";
|
||||
|
||||
function walk(node) {
|
||||
if (node.tag) {
|
||||
result += indent + "TAG " + node.tagID + " " + node.tag + " " + JSON.stringify(node.attributes) + "\n";
|
||||
} else {
|
||||
result += indent + "TEXT " + node.tagID + " " + node.content + "\n";
|
||||
}
|
||||
if (node.children) {
|
||||
indent += " ";
|
||||
node.children.forEach(walk);
|
||||
indent = indent.slice(2);
|
||||
}
|
||||
}
|
||||
walk(root);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
exports._dumpDOM = _dumpDOM;
|
||||
exports.buildSimpleDOM = buildSimpleDOM;
|
||||
exports._offsetPos = offsetPos;
|
||||
exports._updateHash = _updateHash;
|
||||
exports._getTextNodeID = getTextNodeID;
|
||||
exports._seed = seed;
|
||||
exports.SimpleDOMBuilder = SimpleDOMBuilder;
|
||||
});
|
@ -46,6 +46,7 @@ define(function (require, exports, module) {
|
||||
require("spec/FileUtils-test");
|
||||
require("spec/FindReplace-test");
|
||||
require("spec/HTMLInstrumentation-test");
|
||||
require("spec/HTMLSimpleDOM-test");
|
||||
require("spec/HTMLTokenizer-test");
|
||||
require("spec/InlineEditorProviders-test");
|
||||
require("spec/InstallExtensionDialog-test");
|
||||
|
@ -33,9 +33,9 @@ define(function (require, exports, module) {
|
||||
var NativeFileSystem = require("file/NativeFileSystem").NativeFileSystem,
|
||||
FileUtils = require("file/FileUtils"),
|
||||
HTMLInstrumentation = require("language/HTMLInstrumentation"),
|
||||
HTMLSimpleDOM = require("language/HTMLSimpleDOM"),
|
||||
RemoteFunctions = require("text!LiveDevelopment/Agents/RemoteFunctions.js"),
|
||||
SpecRunnerUtils = require("spec/SpecRunnerUtils"),
|
||||
MurmurHash3 = require("thirdparty/murmurhash3_gc"),
|
||||
WellFormedDoc = require("text!spec/HTMLInstrumentation-test-files/wellformed.html"),
|
||||
NotWellFormedDoc = require("text!spec/HTMLInstrumentation-test-files/omitEndTags.html"),
|
||||
InvalidHTMLDoc = require("text!spec/HTMLInstrumentation-test-files/invalidHTML.html"),
|
||||
@ -53,7 +53,7 @@ define(function (require, exports, module) {
|
||||
// starting from an empty document (which, in the browser, includes implied html/head/body
|
||||
// tags). We have to also strip the tagIDs from this DOM since they won't appear in the
|
||||
// browser in this case.
|
||||
var dom = HTMLInstrumentation._buildSimpleDOM("<html><head></head><body></body></html>", true);
|
||||
var dom = HTMLSimpleDOM.buildSimpleDOM("<html><head></head><body></body></html>", true);
|
||||
Object.keys(dom.nodeMap).forEach(function (key) {
|
||||
var node = dom.nodeMap[key];
|
||||
delete node.tagID;
|
||||
@ -931,44 +931,6 @@ define(function (require, exports, module) {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Strict HTML parsing", function () {
|
||||
it("should parse a document with balanced, void and self-closing tags", function () {
|
||||
expect(HTMLInstrumentation._buildSimpleDOM("<p><b>some</b>awesome text</p><p>and <img> another <br/> para</p>", true)).not.toBeNull();
|
||||
});
|
||||
it("should parse a document with an implied-close tag followed by a tag that forces it to close", function () {
|
||||
var result = HTMLInstrumentation._buildSimpleDOM("<div><p>unclosed para<h1>heading that closes para</h1></div>", true);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.tag).toBe("div");
|
||||
expect(result.children[0].tag).toBe("p");
|
||||
expect(result.children[1].tag).toBe("h1");
|
||||
});
|
||||
it("should return null for an unclosed non-void/non-implied-close tag", function () {
|
||||
expect(HTMLInstrumentation._buildSimpleDOM("<p>this has an <b>unclosed bold tag</p>", true)).toBeNull();
|
||||
});
|
||||
it("should return null for an extra close tag", function () {
|
||||
expect(HTMLInstrumentation._buildSimpleDOM("<p>this has an unopened bold</b> tag</p>", true)).toBeNull();
|
||||
});
|
||||
it("should return null if there are unclosed tags at the end of the document", function () {
|
||||
expect(HTMLInstrumentation._buildSimpleDOM("<div>this has <b>multiple unclosed tags", true)).toBeNull();
|
||||
});
|
||||
it("should return null if there is a tokenization failure", function () {
|
||||
expect(HTMLInstrumentation._buildSimpleDOM("<div<badtag></div>", true)).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle empty attributes", function () {
|
||||
var dom = HTMLInstrumentation._buildSimpleDOM("<input disabled>", true);
|
||||
expect(dom.attributes.disabled).toEqual("");
|
||||
});
|
||||
|
||||
it("should merge text nodes around a comment", function () {
|
||||
var dom = HTMLInstrumentation._buildSimpleDOM("<div>Text <!-- comment --> Text2</div>", true);
|
||||
expect(dom.children.length).toBe(1);
|
||||
var textNode = dom.children[0];
|
||||
expect(textNode.content).toBe("Text Text2");
|
||||
expect(textNode.textSignature).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML Instrumentation in dirty files", function () {
|
||||
var changeList, offsets;
|
||||
|
||||
@ -1009,7 +971,7 @@ define(function (require, exports, module) {
|
||||
editor.document.refreshText(origText);
|
||||
}
|
||||
|
||||
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
|
||||
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
|
||||
result;
|
||||
|
||||
var clonedDOM = cloneDOM(previousDOM);
|
||||
@ -1121,33 +1083,10 @@ define(function (require, exports, module) {
|
||||
});
|
||||
});
|
||||
|
||||
it("should build simple DOM", function () {
|
||||
setupEditor(WellFormedDoc);
|
||||
runs(function () {
|
||||
var dom = HTMLInstrumentation._buildSimpleDOM(editor.document.getText());
|
||||
expect(dom.tagID).toEqual(jasmine.any(Number));
|
||||
expect(dom.tag).toEqual("html");
|
||||
expect(dom.start).toEqual(16);
|
||||
expect(dom.end).toEqual(1269);
|
||||
expect(dom.subtreeSignature).toEqual(jasmine.any(Number));
|
||||
expect(dom.childSignature).toEqual(jasmine.any(Number));
|
||||
expect(dom.children.length).toEqual(5);
|
||||
var meta = dom.children[1].children[1];
|
||||
expect(Object.keys(meta.attributes).length).toEqual(1);
|
||||
expect(meta.attributes.charset).toEqual("utf-8");
|
||||
var titleContents = dom.children[1].children[5].children[0];
|
||||
expect(titleContents.content).toEqual("GETTING STARTED WITH BRACKETS");
|
||||
expect(titleContents.textSignature).toEqual(MurmurHash3.hashString(titleContents.content, titleContents.content.length, HTMLInstrumentation._seed));
|
||||
expect(dom.children[1].parent).toEqual(dom);
|
||||
expect(dom.nodeMap[meta.tagID]).toBe(meta);
|
||||
expect(meta.childSignature).toEqual(jasmine.any(Number));
|
||||
});
|
||||
});
|
||||
|
||||
it("should mark editor text based on the simple DOM", function () {
|
||||
setupEditor(WellFormedDoc);
|
||||
runs(function () {
|
||||
var dom = HTMLInstrumentation._buildSimpleDOM(editor.document.getText());
|
||||
var dom = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText());
|
||||
HTMLInstrumentation._markTextFromDOM(editor, dom);
|
||||
expect(editor._codeMirror.getAllMarks().length).toEqual(15);
|
||||
});
|
||||
@ -1156,7 +1095,7 @@ define(function (require, exports, module) {
|
||||
it("should handle no diff", function () {
|
||||
setupEditor(WellFormedDoc);
|
||||
runs(function () {
|
||||
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText());
|
||||
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText());
|
||||
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
|
||||
var result = HTMLInstrumentation._updateDOM(previousDOM, editor);
|
||||
expect(result.edits).toEqual([]);
|
||||
@ -1301,7 +1240,7 @@ define(function (require, exports, module) {
|
||||
|
||||
setupEditor(WellFormedDoc);
|
||||
runs(function () {
|
||||
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
|
||||
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
|
||||
tagID = previousDOM.children[3].children[1].tagID,
|
||||
result,
|
||||
origParent = previousDOM.children[3];
|
||||
@ -1349,7 +1288,7 @@ define(function (require, exports, module) {
|
||||
it("should avoid updating while typing an incomplete tag, then update when it's done", function () {
|
||||
setupEditor(WellFormedDoc);
|
||||
runs(function () {
|
||||
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
|
||||
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
|
||||
result;
|
||||
|
||||
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
|
||||
@ -1402,7 +1341,7 @@ define(function (require, exports, module) {
|
||||
it("should handle typing of a <p> without a </p> and then adding it later", function () {
|
||||
setupEditor(WellFormedDoc);
|
||||
runs(function () {
|
||||
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
|
||||
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
|
||||
result;
|
||||
|
||||
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
|
||||
@ -1485,7 +1424,7 @@ define(function (require, exports, module) {
|
||||
it("should handle deleting of an empty tag character-by-character", function () {
|
||||
setupEditor("<p><img>{{0}}</p>", true);
|
||||
runs(function () {
|
||||
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
|
||||
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
|
||||
imgTagID = previousDOM.children[0].tagID,
|
||||
result;
|
||||
|
||||
@ -1506,7 +1445,7 @@ define(function (require, exports, module) {
|
||||
it("should handle deleting of a non-empty tag character-by-character", function () {
|
||||
setupEditor("<div><p>deleteme</p>{{0}}</div>", true);
|
||||
runs(function () {
|
||||
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
|
||||
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
|
||||
pTagID = previousDOM.children[0].tagID,
|
||||
result;
|
||||
|
||||
@ -1527,7 +1466,7 @@ define(function (require, exports, module) {
|
||||
it("should handle typing of a new attribute character-by-character", function () {
|
||||
setupEditor("<p{{0}}>some text</p>", true);
|
||||
runs(function () {
|
||||
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
|
||||
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
|
||||
tagID = previousDOM.tagID,
|
||||
result;
|
||||
|
||||
@ -1579,7 +1518,7 @@ define(function (require, exports, module) {
|
||||
it("should handle deleting of an attribute character-by-character", function () {
|
||||
setupEditor("<p class='myclass'{{0}}>some text</p>", true);
|
||||
runs(function () {
|
||||
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
|
||||
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
|
||||
tagID = previousDOM.tagID,
|
||||
result;
|
||||
|
||||
@ -1627,7 +1566,7 @@ define(function (require, exports, module) {
|
||||
it("should handle wrapping a tag around some text character by character", function () {
|
||||
setupEditor("<p>{{0}}some text{{1}}</p>", true);
|
||||
runs(function () {
|
||||
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
|
||||
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
|
||||
tagID = previousDOM.tagID,
|
||||
result;
|
||||
|
||||
@ -1674,7 +1613,7 @@ define(function (require, exports, module) {
|
||||
it("should handle adding an <html> tag into an empty document", function () {
|
||||
setupEditor("");
|
||||
runs(function () {
|
||||
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
|
||||
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
|
||||
tagID,
|
||||
result;
|
||||
|
||||
@ -1712,7 +1651,7 @@ define(function (require, exports, module) {
|
||||
it("should handle adding a <head> tag into a document", function () {
|
||||
setupEditor("<html>{{0}}</html>", true);
|
||||
runs(function () {
|
||||
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
|
||||
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
|
||||
tagID,
|
||||
result;
|
||||
|
||||
@ -1745,7 +1684,7 @@ define(function (require, exports, module) {
|
||||
it("should handle adding a <body> tag into a document", function () {
|
||||
setupEditor("<html><head></head>{{0}}</html>", true);
|
||||
runs(function () {
|
||||
var previousDOM = HTMLInstrumentation._buildSimpleDOM(editor.document.getText()),
|
||||
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(editor.document.getText()),
|
||||
tagID,
|
||||
result;
|
||||
|
||||
@ -2639,7 +2578,7 @@ define(function (require, exports, module) {
|
||||
}
|
||||
|
||||
benchmarker.start("Initial DOM build");
|
||||
var previousDOM = HTMLInstrumentation._buildSimpleDOM(previousText),
|
||||
var previousDOM = HTMLSimpleDOM.buildSimpleDOM(previousText),
|
||||
changeList,
|
||||
result;
|
||||
benchmarker.end("Initial DOM build");
|
||||
@ -2662,7 +2601,7 @@ define(function (require, exports, module) {
|
||||
|
||||
for (i = 0; i < runs; i++) {
|
||||
editor.document.setText(previousText);
|
||||
previousDOM = HTMLInstrumentation._buildSimpleDOM(previousText);
|
||||
previousDOM = HTMLSimpleDOM.buildSimpleDOM(previousText);
|
||||
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
|
||||
|
||||
$(editor).on("change.perftest", changeFunction);
|
||||
@ -2678,7 +2617,7 @@ define(function (require, exports, module) {
|
||||
|
||||
for (i = 0; i < runs; i++) {
|
||||
editor.document.setText(previousText);
|
||||
previousDOM = HTMLInstrumentation._buildSimpleDOM(previousText);
|
||||
previousDOM = HTMLSimpleDOM.buildSimpleDOM(previousText);
|
||||
HTMLInstrumentation._markTextFromDOM(editor, previousDOM);
|
||||
|
||||
$(editor).on("change.perftest", fullChangeFunction);
|
||||
@ -2816,22 +2755,5 @@ define(function (require, exports, module) {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
xdescribe("DOMNavigator", function () {
|
||||
it("implements easy depth-first traversal", function () {
|
||||
var dom = HTMLInstrumentation._buildSimpleDOM("<html><body><div>Here is <strong>my text</strong></div></body></html>");
|
||||
var nav = new HTMLInstrumentation._DOMNavigator(dom);
|
||||
expect(nav.next().tag).toEqual("body");
|
||||
expect(nav.next().tag).toEqual("div");
|
||||
expect(nav.next().content).toEqual("Here is ");
|
||||
expect(nav.getPosition()).toEqual({
|
||||
tagID: dom.children[0].children[0].tagID,
|
||||
child: 0
|
||||
});
|
||||
expect(nav.next().tag).toEqual("strong");
|
||||
expect(nav.next().content).toEqual("my text");
|
||||
expect(nav.next()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
101
test/spec/HTMLSimpleDOM-test.js
Normal file
101
test/spec/HTMLSimpleDOM-test.js
Normal file
@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved.
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
|
||||
/*jslint vars: true, plusplus: true, devel: true, browser: true, nomen: true, regexp: true, indent: 4, maxerr: 50, evil: true */
|
||||
/*global define, $, describe, beforeEach, afterEach, it, runs, waitsFor, expect, spyOn, xit, xdescribe, jasmine, Node */
|
||||
/*unittests: HTML SimpleDOM*/
|
||||
|
||||
define(function (require, exports, module) {
|
||||
"use strict";
|
||||
|
||||
var HTMLSimpleDOM = require("language/HTMLSimpleDOM"),
|
||||
WellFormedDoc = require("text!spec/HTMLInstrumentation-test-files/wellformed.html"),
|
||||
MurmurHash3 = require("thirdparty/murmurhash3_gc");
|
||||
|
||||
|
||||
describe("HTML SimpleDOM", function () {
|
||||
describe("Strict HTML parsing", function () {
|
||||
it("should parse a document with balanced, void and self-closing tags", function () {
|
||||
expect(HTMLSimpleDOM.buildSimpleDOM("<p><b>some</b>awesome text</p><p>and <img> another <br/> para</p>", true)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("should parse a document with an implied-close tag followed by a tag that forces it to close", function () {
|
||||
var result = HTMLSimpleDOM.buildSimpleDOM("<div><p>unclosed para<h1>heading that closes para</h1></div>", true);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.tag).toBe("div");
|
||||
expect(result.children[0].tag).toBe("p");
|
||||
expect(result.children[1].tag).toBe("h1");
|
||||
});
|
||||
|
||||
it("should return null for an unclosed non-void/non-implied-close tag", function () {
|
||||
expect(HTMLSimpleDOM.buildSimpleDOM("<p>this has an <b>unclosed bold tag</p>", true)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for an extra close tag", function () {
|
||||
expect(HTMLSimpleDOM.buildSimpleDOM("<p>this has an unopened bold</b> tag</p>", true)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null if there are unclosed tags at the end of the document", function () {
|
||||
expect(HTMLSimpleDOM.buildSimpleDOM("<div>this has <b>multiple unclosed tags", true)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null if there is a tokenization failure", function () {
|
||||
expect(HTMLSimpleDOM.buildSimpleDOM("<div<badtag></div>", true)).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle empty attributes", function () {
|
||||
var dom = HTMLSimpleDOM.buildSimpleDOM("<input disabled>", true);
|
||||
expect(dom.attributes.disabled).toEqual("");
|
||||
});
|
||||
|
||||
it("should merge text nodes around a comment", function () {
|
||||
var dom = HTMLSimpleDOM.buildSimpleDOM("<div>Text <!-- comment --> Text2</div>", true);
|
||||
expect(dom.children.length).toBe(1);
|
||||
var textNode = dom.children[0];
|
||||
expect(textNode.content).toBe("Text Text2");
|
||||
expect(textNode.textSignature).toBeDefined();
|
||||
});
|
||||
|
||||
it("should build simple DOM", function () {
|
||||
var dom = HTMLSimpleDOM.buildSimpleDOM(WellFormedDoc);
|
||||
expect(dom.tagID).toEqual(jasmine.any(Number));
|
||||
expect(dom.tag).toEqual("html");
|
||||
expect(dom.start).toEqual(16);
|
||||
expect(dom.end).toEqual(1269);
|
||||
expect(dom.subtreeSignature).toEqual(jasmine.any(Number));
|
||||
expect(dom.childSignature).toEqual(jasmine.any(Number));
|
||||
expect(dom.children.length).toEqual(5);
|
||||
var meta = dom.children[1].children[1];
|
||||
expect(Object.keys(meta.attributes).length).toEqual(1);
|
||||
expect(meta.attributes.charset).toEqual("utf-8");
|
||||
var titleContents = dom.children[1].children[5].children[0];
|
||||
expect(titleContents.content).toEqual("GETTING STARTED WITH BRACKETS");
|
||||
expect(titleContents.textSignature).toEqual(MurmurHash3.hashString(titleContents.content, titleContents.content.length, HTMLSimpleDOM._seed));
|
||||
expect(dom.children[1].parent).toEqual(dom);
|
||||
expect(dom.nodeMap[meta.tagID]).toBe(meta);
|
||||
expect(meta.childSignature).toEqual(jasmine.any(Number));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user