diff --git a/packages/liveui/livedocument.js b/packages/liveui/livedocument.js
new file mode 100644
index 0000000000..d2e471da84
--- /dev/null
+++ b/packages/liveui/livedocument.js
@@ -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 = /|<|>|[^<>]+/g;
+ var RANGE_PARSE_REGEX = /^$/;
+ 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 . 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 = "";
+ }
+ }
+ }
+ // 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
+ 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 ("" +
+ html + "");
+ };
+
+})();
\ No newline at end of file
diff --git a/packages/liveui/livedocument_tests.js b/packages/liveui/livedocument_tests.js
new file mode 100644
index 0000000000..30eb642d3b
--- /dev/null
+++ b/packages/liveui/livedocument_tests.js
@@ -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 "
Hello
"; });
+ doTest(function(A) { return "Hello | World | "; });
+ doTest(function(A) { return ""+A("Hello")+" | "; });
+ doTest(function(A) { return A(""+A("Hello")+" | "); });
+ doTest(function(A) { return A(A(A(A(A(A("foo")))))); });
+ doTest(
+ function(A) { return "Yo"+A("
Hello "+A(A("World")),"
Hello World
")+
+ "
"; });
+ doTest(function(A) {
+ return A(""+A("- one","
- one
")+
+ A("- two","
- two
")+
+ A("- three","
- three
"),
+ ""); });
+
+ doTest(function(A) {
+ return A(""+A(""+A("| "+A("Hi")+" | ")+"
")+"
",
+ "");
+ });
+
+ test.throws(function() {
+ doTest(function(A) {
+ var z = A("Hello");
+ return z+z;
+ });
+ });
+
+ doTest(function(A) {
+ return 'Hello
';
+ });
+});
\ No newline at end of file
diff --git a/packages/liveui/liveui_tests.js b/packages/liveui/liveui_tests.js
index 028a512b6f..48b7a93b9f 100644
--- a/packages/liveui/liveui_tests.js
+++ b/packages/liveui/liveui_tests.js
@@ -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;
diff --git a/packages/liveui/package.js b/packages/liveui/package.js
index 3086be6849..a39a519b0e 100644
--- a/packages/liveui/package.js
+++ b/packages/liveui/package.js
@@ -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',
diff --git a/packages/test-helpers/package.js b/packages/test-helpers/package.js
index 506e12198c..e8e352a05a 100644
--- a/packages/test-helpers/package.js
+++ b/packages/test-helpers/package.js
@@ -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);
});
diff --git a/packages/test-helpers/wrappedfrag.js b/packages/test-helpers/wrappedfrag.js
new file mode 100644
index 0000000000..0694c51fcf
--- /dev/null
+++ b/packages/test-helpers/wrappedfrag.js
@@ -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;
+};