first cut of annotate/materialize

This commit is contained in:
David Greenspan
2012-07-23 15:57:38 -07:00
parent 7ad1f5eff3
commit 14ced70720
6 changed files with 251 additions and 26 deletions

View File

@@ -0,0 +1,159 @@
Meteor.ui = Meteor.ui || {};
Meteor.ui._doc = Meteor.ui._doc || {};
(function() {
var LIVEUI_START_PREFIX = "LIVEUI_START_";
var LIVEUI_END_PREFIX = "LIVEUI_END_";
var LIVEUI_MARKER_PREFIX = "LIVEUI_";
var HTML_PARSE_REGEX = /<!--LIVEUI_(START|END)_(.*?)-->|<|>|[^<>]+/g;
var RANGE_PARSE_REGEX = /^<!--LIVEUI_(START|END)_(.*?)-->$/;
var MARKER_PARSE_REGEX = /^LIVEUI_(.*)$/;
Meteor.ui._TAG = "_liveui";
Meteor.ui._doc._newAnnotations = {}; // {id -> options} until range created
Meteor.ui._doc._newRanges = []; // [LiveRange, ...] until flush time
Meteor.ui._doc._nextId = 1;
// XXX to mention:
// - turning ranges into frags separately helps deal with
// mismatched tags
// - the LIVEUI_START/END comments could be any strings, in theory,
// since we pull them out -- for example, they could be fake
// tags like <range> </range>. Html comments are invisible
// if they go through, but not e.g. inside tag attributes.
// - there is no "ignore annotations" mode, that has to be implemented
// in liveui.
Meteor.ui._doc.materialize = function (html) {
var idToSubHtml = {};
var inTag = false;
var parts = [[]];
var ids = [];
_.each(html.match(HTML_PARSE_REGEX), function(tok) {
var part = tok;
if (tok === '<') {
inTag = true;
} else if (tok === '>') {
inTag = false;
} else if (tok.charAt(0) === '<') {
// START or END comment
if (inTag) {
// can't have a "LiveRange" between tag angle brackets;
// until we deal with this case somehow, ignore
// the annotation
part = "";
} else {
var match = tok.match(RANGE_PARSE_REGEX);
var isStart = match[1] === 'START';
var id = match[2];
if (isStart) {
ids.push(id); // push the id we're in
parts.push([]); // start a new fragment
part = ""; // don't emit anything
} else {
var curId = ids.pop(); // pop the id
if (curId !== id)
throw new Error("Range mismatch: "+curId+" / "+id);
// record the HTML for this range
var subHtml = parts.pop().join('');
idToSubHtml[id] = subHtml;
// emit a comment in the parent range
part = "<!--" + LIVEUI_MARKER_PREFIX + id + "-->";
}
}
}
// append the current token to the current fragment
parts[parts.length - 1].push(part);
});
if (ids.length > 0)
throw new Error("Unclosed ranges "+ids.join(','));
var topHtml = parts.pop().join('');
// Helper that invokes `f` on every comment node under `parent`.
// If `f` returns a node, visit that node next.
var eachComment = function(parent, f) {
for (var n = parent.firstChild; n;) {
if (n.nodeType === 8) { // COMMENT
n = (f(n) || n.nextSibling);
continue;
}
if (n.nodeType === 1) // ELEMENT
eachComment(n, f); // recurse
n = n.nextSibling;
}
};
var makeFrag = function(html) {
var frag = Meteor.ui._htmlToFragment(html);
// empty frag becomes HTML comment <!--empty-->
if (! frag.firstChild)
frag.appendChild(document.createComment("empty"));
eachComment(frag, function(comment) {
var match = MARKER_PARSE_REGEX.exec(comment.nodeValue);
if (match) {
var id = match[1];
var html = idToSubHtml[id];
// Look up annotation data for this id, to determine if it exists
// and hasn't been used before during this or a previous
// materialize (if the dev is not playing by the rules)
var options = Meteor.ui._doc._newAnnotations[id];
if (! options)
throw new Error("Missing or duplicate annotation (on "+
(html||'unknown html')+")");
Meteor.ui._doc._newAnnotations[id] = null;
var subFrag = makeFrag(html);
var range = new Meteor.ui._LiveRange(Meteor.ui._TAG, subFrag);
// assign options to the LiveRange, including `id`
_.extend(range, options);
var next = comment.nextSibling;
var container = comment.parentNode;
if (container && container.nodeName === "TABLE" &&
_.any(subFrag.childNodes,
function(n) { return n.nodeName === "TR"; })) {
// Avoid putting a TR directly in a TABLE without an
// intervening TBODY, because it doesn't work in IE. We do
// the same thing on all browsers for ease of testing
// and debugging.
var tbody = document.createElement("TBODY");
tbody.appendChild(subFrag);
comment.parentNode.replaceChild(tbody, comment);
} else {
comment.parentNode.replaceChild(subFrag, comment);
}
return next;
}
});
return frag;
};
return makeFrag(topHtml);
};
Meteor.ui._doc.annotate = function(html, options) {
options = options || {};
// Generate a unique id string, e.g. "a17"
var id = "a"+(Meteor.ui._doc._nextId++);
options.id = id;
// Save `options` object to attach to LiveRange later
Meteor.ui._doc._newAnnotations[id] = options;
// Surround the HTML with comments
return ("<!--" + LIVEUI_START_PREFIX + options.id + "-->" +
html + "<!--" + LIVEUI_END_PREFIX + options.id + "-->");
};
})();

View File

@@ -0,0 +1,56 @@
Tinytest.add("livedocument - assembly", function(test) {
var doTest = function(calc) {
var frag = Meteor.ui._doc.materialize(
calc(function(str, expected) {
return Meteor.ui._doc.annotate(str);
}));
var groups = [];
var html = calc(function(str, expected, noRange) {
if (arguments.length > 1)
str = expected;
if (! noRange)
groups.push(str);
return str;
});
test.equal(WrappedFrag(frag).html(), html);
var actualGroups = [];
var tempRange = new Meteor.ui._LiveRange(Meteor.ui._TAG, frag);
tempRange.visit(function(isStart, rng) {
if (! isStart)
actualGroups.push(Meteor.ui._rangeToHtml(rng));
});
test.equal(actualGroups.join(','), groups.join(','));
};
doTest(function(A) { return "<p>Hello</p>"; });
doTest(function(A) { return "<td>Hello</td><td>World</td>"; });
doTest(function(A) { return "<td>"+A("Hello")+"</td>"; });
doTest(function(A) { return A("<td>"+A("Hello")+"</td>"); });
doTest(function(A) { return A(A(A(A(A(A("foo")))))); });
doTest(
function(A) { return "<div>Yo"+A("<p>Hello "+A(A("World")),"<p>Hello World</p>")+
"</div>"; });
doTest(function(A) {
return A("<ul>"+A("<li>one","<li>one</li>")+
A("<li>two","<li>two</li>")+
A("<li>three","<li>three</li>"),
"<ul><li>one</li><li>two</li><li>three</li></ul>"); });
doTest(function(A) {
return A("<table>"+A("<tr>"+A("<td>"+A("Hi")+"</td>")+"</tr>")+"</table>",
"<table><tbody><tr><td>Hi</td></tr></tbody></table>");
});
test.throws(function() {
doTest(function(A) {
var z = A("Hello");
return z+z;
});
});
doTest(function(A) {
return '<div foo="'+A('bar', 'bar', true)+'">Hello</div>';
});
});

View File

@@ -1,32 +1,6 @@
(function() {
///// WrappedFrag /////
var WrappedFrag = function(frag) {
if (! (this instanceof WrappedFrag))
return new WrappedFrag(frag);
this.frag = frag;
};
WrappedFrag.prototype.rawHtml = function() {
return Meteor.ui._fragmentToHtml(this.frag);
};
WrappedFrag.prototype.html = function() {
return canonicalizeHtml(this.rawHtml());
};
WrappedFrag.prototype.hold = function() {
return Meteor.ui._Sarge.holdFrag(this.frag), this;
};
WrappedFrag.prototype.release = function() {
return Meteor.ui._Sarge.releaseFrag(this.frag), this;
};
WrappedFrag.prototype.node = function() {
return this.frag;
};
///// MISC /////
var legacyLabels = {
'*[id], #[name]': function(n) {
var label = null;

View File

@@ -16,6 +16,7 @@ Package.on_use(function (api) {
api.add_files(['domutils.js'], 'client');
api.add_files(['liveevents_w3c.js', 'liveevents_now3c.js'], 'client');
api.add_files(['liveevents.js'], 'client');
api.add_files(['livedocument.js'], 'client');
api.add_files(['liverange.js', 'liveui.js', 'innerhtml.js', 'patcher.js'],
'client');
});
@@ -27,6 +28,7 @@ Package.on_test(function (api) {
api.add_files('form_responder.js', 'server');
api.add_files([
'livedocument_tests.js',
'liverange_test_helpers.js',
'liveui_tests.js',
'liveui_tests.html',

View File

@@ -13,6 +13,7 @@ Package.on_use(function (api, where) {
api.add_files('canonicalize_html.js', where);
api.add_files('stub_stream.js', where);
api.add_files('onscreendiv.js', where);
api.add_files('wrappedfrag.js', where);
api.add_files('current_style.js', where);
api.add_files('reactivevar.js', where);
});

View File

@@ -0,0 +1,33 @@
// A WrappedFrag provides utility methods pertaining to a given
// DocumentFragment that are helpful in tests. For example,
// WrappedFrag(frag).html() constructs a sort of cross-browser
// innerHTML for the fragment.
// Constructor, with optional 'new':
// var f = [new] WrappedFrag([frag])
WrappedFrag = function(frag) {
if (! (this instanceof WrappedFrag))
return new WrappedFrag(frag);
this.frag = frag;
};
WrappedFrag.prototype.rawHtml = function() {
return Meteor.ui._fragmentToHtml(this.frag);
};
WrappedFrag.prototype.html = function() {
return canonicalizeHtml(this.rawHtml());
};
WrappedFrag.prototype.hold = function() {
return Meteor.ui._Sarge.holdFrag(this.frag), this;
};
WrappedFrag.prototype.release = function() {
return Meteor.ui._Sarge.releaseFrag(this.frag), this;
};
WrappedFrag.prototype.node = function() {
return this.frag;
};