diff --git a/docs/client/api.html b/docs/client/api.html index 9b1e2b4e89..5e8a7b30f6 100644 --- a/docs/client/api.html +++ b/docs/client/api.html @@ -2036,14 +2036,7 @@ Example: When you write a template as `<{{! }}template name="foo"> ... <{{! }}/template>` in an HTML file in your app, Meteor generates a -"component object" named `Template.foo`. - -{{#note}} - Meteor's component API is currently in flux. This section documents a few - features of the component object that are useful for writing apps; a future - release will elaborate more about how components work and about how to build - components that aren't just template. -{{/note}} +"template object" named `Template.foo`. The same template may occur many times on a page, and these occurrences are called template instances. Template instances have a @@ -2214,30 +2207,35 @@ You can define helpers and event maps on `UI.body` just like on any {{> api_box ui_render}} -This returns an "instantiated component" object, which can be passed to -[`UI.insert`](#ui_insert). The template's [`created`](#template_created) callback -will be invoked. The component will continue to be updated reactively as the -data used changes. +This returns an "rendered template" object, which can be passed to +[`UI.insert`](#ui_insert). The template's +[`created`](#template_created) callback will be invoked. The rendered +template will continue to be updated reactively as the data used +changes. {{#warning}} - Future releases will provide a richer API for "instantiated components" - (probably unifying them with "template instances"). For now, all you can - do with them is pass them to `UI.insert`. + Future releases will provide a richer API for working with rendered + templates, for example unifying them "template instances." For now, all you + can do with them is pass them to `UI.insert`. +{{/warning}} - Most users will not need to manually render components or manually insert them - into the DOM at all. As of 0.8.0, if you call `UI.render` and never insert - the result into the DOM, the logic to keep the instantiated component updated +{{#warning}} + Most users will not need to manually render templates or manually insert them + into the DOM at all. As of 0.8.x, if you call `UI.render` and never insert + the result into the DOM, the logic to keep the rendered template updated will continue running in your browser forever. Additionally, if you remove any - part of your DOM using any mechanism other than jQuery, the logic to keep that - part of the the DOM updated will continue running. To avoid these issues, - either avoid directly updating the DOM or ensure that any removals go through - jQuery. + part of your DOM using any mechanism other than Meteor or jQuery, the logic + to keep that part of the the DOM updated will continue running. To avoid these + issues, either avoid directly updating the DOM or ensure that any removals go + through Meteor or jQuery. {{/warning}} {{> api_box ui_renderwithdata}} {{> api_box ui_insert}} +{{> api_box ui_remove}} + {{> api_box ui_getelementdata}} diff --git a/docs/client/api.js b/docs/client/api.js index b389f4fad9..a4c9a3a828 100644 --- a/docs/client/api.js +++ b/docs/client/api.js @@ -1866,7 +1866,7 @@ Template.api.ui_body = { id: "ui_body", name: "UI.body", locus: "Client", - descr: ["The [component object](#templates_api) representing your `` tag."] + descr: ["The [template object](#templates_api) representing your `` tag."] }; Template.api.ui_render = { @@ -1899,12 +1899,12 @@ Template.api.ui_renderwithdata = { Template.api.ui_insert = { id: "ui_insert", - name: "UI.insert(instantiatedComponent, parentNode[, nextNode])", + name: "UI.insert(renderedTemplate, parentNode[, nextNode])", locus: "Client", - descr: ["Inserts an instantiated component into the DOM and calls its [`rendered`](#template_rendered) callback."], + descr: ["Inserts a rendered template into the DOM and calls its [`rendered`](#template_rendered) callback."], args: [ - {name: "instantiatedComponent", - type: "Instantiated component object", + {name: "renderedTemplate", + type: "Rendered template object", descr: "The return value from `UI.render` or `UI.renderWithData`." }, {name: "parentNode", @@ -1917,6 +1917,19 @@ Template.api.ui_insert = { }] }; +Template.api.ui_remove = { + id: "ui_remove", + name: "UI.remove(renderedTemplate)", + locus: "Client", + descr: ["Removes a rendered template from the DOM and destroys it, calling the [`destroyed`](#template_destroyed) callback and stopping the logic that reactively updates the template."], + args: [ + {name: "renderedTemplate", + type: "Rendered template object", + descr: "The return value from `UI.render` or `UI.renderWithData`." + } + ] +}; + Template.api.ui_getelementdata = { id: "ui_getelementdata", name: "UI.getElementData(el)", diff --git a/docs/client/docs.js b/docs/client/docs.js index fdcdf640d8..bb3fc8eec8 100644 --- a/docs/client/docs.js +++ b/docs/client/docs.js @@ -261,6 +261,7 @@ var toc = [ "UI.render", "UI.renderWithData", "UI.insert", + "UI.remove", "UI.getElementData" ], {type: "spacer"}, diff --git a/examples/unfinished/blaze-test/.meteor/.gitignore b/examples/unfinished/blaze-test/.meteor/.gitignore new file mode 100644 index 0000000000..4083037423 --- /dev/null +++ b/examples/unfinished/blaze-test/.meteor/.gitignore @@ -0,0 +1 @@ +local diff --git a/examples/unfinished/blaze-test/.meteor/packages b/examples/unfinished/blaze-test/.meteor/packages new file mode 100644 index 0000000000..7f72e45268 --- /dev/null +++ b/examples/unfinished/blaze-test/.meteor/packages @@ -0,0 +1,11 @@ +# Meteor packages used by this project, one per line. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. + +standard-app-packages +autopublish +insecure +blaze +htmljs +blaze-tools diff --git a/examples/unfinished/blaze-test/.meteor/release b/examples/unfinished/blaze-test/.meteor/release new file mode 100644 index 0000000000..621e94f0ec --- /dev/null +++ b/examples/unfinished/blaze-test/.meteor/release @@ -0,0 +1 @@ +none diff --git a/examples/unfinished/blaze-test/client/blaze-test.css b/examples/unfinished/blaze-test/client/blaze-test.css new file mode 100644 index 0000000000..b6b4052b43 --- /dev/null +++ b/examples/unfinished/blaze-test/client/blaze-test.css @@ -0,0 +1 @@ +/* CSS declarations go here */ diff --git a/examples/unfinished/blaze-test/client/blaze-test.html b/examples/unfinished/blaze-test/client/blaze-test.html new file mode 100644 index 0000000000..4cbb558b2e --- /dev/null +++ b/examples/unfinished/blaze-test/client/blaze-test.html @@ -0,0 +1,7 @@ + + blaze-test + + + + + diff --git a/examples/unfinished/blaze-test/client/blaze-test.js b/examples/unfinished/blaze-test/client/blaze-test.js new file mode 100644 index 0000000000..92761e3cdc --- /dev/null +++ b/examples/unfinished/blaze-test/client/blaze-test.js @@ -0,0 +1,159 @@ +Meteor.startup(function () { + +Blaze._wrapAutorun = function (c) { + console.log('Created #' + c._id); + var callback = function () { + if (c.stopped) { + console.log('Stopped #' + c._id); + } else { + console.log('Invalidated #' + c._id); + Deps.afterFlush(function () { + c.onInvalidate(callback); + }); + } + }; + c.onInvalidate(callback); +}; + +theNumber = Blaze.Var(0); +theColor = Blaze.Var('yellow'); + +If = function (conditionVar, contentFunc, elseFunc) { + return Blaze.Isolate(function () { + return conditionVar.get() ? contentFunc() : + (elseFunc ? elseFunc() : null); + }); +}; + +With = function (dataVar, func) { + if (! (this instanceof With)) + // called without new + return new With(dataVar, func); + + Blaze.Controller.call(this); + + this.data = dataVar; + this.func = func; +}; +Blaze.__extends(With, Blaze.Controller); +_.extend(With.prototype, { + render: function () { + var func = this.func; + return func(); + } +}); + +Events = function (eventMap, func) { + if (! (this instanceof Events)) + // called without new + return new Events(eventMap, func); + + Blaze.Controller.call(this); + + this.eventMap = eventMap; + this.func = func; +}; +Blaze.__extends(Events, Blaze.Controller); +_.extend(Events.prototype, { + render: function () { + var func = this.func; + return func(); + }, + renderToDOM: function () { + var range = Blaze.Controller.prototype.renderToDOM.call(this); + range.addDOMAugmenter(new Blaze.EventAugmenter(this.eventMap)); + return range; + } +}); + +Repeat = function (countVar, contentFunc) { + var seq, count; + var comp = Deps.autorun(function () { + if (! seq) { + count = countVar.get(); + if (typeof count !== 'number') + throw new Error("Expected number"); + var funcs = new Array(count); + for (var i = 0; i < count; i++) + funcs[i] = contentFunc; + seq = new Blaze.Sequence(funcs); + } else { + var targetCount = countVar.get(); + while (count < targetCount) { + seq.addItem(contentFunc, count); + count++; + } + while (count > targetCount) { + seq.removeItem(count-1); + count--; + } + } + }); + Blaze._wrapAutorun(comp); + return Blaze.List(seq); +}; + +Ticker = function () { + var self = this; + Blaze.Component.call(self); + self.time = Blaze.Var(new Date); + self.timer = setInterval(function () { + self.time.set(new Date); + }, 1000); +}; +Blaze.__extends(Ticker, Blaze.Component); +_.extend(Ticker.prototype, { + render: function () { + var self = this; + return Blaze.Isolate(function () { + return String(self.time.get()); + }); + }, + finalize: function () { + clearInterval(this.timer); + } +}); + +outerRange = Blaze.render(function () { + return [HTML.DIV( + {style: If(Blaze.Var(function () { return theNumber.get() % 3 === 0; }), + function () { return ['background:', theColor.get()]; })}, + "The number ", Blaze.Isolate(function () { return theNumber.get(); }), " is ", + If(Blaze.Var(function () { + return theNumber.get() % 2 === 0; + }), function () { + return "even"; + }, function () { + return "odd"; + }), "."), + HTML.UL( + Repeat(theNumber, + function () { + return With(Blaze.Var(123), function () { + return Events( + {'click li': function () { console.log('click li'); }}, + function () { + return HTML.LI( + Blaze.Isolate(function () { + console.log('Context:', Blaze.currentController.parentController.data.get()); + return theNumber.get(); }), + " - ", new Ticker + ); + }); + }); + }))]; + +}); +outerRange.attach(document.body); + + +// Now, run: +// +// ``` +// theNumber.set(1); +// theNumber.set(2); +// +// outerRange.stop(); +// ``` + +}); diff --git a/packages/spacebars-common/.gitignore b/packages/blaze-tools/.gitignore similarity index 100% rename from packages/spacebars-common/.gitignore rename to packages/blaze-tools/.gitignore diff --git a/packages/blaze-tools/package.js b/packages/blaze-tools/package.js new file mode 100644 index 0000000000..26338f6f90 --- /dev/null +++ b/packages/blaze-tools/package.js @@ -0,0 +1,24 @@ +Package.describe({ + summary: "Compile-time tools for Blaze", + internal: true +}); + +Package.on_use(function (api) { + api.export('BlazeTools'); + + api.use('htmljs'); + api.use('underscore'); + + api.add_files(['preamble.js', + 'tokens.js', + 'tojs.js']); +}); + +Package.on_test(function (api) { + api.use('blaze-tools'); + api.use('tinytest'); + api.use('underscore'); + api.use('html-tools'); + + api.add_files(['token_tests.js']); +}); diff --git a/packages/blaze-tools/preamble.js b/packages/blaze-tools/preamble.js new file mode 100644 index 0000000000..2211e646ab --- /dev/null +++ b/packages/blaze-tools/preamble.js @@ -0,0 +1 @@ +BlazeTools = {}; diff --git a/packages/blaze-tools/tojs.js b/packages/blaze-tools/tojs.js new file mode 100644 index 0000000000..730dc24d54 --- /dev/null +++ b/packages/blaze-tools/tojs.js @@ -0,0 +1,156 @@ + +BlazeTools.EmitCode = function (value) { + if (! (this instanceof BlazeTools.EmitCode)) + // called without `new` + return new BlazeTools.EmitCode(value); + + if (typeof value !== 'string') + throw new Error('BlazeTools.EmitCode must be constructed with a string'); + + this.value = value; +}; +BlazeTools.EmitCode.prototype.toJS = function (visitor) { + return this.value; +}; + +// Turns any JSONable value into a JavaScript literal. +toJSLiteral = function (obj) { + // See for `\u2028\u2029`. + // Also escape Unicode surrogates. + return (JSON.stringify(obj) + .replace(/[\u2028\u2029\ud800-\udfff]/g, function (c) { + return '\\u' + ('000' + c.charCodeAt(0).toString(16)).slice(-4); + })); +}; +BlazeTools.toJSLiteral = toJSLiteral; + + + +var jsReservedWordSet = (function (set) { + _.each("abstract else instanceof super boolean enum int switch break export interface synchronized byte extends let this case false long throw catch final native throws char finally new transient class float null true const for package try continue function private typeof debugger goto protected var default if public void delete implements return volatile do import short while double in static with".split(' '), function (w) { + set[w] = 1; + }); + return set; +})({}); + +toObjectLiteralKey = function (k) { + if (/^[a-zA-Z$_][a-zA-Z$0-9_]*$/.test(k) && jsReservedWordSet[k] !== 1) + return k; + return toJSLiteral(k); +}; +BlazeTools.toObjectLiteralKey = toObjectLiteralKey; + +var hasToJS = function (x) { + return x.toJS && (typeof (x.toJS) === 'function'); +}; + +ToJSVisitor = HTML.Visitor.extend(); +ToJSVisitor.def({ + visitNull: function (nullOrUndefined) { + return 'null'; + }, + visitPrimitive: function (stringBooleanOrNumber) { + return toJSLiteral(stringBooleanOrNumber); + }, + visitArray: function (array) { + var parts = []; + for (var i = 0; i < array.length; i++) + parts.push(this.visit(array[i])); + return '[' + parts.join(', ') + ']'; + }, + visitTag: function (tag) { + return this.generateCall(tag.tagName, tag.attrs, tag.children); + }, + visitComment: function (comment) { + return this.generateCall('HTML.Comment', null, [comment.value]); + }, + visitCharRef: function (charRef) { + return this.generateCall('HTML.CharRef', + {html: charRef.html, str: charRef.str}); + }, + visitRaw: function (raw) { + return this.generateCall('HTML.Raw', null, [raw.value]); + }, + visitObject: function (x) { + if (hasToJS(x)) { + return x.toJS(this); + } + + throw new Error("Unexpected object in HTMLjs in toJS: " + x); + }, + generateCall: function (name, attrs, children) { + var tagSymbol; + if (name.indexOf('.') >= 0) { + tagSymbol = name; + } else if (HTML.isTagEnsured(name)) { + tagSymbol = 'HTML.' + HTML.getSymbolName(name); + } else { + tagSymbol = 'HTML.getTag(' + toJSLiteral(name) + ')'; + } + + var attrsArray = null; + if (attrs) { + attrsArray = []; + var needsHTMLAttrs = false; + if (HTML.isArray(attrs)) { + var attrsArray = []; + for (var i = 0; i < attrs.length; i++) { + var a = attrs[i]; + if (hasToJS(a)) { + attrsArray.push(a.toJS(this)); + needsHTMLAttrs = true; + } else { + var attrsObjStr = this.generateAttrsDictionary(attrs[i]); + if (attrsObjStr !== null) + attrsArray.push(attrsObjStr); + } + } + } else if (hasToJS(attrs)) { + attrsArray.push(attrs.toJS(this)); + needsHTMLAttrs = true; + } else { + attrsArray.push(this.generateAttrsDictionary(attrs)); + } + } + var attrsStr = null; + if (attrsArray && attrsArray.length) { + if (attrsArray.length === 1 && ! needsHTMLAttrs) { + attrsStr = attrsArray[0]; + } else { + attrsStr = 'HTML.Attrs(' + attrsArray.join(', ') + ')'; + } + } + + var argStrs = []; + if (attrsStr !== null) + argStrs.push(attrsStr); + + if (children) { + for (var i = 0; i < children.length; i++) + argStrs.push(this.visit(children[i])); + } + + return tagSymbol + '(' + argStrs.join(', ') + ')'; + }, + generateAttrsDictionary: function (attrsDict) { + if (attrsDict.toJS && (typeof (attrsDict.toJS) === 'function')) { + // not an attrs dictionary, but something else! Like a template tag. + return attrsDict.toJS(this); + } + + var kvStrs = []; + for (var k in attrsDict) { + if (! HTML.isNully(attrsDict[k])) + kvStrs.push(toObjectLiteralKey(k) + ': ' + + this.visit(attrsDict[k])); + } + if (kvStrs.length) + return '{' + kvStrs.join(', ') + '}'; + return null; + } +}); +BlazeTools.ToJSVisitor = ToJSVisitor; + +BlazeTools.toJS = function (content) { + return (new ToJSVisitor).visit(content); +}; diff --git a/packages/spacebars-compiler/token_tests.js b/packages/blaze-tools/token_tests.js similarity index 90% rename from packages/spacebars-compiler/token_tests.js rename to packages/blaze-tools/token_tests.js index c85f0c9f66..ba8d0929c1 100644 --- a/packages/spacebars-compiler/token_tests.js +++ b/packages/blaze-tools/token_tests.js @@ -1,4 +1,4 @@ -Tinytest.add("spacebars - token parsers", function (test) { +Tinytest.add("blaze-tools - token parsers", function (test) { var run = function (func, input, expected) { var scanner = new HTMLTools.Scanner('z' + input); @@ -23,9 +23,9 @@ Tinytest.add("spacebars - token parsers", function (test) { run(func, input, expected); }; - var parseNumber = Spacebars._$.parseNumber; - var parseIdentifierName = Spacebars._$.parseIdentifierName; - var parseStringLiteral = Spacebars._$.parseStringLiteral; + var parseNumber = BlazeTools.parseNumber; + var parseIdentifierName = BlazeTools.parseIdentifierName; + var parseStringLiteral = BlazeTools.parseStringLiteral; runValue(parseNumber, "0", 0); runValue(parseNumber, "-0", 0); diff --git a/packages/spacebars-compiler/tokens.js b/packages/blaze-tools/tokens.js similarity index 98% rename from packages/spacebars-compiler/tokens.js rename to packages/blaze-tools/tokens.js index 2a34585944..5f52a0a760 100644 --- a/packages/spacebars-compiler/tokens.js +++ b/packages/blaze-tools/tokens.js @@ -52,7 +52,7 @@ var rLineContinuation = /^\\(\r\n|[\u000A\u000D\u2028\u2029])/; -parseNumber = function (scanner) { +BlazeTools.parseNumber = function (scanner) { var startPos = scanner.pos; var isNegative = false; @@ -77,7 +77,7 @@ parseNumber = function (scanner) { return { text: text, value: value }; }; -parseIdentifierName = function (scanner) { +BlazeTools.parseIdentifierName = function (scanner) { var startPos = scanner.pos; var rest = scanner.rest(); var match = rIdentifierPrefix.exec(rest); @@ -108,7 +108,7 @@ parseIdentifierName = function (scanner) { return scanner.input.substring(startPos, scanner.pos); }; -parseStringLiteral = function (scanner) { +BlazeTools.parseStringLiteral = function (scanner) { var startPos = scanner.pos; var rest = scanner.rest(); var match = rStringQuote.exec(rest); @@ -175,10 +175,3 @@ parseStringLiteral = function (scanner) { var value = JSON.parse(jsonLiteral); return { text: text, value: value }; }; - -// expose for testing -Spacebars._$ = { - parseNumber: parseNumber, - parseIdentifierName: parseIdentifierName, - parseStringLiteral: parseStringLiteral -}; diff --git a/packages/blaze/.gitignore b/packages/blaze/.gitignore new file mode 100644 index 0000000000..677a6fc263 --- /dev/null +++ b/packages/blaze/.gitignore @@ -0,0 +1 @@ +.build* diff --git a/packages/ui/attrs.js b/packages/blaze/attrs.js similarity index 99% rename from packages/ui/attrs.js rename to packages/blaze/attrs.js index a15e2e7a6e..d3c5e0f657 100644 --- a/packages/ui/attrs.js +++ b/packages/blaze/attrs.js @@ -315,7 +315,7 @@ ElementAttributesUpdater.prototype.update = function(newAttrs) { var handlers = this.handlers; for (var k in handlers) { - if (! newAttrs.hasOwnProperty(k)) { + if (! _.has(newAttrs, k)) { // remove attributes (and handlers) for attribute names // that don't exist as keys of `newAttrs` and so won't // be visited when traversing it. (Attributes that @@ -333,7 +333,7 @@ ElementAttributesUpdater.prototype.update = function(newAttrs) { var handler = null; var oldValue; var value = newAttrs[k]; - if (! handlers.hasOwnProperty(k)) { + if (! _.has(handlers, k)) { if (value !== null) { // make new handler handler = makeAttributeHandler(elem, k, value); diff --git a/packages/blaze/builtins.js b/packages/blaze/builtins.js new file mode 100644 index 0000000000..524171af48 --- /dev/null +++ b/packages/blaze/builtins.js @@ -0,0 +1,175 @@ +Blaze._calculateCondition = function (cond) { + if (cond instanceof Array && cond.length === 0) + cond = false; + return !! cond; +}; + +Blaze.With = function (data, contentFunc) { + var view = Blaze.View('with', contentFunc); + + view.dataVar = new Blaze.ReactiveVar; + + view.onCreated(function () { + if (typeof data === 'function') { + // `data` is a reactive function + view.autorun(function () { + view.dataVar.set(data()); + }, view.parentView); + } else { + view.dataVar.set(data); + } + }); + + return view; +}; + +Blaze.If = function (conditionFunc, contentFunc, elseFunc, _not) { + var conditionVar = new Blaze.ReactiveVar; + + var view = Blaze.View(_not ? 'unless' : 'if', function () { + return conditionVar.get() ? contentFunc() : + (elseFunc ? elseFunc() : null); + }); + view.__conditionVar = conditionVar; + view.onCreated(function () { + this.autorun(function () { + var cond = Blaze._calculateCondition(conditionFunc()); + conditionVar.set(_not ? (! cond) : cond); + }, this.parentView); + }); + + return view; +}; + +Blaze.Unless = function (conditionFunc, contentFunc, elseFunc) { + return Blaze.If(conditionFunc, contentFunc, elseFunc, true /*_not*/); +}; + +Blaze.Each = function (argFunc, contentFunc, elseFunc) { + var eachView = Blaze.View('each', function () { + var subviews = this.initialSubviews; + this.initialSubviews = null; + if (this.isCreatedForExpansion) { + this.expandedValueDep = new Deps.Dependency; + this.expandedValueDep.depend(); + } + return subviews; + }); + eachView.initialSubviews = []; + eachView.numItems = 0; + eachView.inElseMode = false; + eachView.stopHandle = null; + eachView.contentFunc = contentFunc; + eachView.elseFunc = elseFunc; + eachView.argVar = new Blaze.ReactiveVar; + + eachView.onCreated(function () { + // We evaluate argFunc in an autorun to make sure + // Blaze.currentView is always set when it runs (rather than + // passing argFunc straight to ObserveSequence). + eachView.autorun(function () { + eachView.argVar.set(argFunc()); + }, eachView.parentView); + + eachView.stopHandle = ObserveSequence.observe(function () { + return eachView.argVar.get(); + }, { + addedAt: function (id, item, index) { + Deps.nonreactive(function () { + var newItemView = Blaze.With(item, eachView.contentFunc); + eachView.numItems++; + + if (eachView.expandedValueDep) { + eachView.expandedValueDep.changed(); + } else if (eachView.domrange) { + if (eachView.inElseMode) { + eachView.domrange.removeMember(0); + eachView.inElseMode = false; + } + + var range = Blaze.materializeView(newItemView, eachView); + eachView.domrange.addMember(range, index); + } else { + eachView.initialSubviews.splice(index, 0, newItemView); + } + }); + }, + removedAt: function (id, item, index) { + Deps.nonreactive(function () { + eachView.numItems--; + if (eachView.expandedValueDep) { + eachView.expandedValueDep.changed(); + } else if (eachView.domrange) { + eachView.domrange.removeMember(index); + if (eachView.elseFunc && eachView.numItems === 0) { + eachView.inElseMode = true; + eachView.domrange.addMember( + Blaze.materializeView( + Blaze.View('each_else',eachView.elseFunc), + eachView), 0); + } + } else { + eachView.initialSubviews.splice(index, 1); + } + }); + }, + changedAt: function (id, newItem, oldItem, index) { + Deps.nonreactive(function () { + var itemView; + if (eachView.expandedValueDep) { + eachView.expandedValueDep.changed(); + } else if (eachView.domrange) { + itemView = eachView.domrange.getMember(index).view; + } else { + itemView = eachView.initialSubviews[index]; + } + itemView.dataVar.set(newItem); + }); + }, + movedTo: function (id, item, fromIndex, toIndex) { + Deps.nonreactive(function () { + if (eachView.expandedValueDep) { + eachView.expandedValueDep.changed(); + } else if (eachView.domrange) { + eachView.domrange.moveMember(fromIndex, toIndex); + } else { + var subviews = eachView.initialSubviews; + var itemView = subviews[fromIndex]; + subviews.splice(fromIndex, 1); + subviews.splice(toIndex, 0, itemView); + } + }); + } + }); + + if (eachView.elseFunc && eachView.numItems === 0) { + eachView.inElseMode = true; + eachView.initialSubviews[0] = + Blaze.View('each_else', eachView.elseFunc); + } + }); + + eachView.onDestroyed(function () { + if (eachView.stopHandle) + eachView.stopHandle.stop(); + }); + + return eachView; +}; + +Blaze.InOuterTemplateScope = function (templateView, contentFunc) { + var view = Blaze.View('InOuterTemplateScope', contentFunc); + var parentView = templateView.parentView; + + // Hack so that if you call `{{> foo bar}}` and it expands into + // `{{#with bar}}{{> foo}}{{/with}}`, and then `foo` is a template + // that inserts `{{> UI.contentBlock}}`, the data context for + // `UI.contentBlock` is not `bar` but the one enclosing that. + if (parentView.__isTemplateWith) + parentView = parentView.parentView; + + view.onCreated(function () { + this.parentView = parentView; + }); + return view; +}; diff --git a/packages/blaze/dombackend.js b/packages/blaze/dombackend.js new file mode 100644 index 0000000000..f0547de967 --- /dev/null +++ b/packages/blaze/dombackend.js @@ -0,0 +1,173 @@ +var DOMBackend = {}; +Blaze.DOMBackend = DOMBackend; + +var $jq = (typeof jQuery !== 'undefined' ? jQuery : + (typeof Package !== 'undefined' ? + Package.jquery && Package.jquery.jQuery : null)); +if (! $jq) + throw new Error("jQuery not found"); + +DOMBackend._$jq = $jq; + +DOMBackend.parseHTML = function (html) { + // Return an array of nodes. + // + // jQuery does fancy stuff like creating an appropriate + // container element and setting innerHTML on it, as well + // as working around various IE quirks. + return $jq.parseHTML(html) || []; +}; + +DOMBackend.Events = { + // `selector` is non-null. `type` is one type (but + // may be in backend-specific form, e.g. have namespaces). + // Order fired must be order bound. + delegateEvents: function (elem, type, selector, handler) { + $jq(elem).on(type, selector, handler); + }, + + undelegateEvents: function (elem, type, handler) { + $jq(elem).off(type, '**', handler); + }, + + bindEventCapturer: function (elem, type, selector, handler) { + var $elem = $jq(elem); + + var wrapper = function (event) { + event = $jq.event.fix(event); + event.currentTarget = event.target; + + // Note: It might improve jQuery interop if we called into jQuery + // here somehow. Since we don't use jQuery to dispatch the event, + // we don't fire any of jQuery's event hooks or anything. However, + // since jQuery can't bind capturing handlers, it's not clear + // where we would hook in. Internal jQuery functions like `dispatch` + // are too high-level. + var $target = $jq(event.currentTarget); + if ($target.is($elem.find(selector))) + handler.call(elem, event); + }; + + handler._meteorui_wrapper = wrapper; + + type = DOMBackend.Events.parseEventType(type); + // add *capturing* event listener + elem.addEventListener(type, wrapper, true); + }, + + unbindEventCapturer: function (elem, type, handler) { + type = DOMBackend.Events.parseEventType(type); + elem.removeEventListener(type, handler._meteorui_wrapper, true); + }, + + parseEventType: function (type) { + // strip off namespaces + var dotLoc = type.indexOf('.'); + if (dotLoc >= 0) + return type.slice(0, dotLoc); + return type; + } +}; + + +///// Removal detection and interoperability. + +// For an explanation of this technique, see: +// http://bugs.jquery.com/ticket/12213#comment:23 . +// +// In short, an element is considered "removed" when jQuery +// cleans up its *private* userdata on the element, +// which we can detect using a custom event with a teardown +// hook. + +var NOOP = function () {}; + +// Circular doubly-linked list +var TeardownCallback = function (func) { + this.next = this; + this.prev = this; + this.func = func; +}; + +// Insert newElt before oldElt in the circular list +TeardownCallback.prototype.linkBefore = function(oldElt) { + this.prev = oldElt.prev; + this.next = oldElt; + oldElt.prev.next = this; + oldElt.prev = this; +}; + +TeardownCallback.prototype.unlink = function () { + this.prev.next = this.next; + this.next.prev = this.prev; +}; + +TeardownCallback.prototype.go = function () { + var func = this.func; + func && func(); +}; + +TeardownCallback.prototype.stop = TeardownCallback.prototype.unlink; + +DOMBackend.Teardown = { + _JQUERY_EVENT_NAME: 'blaze_teardown_watcher', + _CB_PROP: '$blaze_teardown_callbacks', + // Registers a callback function to be called when the given element or + // one of its ancestors is removed from the DOM via the backend library. + // The callback function is called at most once, and it receives the element + // in question as an argument. + onElementTeardown: function (elem, func) { + var elt = new TeardownCallback(func); + + var propName = DOMBackend.Teardown._CB_PROP; + if (! elem[propName]) { + // create an empty node that is never unlinked + elem[propName] = new TeardownCallback; + + // Set up the event, only the first time. + $jq(elem).on(DOMBackend.Teardown._JQUERY_EVENT_NAME, NOOP); + } + + elt.linkBefore(elem[propName]); + + return elt; // so caller can call stop() + }, + // Recursively call all teardown hooks, in the backend and registered + // through DOMBackend.onElementTeardown. + tearDownElement: function (elem) { + var elems = Array.prototype.slice.call(elem.getElementsByTagName('*')); + elems.push(elem); + $jq.cleanData(elems); + } +}; + +$jq.event.special[DOMBackend.Teardown._JQUERY_EVENT_NAME] = { + setup: function () { + // This "setup" callback is important even though it is empty! + // Without it, jQuery will call addEventListener, which is a + // performance hit, especially with Chrome's async stack trace + // feature enabled. + }, + teardown: function() { + var elem = this; + var callbacks = elem[DOMBackend.Teardown._CB_PROP]; + if (callbacks) { + var elt = callbacks.next; + while (elt !== callbacks) { + elt.go(); + elt = elt.next; + } + callbacks.go(); + + elem[DOMBackend.Teardown._CB_PROP] = null; + } + } +}; + + +// Must use jQuery semantics for `context`, not +// querySelectorAll's. In other words, all the parts +// of `selector` must be found under `context`. +DOMBackend.findBySelector = function (selector, context) { + return $jq(selector, context); +}; diff --git a/packages/blaze/domrange.js b/packages/blaze/domrange.js new file mode 100644 index 0000000000..0108bf6c3f --- /dev/null +++ b/packages/blaze/domrange.js @@ -0,0 +1,453 @@ + +// A constant empty array (frozen if the JS engine supports it). +var _emptyArray = Object.freeze ? Object.freeze([]) : []; + +// `[new] Blaze.DOMRange([nodeAndRangeArray])` +// +// A DOMRange consists of an array of consecutive nodes and DOMRanges, +// which may be replaced at any time with a new array. If the DOMRange +// has been attached to the DOM at some location, then updating +// the array will cause the DOM to be updated at that location. +Blaze.DOMRange = function (nodeAndRangeArray) { + if (! (this instanceof DOMRange)) + // called without `new` + return new DOMRange(nodeAndRangeArray); + + var members = (nodeAndRangeArray || _emptyArray); + if (! (members && (typeof members.length) === 'number')) + throw new Error("Expected array"); + + for (var i = 0; i < members.length; i++) + this._memberIn(members[i]); + + this.members = members; + this.emptyRangePlaceholder = null; + this.attached = false; + this.parentElement = null; + this.parentRange = null; + this.attachedCallbacks = _emptyArray; +}; +var DOMRange = Blaze.DOMRange; + +// static methods +DOMRange._insert = function (rangeOrNode, parentElement, nextNode, _isMove) { + var m = rangeOrNode; + if (m instanceof DOMRange) { + m.attach(parentElement, nextNode, _isMove); + } else { + if (_isMove) + DOMRange._moveNodeWithHooks(m, parentElement, nextNode); + else + DOMRange._insertNodeWithHooks(m, parentElement, nextNode); + } +}; + +DOMRange._remove = function (rangeOrNode) { + var m = rangeOrNode; + if (m instanceof DOMRange) { + m.detach(); + } else { + DOMRange._removeNodeWithHooks(m); + } +}; + +DOMRange._removeNodeWithHooks = function (n) { + if (! n.parentNode) + return; + if (n.nodeType === 1 && + n.parentNode._uihooks && n.parentNode._uihooks.removeElement) { + n.parentNode._uihooks.removeElement(n); + } else { + n.parentNode.removeChild(n); + } +}; + +DOMRange._insertNodeWithHooks = function (n, parent, next) { + // `|| null` because IE throws an error if 'next' is undefined + next = next || null; + if (n.nodeType === 1 && + parent._uihooks && parent._uihooks.insertElement) { + parent._uihooks.insertElement(n, next); + } else { + parent.insertBefore(n, next); + } +}; + +DOMRange._moveNodeWithHooks = function (n, parent, next) { + if (! n.parentNode) + return; + // `|| null` because IE throws an error if 'next' is undefined + next = next || null; + if (n.nodeType === 1 && + parent._uihooks && parent._uihooks.moveElement) { + parent._uihooks.moveElement(n, next); + } else { + parent.insertBefore(n, next); + } +}; + +DOMRange.forElement = function (elem) { + if (elem.nodeType !== 1) + throw new Error("Expected element, found: " + elem); + var range = null; + while (elem && ! range) { + range = (elem.$blaze_range || null); + if (! range) + elem = elem.parentNode; + } + return range; +}; + +DOMRange.prototype.attach = function (parentElement, nextNode, _isMove) { + // This method is called to insert the DOMRange into the DOM for + // the first time, but it's also used internally when + // updating the DOM. + // + // If _isMove is true, move this attached range to a different + // location under the same parentElement. + if (_isMove) { + if (! (this.parentElement === parentElement && + this.attached)) + throw new Error("Can only move an attached DOMRange, and only under the same parent element"); + } + + var members = this.members; + if (members.length) { + this.emptyRangePlaceholder = null; + for (var i = 0; i < members.length; i++) { + DOMRange._insert(members[i], parentElement, nextNode, _isMove); + } + } else { + var placeholder = document.createTextNode(""); + this.emptyRangePlaceholder = placeholder; + parentElement.insertBefore(placeholder, nextNode || null); + } + this.attached = true; + this.parentElement = parentElement; + + if (! _isMove) { + for(var i = 0; i < this.attachedCallbacks.length; i++) { + var obj = this.attachedCallbacks[i]; + obj.attached && obj.attached(this, parentElement); + } + } +}; + +DOMRange.prototype.setMembers = function (newNodeAndRangeArray) { + var newMembers = newNodeAndRangeArray; + if (! (newMembers && (typeof newMembers.length) === 'number')) + throw new Error("Expected array"); + + var oldMembers = this.members; + + for (var i = 0; i < oldMembers.length; i++) + this._memberOut(oldMembers[i]); + for (var i = 0; i < newMembers.length; i++) + this._memberIn(newMembers[i]); + + if (! this.attached) { + this.members = newMembers; + } else { + // don't do anything if we're going from empty to empty + if (newMembers.length || oldMembers.length) { + // detach the old members and insert the new members + var nextNode = this.lastNode().nextSibling; + var parentElement = this.parentElement; + this.detach(); + this.members = newMembers; + this.attach(parentElement, nextNode); + } + } +}; + +DOMRange.prototype.firstNode = function () { + if (! this.attached) + throw new Error("Must be attached"); + + if (! this.members.length) + return this.emptyRangePlaceholder; + + var m = this.members[0]; + return (m instanceof DOMRange) ? m.firstNode() : m; +}; + +DOMRange.prototype.lastNode = function () { + if (! this.attached) + throw new Error("Must be attached"); + + if (! this.members.length) + return this.emptyRangePlaceholder; + + var m = this.members[this.members.length - 1]; + return (m instanceof DOMRange) ? m.lastNode() : m; +}; + +DOMRange.prototype.detach = function () { + if (! this.attached) + throw new Error("Must be attached"); + + var oldParentElement = this.parentElement; + var members = this.members; + if (members.length) { + for (var i = 0; i < members.length; i++) { + DOMRange._remove(members[i]); + } + } else { + var placeholder = this.emptyRangePlaceholder; + this.parentElement.removeChild(placeholder); + this.emptyRangePlaceholder = null; + } + this.attached = false; + this.parentElement = null; + + for(var i = 0; i < this.attachedCallbacks.length; i++) { + var obj = this.attachedCallbacks[i]; + obj.detached && obj.detached(this, oldParentElement); + } +}; + +DOMRange.prototype.addMember = function (newMember, atIndex, _isMove) { + var members = this.members; + if (! (atIndex >= 0 && atIndex <= members.length)) + throw new Error("Bad index in range.addMember: " + atIndex); + + if (! _isMove) + this._memberIn(newMember); + + if (! this.attached) { + // currently detached; just updated members + members.splice(atIndex, 0, newMember); + } else if (members.length === 0) { + // empty; use the empty-to-nonempty handling of setMembers + this.setMembers([newMember]); + } else { + var nextNode; + if (atIndex === members.length) { + // insert at end + nextNode = this.lastNode().nextSibling; + } else { + var m = members[atIndex]; + nextNode = (m instanceof DOMRange) ? m.firstNode() : m; + } + members.splice(atIndex, 0, newMember); + DOMRange._insert(newMember, this.parentElement, nextNode, _isMove); + } +}; + +DOMRange.prototype.removeMember = function (atIndex, _isMove) { + var members = this.members; + if (! (atIndex >= 0 && atIndex < members.length)) + throw new Error("Bad index in range.removeMember: " + atIndex); + + if (_isMove) { + members.splice(atIndex, 1); + } else { + var oldMember = members[atIndex]; + this._memberOut(oldMember); + + if (members.length === 1) { + // becoming empty; use the logic in setMembers + this.setMembers(_emptyArray); + } else { + members.splice(atIndex, 1); + if (this.attached) + DOMRange._remove(oldMember); + } + } +}; + +DOMRange.prototype.moveMember = function (oldIndex, newIndex) { + var member = this.members[oldIndex]; + this.removeMember(oldIndex, true /*_isMove*/); + this.addMember(member, newIndex, true /*_isMove*/); +}; + +DOMRange.prototype.getMember = function (atIndex) { + var members = this.members; + if (! (atIndex >= 0 && atIndex < members.length)) + throw new Error("Bad index in range.getMember: " + atIndex); + return this.members[atIndex]; +}; + +DOMRange.prototype._memberIn = function (m) { + if (m instanceof DOMRange) + m.parentRange = this; + else if (m.nodeType === 1) // DOM Element + m.$blaze_range = this; +}; + +DOMRange._destroy = function (m, _skipNodes) { + if (m instanceof DOMRange) { + if (m.view) + Blaze.destroyView(m.view, _skipNodes); + m.parentRange = null; + } else if ((! _skipNodes) && m.nodeType === 1) { + // DOM Element + if (m.$blaze_range) { + Blaze.destroyNode(m); + m.$blaze_range = null; + } + } +}; + +DOMRange.prototype._memberOut = DOMRange._destroy; + +// Tear down, but don't remove, the members. Used when chunks +// of DOM are being torn down or replaced. +DOMRange.prototype.destroyMembers = function (_skipNodes) { + var members = this.members; + for (var i = 0; i < members.length; i++) + this._memberOut(members[i], _skipNodes); +}; + +DOMRange.prototype.destroy = function (_skipNodes) { + DOMRange._destroy(this, _skipNodes); +}; + +DOMRange.prototype.containsElement = function (elem) { + if (! this.attached) + throw new Error("Must be attached"); + + // An element is contained in this DOMRange if it's possible to + // reach it by walking parent pointers, first through the DOM and + // then parentRange pointers. In other words, the element or some + // ancestor of it is at our level of the DOM (a child of our + // parentElement), and this element is one of our members or + // is a member of a descendant Range. + + if (! Blaze._elementContains(this.parentElement, elem)) + return false; + + while (elem.parentNode !== this.parentElement) + elem = elem.parentElement; + + var range = elem.$blaze_range; + while (range && range !== this) + range = range.parentRange; + + return range === this; +}; + +DOMRange.prototype.containsRange = function (range) { + if (! this.attached) + throw new Error("Must be attached"); + + if (! range.attached) + return false; + + // A DOMRange is contained in this DOMRange if it's possible + // to reach this range by following parent pointers. If the + // DOMRange has the same parentElement, then it should be + // a member, or a member of a member etc. Otherwise, we must + // contain its parentElement. + + if (range.parentElement !== this.parentElement) + return this.containsElement(range.parentElement); + + if (range === this) + return false; // don't contain self + + while (range && range !== this) + range = range.parentRange; + + return range === this; +}; + +DOMRange.prototype.onAttached = function (attached) { + this.onAttachedDetached({ attached: attached }); +}; + +// callbacks are `attached(range, element)` and +// `detached(range, element)`, and they may +// access the `callbacks` object in `this`. +// The arguments to `detached` are the same +// range and element that were passed to `attached`. +DOMRange.prototype.onAttachedDetached = function (callbacks) { + if (this.attachedCallbacks === _emptyArray) + this.attachedCallbacks = []; + this.attachedCallbacks.push(callbacks); +}; + +DOMRange.prototype.$ = function (selector) { + var self = this; + + var parentNode = this.parentElement; + if (! parentNode) + throw new Error("Can't select in removed DomRange"); + + // Strategy: Find all selector matches under parentNode, + // then filter out the ones that aren't in this DomRange + // using `DOMRange#containsElement`. This is + // asymptotically slow in the presence of O(N) sibling + // content that is under parentNode but not in our range, + // so if performance is an issue, the selector should be + // run on a child element. + + // Since jQuery can't run selectors on a DocumentFragment, + // we don't expect findBySelector to work. + if (parentNode.nodeType === 11 /* DocumentFragment */) + throw new Error("Can't use $ on an offscreen range"); + + var results = Blaze.DOMBackend.findBySelector(selector, parentNode); + + // We don't assume `results` has jQuery API; a plain array + // should do just as well. However, if we do have a jQuery + // array, we want to end up with one also, so we use + // `.filter`. + + // Function that selects only elements that are actually + // in this DomRange, rather than simply descending from + // `parentNode`. + var filterFunc = function (elem) { + // handle jQuery's arguments to filter, where the node + // is in `this` and the index is the first argument. + if (typeof elem === 'number') + elem = this; + + return self.containsElement(elem); + }; + + if (! results.filter) { + // not a jQuery array, and not a browser with + // Array.prototype.filter (e.g. IE <9) + var newResults = []; + for (var i = 0; i < results.length; i++) { + var x = results[i]; + if (filterFunc(x)) + newResults.push(x); + } + results = newResults; + } else { + // `results.filter` is either jQuery's or ECMAScript's `filter` + results = results.filter(filterFunc); + } + + return results; +}; + +// Returns true if element a contains node b and is not node b. +// +// The restriction that `a` be an element (not a document fragment, +// say) is based on what's easy to implement cross-browser. +Blaze._elementContains = function (a, b) { + if (a.nodeType !== 1) // ELEMENT + return false; + if (a === b) + return false; + + if (a.compareDocumentPosition) { + return a.compareDocumentPosition(b) & 0x10; + } else { + // Should be only old IE and maybe other old browsers here. + // Modern Safari has both functions but seems to get contains() wrong. + // IE can't handle b being a text node. We work around this + // by doing a direct parent test now. + b = b.parentNode; + if (! (b && b.nodeType === 1)) // ELEMENT + return false; + if (a === b) + return true; + + return a.contains(b); + } +}; diff --git a/packages/blaze/events.js b/packages/blaze/events.js new file mode 100644 index 0000000000..10f81e0ce1 --- /dev/null +++ b/packages/blaze/events.js @@ -0,0 +1,197 @@ +var EventSupport = Blaze.EventSupport = {}; + +var DOMBackend = Blaze.DOMBackend; + +// List of events to always delegate, never capture. +// Since jQuery fakes bubbling for certain events in +// certain browsers (like `submit`), we don't want to +// get in its way. +// +// We could list all known bubbling +// events here to avoid creating speculative capturers +// for them, but it would only be an optimization. +var eventsToDelegate = EventSupport.eventsToDelegate = { + blur: 1, change: 1, click: 1, focus: 1, focusin: 1, + focusout: 1, reset: 1, submit: 1 +}; + +var EVENT_MODE = EventSupport.EVENT_MODE = { + TBD: 0, + BUBBLING: 1, + CAPTURING: 2 +}; + +var NEXT_HANDLERREC_ID = 1; + +var HandlerRec = function (elem, type, selector, handler, recipient) { + this.elem = elem; + this.type = type; + this.selector = selector; + this.handler = handler; + this.recipient = recipient; + this.id = (NEXT_HANDLERREC_ID++); + + this.mode = EVENT_MODE.TBD; + + // It's important that delegatedHandler be a different + // instance for each handlerRecord, because its identity + // is used to remove it. + // + // It's also important that the closure have access to + // `this` when it is not called with it set. + this.delegatedHandler = (function (h) { + return function (evt) { + if ((! h.selector) && evt.currentTarget !== evt.target) + // no selector means only fire on target + return; + return h.handler.apply(h.recipient, arguments); + }; + })(this); + + // WHY CAPTURE AND DELEGATE: jQuery can't delegate + // non-bubbling events, because + // event capture doesn't work in IE 8. However, there + // are all sorts of new-fangled non-bubbling events + // like "play" and "touchenter". We delegate these + // events using capture in all browsers except IE 8. + // IE 8 doesn't support these events anyway. + + var tryCapturing = elem.addEventListener && + (! _.has(eventsToDelegate, + DOMBackend.Events.parseEventType(type))); + + if (tryCapturing) { + this.capturingHandler = (function (h) { + return function (evt) { + if (h.mode === EVENT_MODE.TBD) { + // must be first time we're called. + if (evt.bubbles) { + // this type of event bubbles, so don't + // get called again. + h.mode = EVENT_MODE.BUBBLING; + DOMBackend.Events.unbindEventCapturer( + h.elem, h.type, h.capturingHandler); + return; + } else { + // this type of event doesn't bubble, + // so unbind the delegation, preventing + // it from ever firing. + h.mode = EVENT_MODE.CAPTURING; + DOMBackend.Events.undelegateEvents( + h.elem, h.type, h.delegatedHandler); + } + } + + h.delegatedHandler(evt); + }; + })(this); + + } else { + this.mode = EVENT_MODE.BUBBLING; + } +}; +EventSupport.HandlerRec = HandlerRec; + +HandlerRec.prototype.bind = function () { + // `this.mode` may be EVENT_MODE_TBD, in which case we bind both. in + // this case, 'capturingHandler' is in charge of detecting the + // correct mode and turning off one or the other handlers. + if (this.mode !== EVENT_MODE.BUBBLING) { + DOMBackend.Events.bindEventCapturer( + this.elem, this.type, this.selector || '*', + this.capturingHandler); + } + + if (this.mode !== EVENT_MODE.CAPTURING) + DOMBackend.Events.delegateEvents( + this.elem, this.type, + this.selector || '*', this.delegatedHandler); +}; + +HandlerRec.prototype.unbind = function () { + if (this.mode !== EVENT_MODE.BUBBLING) + DOMBackend.Events.unbindEventCapturer(this.elem, this.type, + this.capturingHandler); + + if (this.mode !== EVENT_MODE.CAPTURING) + DOMBackend.Events.undelegateEvents(this.elem, this.type, + this.delegatedHandler); +}; + +EventSupport.listen = function (element, events, selector, handler, recipient, getParentRecipient) { + + var eventTypes = []; + events.replace(/[^ /]+/g, function (e) { + eventTypes.push(e); + }); + + var newHandlerRecs = []; + for (var i = 0, N = eventTypes.length; i < N; i++) { + var type = eventTypes[i]; + + var eventDict = element.$blaze_events; + if (! eventDict) + eventDict = (element.$blaze_events = {}); + + var info = eventDict[type]; + if (! info) { + info = eventDict[type] = {}; + info.handlers = []; + } + var handlerList = info.handlers; + var handlerRec = new HandlerRec( + element, type, selector, handler, recipient); + newHandlerRecs.push(handlerRec); + handlerRec.bind(); + handlerList.push(handlerRec); + // Move handlers of enclosing ranges to end, by unbinding and rebinding + // them. In jQuery (or other DOMBackend) this causes them to fire + // later when the backend dispatches event handlers. + if (getParentRecipient) { + for (var r = getParentRecipient(recipient); r; + r = getParentRecipient(r)) { + // r is an enclosing range (recipient) + for (var j = 0, Nj = handlerList.length; + j < Nj; j++) { + var h = handlerList[j]; + if (h.recipient === r) { + h.unbind(); + h.bind(); + handlerList.splice(j, 1); // remove handlerList[j] + handlerList.push(h); + j--; // account for removed handler + Nj--; // don't visit appended handlers + } + } + } + } + } + + return { + // closes over just `element` and `newHandlerRecs` + stop: function () { + var eventDict = element.$blaze_events; + if (! eventDict) + return; + // newHandlerRecs has only one item unless you specify multiple + // event types. If this code is slow, it's because we have to + // iterate over handlerList here. Clearing a whole handlerList + // via stop() methods is O(N^2) in the number of handlers on + // an element. + for (var i = 0; i < newHandlerRecs.length; i++) { + var handlerToRemove = newHandlerRecs[i]; + var info = eventDict[handlerToRemove.type]; + if (! info) + continue; + var handlerList = info.handlers; + for (var j = handlerList.length - 1; j >= 0; j--) { + if (handlerList[j] === handlerToRemove) { + handlerToRemove.unbind(); + handlerList.splice(j, 1); // remove handlerList[j] + } + } + } + newHandlerRecs.length = 0; + } + }; +}; diff --git a/packages/ui/exceptions.js b/packages/blaze/exceptions.js similarity index 53% rename from packages/ui/exceptions.js rename to packages/blaze/exceptions.js index f60d053c62..a8d9317450 100644 --- a/packages/ui/exceptions.js +++ b/packages/blaze/exceptions.js @@ -1,7 +1,6 @@ - var debugFunc; -// Meteor UI calls into user code in many places, and it's nice to catch exceptions +// We call into user code in many places, and it's nice to catch exceptions // propagated from user code immediately so that the whole system doesn't just // break. Catching exceptions is easy; reporting them is hard. This helper // reports exceptions. @@ -18,7 +17,17 @@ var debugFunc; // // An optional second argument overrides the default message. -reportUIException = function (e, msg) { +// Set this to `true` to cause `reportException` to throw +// the next exception rather than reporting it. This is +// useful in unit tests that test error messages. +Blaze._throwNextException = false; + +Blaze.reportException = function (e, msg) { + if (Blaze._throwNextException) { + Blaze._throwNextException = false; + throw e; + } + if (! debugFunc) // adapted from Deps debugFunc = function () { @@ -30,5 +39,18 @@ reportUIException = function (e, msg) { // In Chrome, `e.stack` is a multiline string that starts with the message // and contains a stack trace. Furthermore, `console.log` makes it clickable. // `console.log` supplies the space between the two arguments. - debugFunc()(msg || 'Exception in Meteor UI:', e.stack || e.message); + debugFunc()(msg || 'Exception caught in template:', e.stack || e.message); +}; + +Blaze.wrapCatchingExceptions = function (f, where) { + if (typeof f !== 'function') + return f; + + return function () { + try { + return f.apply(this, arguments); + } catch (e) { + Blaze.reportException(e, 'Exception in ' + where + ':'); + } + }; }; diff --git a/packages/blaze/lookup.js b/packages/blaze/lookup.js new file mode 100644 index 0000000000..86a1e4fed4 --- /dev/null +++ b/packages/blaze/lookup.js @@ -0,0 +1,75 @@ +var bindIfIsFunction = function (x, target) { + if (typeof x !== 'function') + return x; + return function () { + return x.apply(target, arguments); + }; +}; + +var bindToCurrentDataIfIsFunction = function (x) { + if (typeof x === 'function') { + return function () { + var data = Blaze.getCurrentData(); + if (data == null) + data = {}; + return x.apply(data, arguments); + }; + } + return x; +}; + +var wrapHelper = function (f) { + return Blaze.wrapCatchingExceptions(f, 'template helper'); +}; + +// Implements {{foo}} where `name` is "foo" +// and `component` is the component the tag is found in +// (the lexical "self," on which to look for methods). +// If a function is found, it is bound to the object it +// was found on. Returns a function, +// non-function value, or null. +Blaze.View.prototype.lookup = function (name, _options) { + var template = this.template; + var lookupTemplate = _options && _options.template; + + if (/^\./.test(name)) { + // starts with a dot. must be a series of dots which maps to an + // ancestor of the appropriate height. + if (!/^(\.)+$/.test(name)) + throw new Error("id starting with dot must be a series of dots"); + + return Blaze._parentData(name.length - 1); + + } else if (template && (name in template)) { + return wrapHelper(bindToCurrentDataIfIsFunction(template[name])); + } else if (lookupTemplate && Template.__lookup__(name)) { + return Template.__lookup__(name); + } else if (UI._globalHelpers[name]) { + return wrapHelper(bindToCurrentDataIfIsFunction(UI._globalHelpers[name])); + } else { + var data = Blaze.getCurrentData(); + if (data) + return wrapHelper(bindIfIsFunction(data[name], data)); + } + return null; +}; + +// Implement Spacebars' {{../..}}. +// @param height {Number} The number of '..'s +Blaze._parentData = function (height) { + var theWith = Blaze.getCurrentView('with'); + for (var i = 0; (i < height) && theWith; i++) { + theWith = Blaze.getParentView(theWith, 'with'); + } + + return (theWith ? theWith.dataVar.get() : null); +}; + + +Blaze.View.prototype.lookupTemplate = function (name) { + var result = this.lookup(name, {template:true}); + + if (! result) + throw new Error("No such template: " + name); + return result; +}; diff --git a/packages/blaze/materializer.js b/packages/blaze/materializer.js new file mode 100644 index 0000000000..af3d3ec2e2 --- /dev/null +++ b/packages/blaze/materializer.js @@ -0,0 +1,130 @@ +// new Blaze.DOMMaterializer(options) +// +// An HTML.Visitor that turns HTMLjs into DOM nodes and DOMRanges. +// +// Options: `parentView` +Blaze.DOMMaterializer = HTML.Visitor.extend(); +Blaze.DOMMaterializer.def({ + visitNull: function (x, intoArray) { + return intoArray; + }, + visitPrimitive: function (primitive, intoArray) { + var string = String(primitive); + intoArray.push(document.createTextNode(string)); + return intoArray; + }, + visitCharRef: function (charRef, intoArray) { + return this.visitPrimitive(charRef.str, intoArray); + }, + visitArray: function (array, intoArray) { + for (var i = 0; i < array.length; i++) + this.visit(array[i], intoArray); + return intoArray; + }, + visitComment: function (comment, intoArray) { + intoArray.push(document.createComment(comment.sanitizedValue)); + return intoArray; + }, + visitRaw: function (raw, intoArray) { + // Get an array of DOM nodes by using the browser's HTML parser + // (like innerHTML). + var nodes = Blaze.DOMBackend.parseHTML(raw.value); + for (var i = 0; i < nodes.length; i++) + intoArray.push(nodes[i]); + + return intoArray; + }, + visitTag: function (tag, intoArray) { + var self = this; + var tagName = tag.tagName; + var elem; + if ((HTML.isKnownSVGElement(tagName) || isSVGAnchor(tag)) + && document.createElementNS) { + // inline SVG + elem = document.createElementNS('http://www.w3.org/2000/svg', tagName); + } else { + // normal elements + elem = document.createElement(tagName); + } + + var rawAttrs = tag.attrs; + var children = tag.children; + if (tagName === 'textarea' && ! ('value' in rawAttrs)) { + // turn TEXTAREA contents into a value attribute + rawAttrs = (rawAttrs || {}); + rawAttrs.value = children; + children = []; + } + + if (rawAttrs) { + var attrUpdater = new ElementAttributesUpdater(elem); + var updateAttributes = function () { + var parentView = self.parentView; + var expandedAttrs = Blaze._expandAttributes(rawAttrs, parentView); + var flattenedAttrs = HTML.flattenAttributes(expandedAttrs); + var stringAttrs = {}; + for (var attrName in flattenedAttrs) { + stringAttrs[attrName] = Blaze.toText(flattenedAttrs[attrName], + parentView, + HTML.TEXTMODE.STRING); + } + attrUpdater.update(stringAttrs); + }; + var updaterComputation; + if (self.parentView) { + updaterComputation = self.parentView.autorun(updateAttributes); + } else { + updaterComputation = Deps.nonreactive(function () { + return Deps.autorun(function () { + Deps.withCurrentView(self.parentView, updateAttributes); + }); + }); + } + Blaze.DOMBackend.Teardown.onElementTeardown(elem, function attrTeardown() { + updaterComputation.stop(); + }); + } + + var childNodesAndRanges = self.visit(children, []); + for (var i = 0; i < childNodesAndRanges.length; i++) { + var x = childNodesAndRanges[i]; + if (x instanceof Blaze.DOMRange) + x.attach(elem); + else + elem.appendChild(x); + } + + intoArray.push(elem); + + return intoArray; + }, + visitObject: function (x, intoArray) { + if (Blaze.isTemplate(x)) + x = Blaze.runTemplate(x); + if (x instanceof Blaze.View) { + intoArray.push(Blaze.materializeView(x, this.parentView)); + return intoArray; + } + + // throw the default error + return HTML.Visitor.prototype.visitObject.call(this, x); + } +}); + +var isSVGAnchor = function (node) { + // We generally aren't able to detect SVG elements because + // if "A" were in our list of known svg element names, then all + // nodes would be created using + // `document.createElementNS`. But in the special case of , we can at least detect that attribute and + // create an SVG tag in that case. + // + // However, we still have a general problem of knowing when to + // use document.createElementNS and when to use + // document.createElement; for example, font tags will always + // be created as SVG elements which can cause other + // problems. #1977 + return (node.tagName === "a" && + node.attrs && + node.attrs["xlink:href"] !== undefined); +}; diff --git a/packages/blaze/microscore.js b/packages/blaze/microscore.js new file mode 100644 index 0000000000..8ff37551e1 --- /dev/null +++ b/packages/blaze/microscore.js @@ -0,0 +1,110 @@ +// Microscore is a partial polyfill for Underscore. It implements +// a subset of Underscore functions, and for some functions it +// implements a subset of the full functionality. +// +// Code written against Microscore should just work with Underscore. +// The reverse is not true, because Microscore doesn't support +// all features of every function. A list of known differences +// between Underscore and Microscore is given with each function. +// +// This file should be curated to keep it small, so that it doesn't +// grow into Underscore. +// +// In the future, we'll figure out something better, like package +// slices and dead code elimination. + +if (typeof _ !== 'undefined') + throw new Error("If you have Underscore, don't use Microscore"); + +_ = {}; + +var hasOwnProperty = Object.prototype.hasOwnProperty; +var objectToString = Object.prototype.toString; + +// Doesn't support more than two arguments (more than one "source" +// object). +_.extend = function (tgt, src) { + for (var k in src) { + if (hasOwnProperty.call(src, k)) + tgt[k] = src[k]; + } + return tgt; +}; + +_.has = function (obj, key) { + return hasOwnProperty.call(obj, key); +}; + +// Returns a copy of `array` with falsy elements removed. +_.compact = function (array) { + var result = []; + for (var i = 0; i < array.length; i++) { + var item = array[i]; + if (item) + result.push(item); + } + return result; +}; + +// Returns whether `array` contains an element that is +// `=== item`. +_.contains = function (array, item) { + for (var i = 0; i < array.length; i++) { + if (array[i] === item) + return true; + } + return false; +}; + +// Returns `array` filtered to exclude elements that are +// `=== item`. Similar to `_.without`. +_.without = function (array, item) { + var result = []; + for (var i = 0; i < array.length; i++) { + var x = array[i]; + if (x !== item) + result.push(x); + } + return result; +}; + +// Assembles an array by calling `func(oldElement, index)` +// on each element of `array`. Assumes argument is an array. +_.map = function (array, func) { + var result = new Array(array.length); + for (var i = 0; i < array.length; i++) { + result[i] = func(array[i], i); + } + return result; +}; + +// Given an array: Calls `func(element, index)` on each element of +// `array`. +// +// Given an object: Calls `func(value, key)` on each key/value of +// `obj`. +// +// Only REAL arrays are treated as arrays. No Arguments objects, jQuery +// objects, etc. This may be relaxed to the standard Meteor approach +// if it is too constraining. +// +// Doesn't accept `null` as first argument. Doesn't delegate to built-in +// `forEach` (which is generally not faster anyway because it calls +// across the C/JS boundary). Doesn't mess with JavaScript's built-in +// behavior if keys are added or removed during iteration (i.e. may +// or may not visit them). + +_.each = function (arrayOrObject, func) { + if (objectToString.call(arrayOrObject) === '[object Array]') { + var array = arrayOrObject; + for (var i = 0; i < array.length; i++) { + func(array[i], i); + } + } else { + var obj = arrayOrObject; + for (var key in obj) { + if (_.has(obj, key)) + func(obj[key], key); + } + } +}; diff --git a/packages/blaze/package.js b/packages/blaze/package.js new file mode 100644 index 0000000000..f6bd58246f --- /dev/null +++ b/packages/blaze/package.js @@ -0,0 +1,44 @@ +Package.describe({ + summary: "Meteor UI Components framework", + internal: true +}); + +Package.on_use(function (api) { + api.export(['Blaze']); + api.use('jquery'); // should be a weak dep, by having multiple "DOM backends" + api.use('deps'); + api.use('underscore'); // only the subset in microscore.js + api.use('htmljs'); + api.use('observe-sequence'); + + api.add_files([ + 'preamble.js' + ]); + + // client-only files + api.add_files([ + 'dombackend.js', + 'domrange.js', + 'events.js', + 'attrs.js', + 'materializer.js' + ], 'client'); + + // client and server + api.add_files([ + 'exceptions.js', + 'reactivevar.js', + 'view.js', + 'builtins.js', + 'lookup.js' + ]); +}); + +Package.on_test(function (api) { + api.use('tinytest'); + api.use('jquery'); // strong dependency, for testing jQuery backend + api.use('blaze'); + api.use(['test-helpers', 'underscore'], 'client'); + + // ... +}); diff --git a/packages/blaze/preamble.js b/packages/blaze/preamble.js new file mode 100644 index 0000000000..1ecdd57808 --- /dev/null +++ b/packages/blaze/preamble.js @@ -0,0 +1 @@ +Blaze = {}; diff --git a/packages/blaze/reactivevar.js b/packages/blaze/reactivevar.js new file mode 100644 index 0000000000..e1c3195272 --- /dev/null +++ b/packages/blaze/reactivevar.js @@ -0,0 +1,70 @@ +/** + * ## [new] Blaze.ReactiveVar(initialValue, [equalsFunc]) + * + * A ReactiveVar holds a single value that can be get and set, + * such that calling `set` will invalidate any Computations that + * called `get`, according to the usual contract for reactive + * data sources. + * + * A ReactiveVar is much like a Session variable -- compare `foo.get()` + * to `Session.get("foo")` -- but it doesn't have a global name and isn't + * automatically migrated across hot code pushes. Also, while Session + * variables can only hold JSON or EJSON, ReactiveVars can hold any value. + * + * An important property of ReactiveVars, which is sometimes the reason + * to use one, is that setting the value to the same value as before has + * no effect, meaning ReactiveVars can be used to absorb extra + * invalidations that wouldn't serve a purpose. However, by default, + * ReactiveVars are extremely conservative about what changes they + * absorb. Calling `set` with an object argument will *always* trigger + * invalidations, because even if the new value is `===` the old value, + * the object may have been mutated. You can change the default behavior + * by passing a function of two arguments, `oldValue` and `newValue`, + * to the constructor as `equalsFunc`. + * + * This class is extremely basic right now, but the idea is to evolve + * it into the ReactiveVar of Geoff's Lickable Forms proposal. + */ + +Blaze.ReactiveVar = function (initialValue, equalsFunc) { + if (! (this instanceof Blaze.ReactiveVar)) + // called without `new` + return new Blaze.ReactiveVar(initialValue, equalsFunc); + + this.curValue = initialValue; + this.equalsFunc = equalsFunc; + this.dep = new Deps.Dependency; +}; + +Blaze.ReactiveVar._isEqual = function (oldValue, newValue) { + var a = oldValue, b = newValue; + // Two values are "equal" here if they are `===` and are + // number, boolean, string, undefined, or null. + if (a !== b) + return false; + else + return ((!a) || (typeof a === 'number') || (typeof a === 'boolean') || + (typeof a === 'string')); +}; + +Blaze.ReactiveVar.prototype.get = function () { + if (Deps.active) + this.dep.depend(); + + return this.curValue; +}; + +Blaze.ReactiveVar.prototype.set = function (newValue) { + var oldValue = this.curValue; + + if ((this.equalsFunc || Blaze.ReactiveVar._isEqual)(oldValue, newValue)) + // value is same as last time + return; + + this.curValue = newValue; + this.dep.changed(); +}; + +Blaze.ReactiveVar.prototype.toString = function () { + return 'ReactiveVar{' + this.get() + '}'; +}; diff --git a/packages/blaze/view.js b/packages/blaze/view.js new file mode 100644 index 0000000000..59976fb44e --- /dev/null +++ b/packages/blaze/view.js @@ -0,0 +1,544 @@ +/// [new] Blaze.View([kind], renderMethod) +/// +/// Blaze.View is the building block of reactive DOM. Views have +/// the following features: +/// +/// * lifecycle callbacks - Views are created, rendered, and destroyed, +/// and callbacks can be registered to fire when these things happen. +/// +/// * parent pointer - A View points to its parentView, which is the +/// View that caused it to be rendered. These pointers form a +/// hierarchy or tree of Views. +/// +/// * render() method - A View's render() method specifies the DOM +/// (or HTML) content of the View. If the method establishes +/// reactive dependencies, it may be re-run. +/// +/// * a DOMRange - If a View is rendered to DOM, its position and +/// extent in the DOM are tracked using a DOMRange object. +/// +/// When a View is constructed by calling Blaze.View, the View is +/// not yet considered "created." It doesn't have a parentView yet, +/// and no logic has been run to initialize the View. All real +/// work is deferred until at least creation time, when the onCreated +/// callbacks are fired, which happens when the View is "used" in +/// some way that requires it to be rendered. +/// +/// ...more lifecycle stuff +/// +/// `kind` is an optional string tag identifying the View. The only +/// time it's used is when looking in the View tree for a View of a +/// particular kind; for example, data contexts are stored on Views +/// of kind "with". Kinds are also useful when debugging, so in +/// general it's good for functions that create Views to set the kind. +/// Templates have kinds of the form "Template.foo". +Blaze.View = function (kind, render) { + if (! (this instanceof Blaze.View)) + // called without `new` + return new Blaze.View(kind, render); + + if (typeof kind === 'function') { + // omitted "kind" argument + render = kind; + kind = ''; + } + this.kind = kind; + this.render = render; + + this._callbacks = { + created: null, + materialized: null, + rendered: null, + destroyed: null + }; + + // Setting all properties here is good for readability, + // and also may help Chrome optimize the code by keeping + // the View object from changing shape too much. + this.isCreated = false; + this.isCreatedForExpansion = false; + this.isDestroyed = false; + this.isInRender = false; + this.parentView = null; + this.domrange = null; +}; + +Blaze.View.prototype.render = function () { return null; }; + +Blaze.View.prototype.onCreated = function (cb) { + this._callbacks.created = this._callbacks.created || []; + this._callbacks.created.push(cb); +}; +Blaze.View.prototype.onMaterialized = function (cb) { + this._callbacks.materialized = this._callbacks.materialized || []; + this._callbacks.materialized.push(cb); +}; +Blaze.View.prototype.onRendered = function (cb) { + this._callbacks.rendered = this._callbacks.rendered || []; + this._callbacks.rendered.push(cb); +}; +Blaze.View.prototype.onDestroyed = function (cb) { + this._callbacks.destroyed = this._callbacks.destroyed || []; + this._callbacks.destroyed.push(cb); +}; + +/// View#autorun(func) +/// +/// Sets up a Deps autorun that is "scoped" to this View in two +/// important ways: 1) Blaze.currentView is automatically set +/// on every re-run, and 2) the autorun is stopped when the +/// View is destroyed. As with Deps.autorun, the first run of +/// the function is immediate, and a Computation object that can +/// be used to stop the autorun is returned. +/// +/// View#autorun is meant to be called from View callbacks like +/// onCreated, or from outside the rendering process. It may not +/// be called before the onCreated callbacks are fired (too early), +/// or from a render() method (too confusing). +/// +/// Typically, autoruns that update the state +/// of the View (as in Blaze.With) should be started from an onCreated +/// callback. Autoruns that update the DOM should be started +/// from either onCreated (guarded against the absence of +/// view.domrange), onMaterialized, or onRendered. +Blaze.View.prototype.autorun = function (f, _inViewScope) { + var self = this; + + // The restrictions on when View#autorun can be called are in order + // to avoid bad patterns, like creating a Blaze.View and immediately + // calling autorun on it. A freshly created View is not ready to + // have logic run on it; it doesn't have a parentView, for example. + // It's when the View is materialized or expanded that the onCreated + // handlers are fired and the View starts up. + // + // Letting the render() method call `this.autorun()` is problematic + // because of re-render. The best we can do is to stop the old + // autorun and start a new one for each render, but that's a pattern + // we try to avoid internally because it leads to helpers being + // called extra times, in the case where the autorun causes the + // view to re-render (and thus the autorun to be torn down and a + // new one established). + // + // We could lift these restrictions in various ways. One interesting + // idea is to allow you to call `view.autorun` after instantiating + // `view`, and automatically wrap it in `view.onCreated`, deferring + // the autorun so that it starts at an appropriate time. However, + // then we can't return the Computation object to the caller, because + // it doesn't exist yet. + if (! self.isCreated) { + throw new Error("View#autorun must be called from the created callback at the earliest"); + } + if (this.isInRender) { + throw new Error("Can't call View#autorun from inside render(); try calling it from the created or rendered callback"); + } + if (Deps.active) { + throw new Error("Can't call View#autorun from a Deps Computation; try calling it from the created or rendered callback"); + } + + var c = Deps.autorun(function viewAutorun(c) { + return Blaze.withCurrentView(_inViewScope || self, function () { + return f.call(self, c); + }); + }); + self.onDestroyed(function () { c.stop(); }); + + return c; +}; + +Blaze._fireCallbacks = function (view, which) { + Blaze.withCurrentView(view, function () { + Deps.nonreactive(function fireCallbacks() { + var cbs = view._callbacks[which]; + for (var i = 0, N = (cbs && cbs.length); i < N; i++) + cbs[i].call(view); + }); + }); +}; + +Blaze.materializeView = function (view, parentView) { + view.parentView = (parentView || null); + + if (view.isCreated) + throw new Error("Can't render the same View twice"); + view.isCreated = true; + + Blaze._fireCallbacks(view, 'created'); + + var domrange; + + var needsRenderedCallback = false; + var scheduleRenderedCallback = function () { + if (needsRenderedCallback && ! view.isDestroyed && + view._callbacks.rendered && view._callbacks.rendered.length) { + Deps.afterFlush(function callRendered() { + if (needsRenderedCallback && ! view.isDestroyed) { + needsRenderedCallback = false; + Blaze._fireCallbacks(view, 'rendered'); + } + }); + } + }; + + var lastHtmljs; + // We don't expect to be called in a Computation, but just in case, + // wrap in Deps.nonreactive. + Deps.nonreactive(function () { + view.autorun(function doRender(c) { + // `view.autorun` sets the current view. + // Any dependencies that should invalidate this Computation come + // from this line: + view.isInRender = true; + var htmljs = view.render(); + view.isInRender = false; + + Deps.nonreactive(function doMaterialize() { + var materializer = new Blaze.DOMMaterializer({parentView: view}); + var rangesAndNodes = materializer.visit(htmljs, []); + if (c.firstRun || ! Blaze._isContentEqual(lastHtmljs, htmljs)) { + if (c.firstRun) { + domrange = new Blaze.DOMRange(rangesAndNodes); + view.domrange = domrange; + domrange.view = view; + } else { + domrange.setMembers(rangesAndNodes); + } + Blaze._fireCallbacks(view, 'materialized'); + needsRenderedCallback = true; + if (! c.firstRun) + scheduleRenderedCallback(); + } + }); + lastHtmljs = htmljs; + + // Causes any nested views to stop immediately, not when we call + // `setMembers` the next time around the autorun. Otherwise, + // helpers in the DOM tree to be replaced might be scheduled + // to re-run before we have a chance to stop them. + Deps.onInvalidate(function () { + domrange.destroyMembers(); + }); + }); + + var teardownHook = null; + + domrange.onAttached(function attached(range, element) { + teardownHook = Blaze.DOMBackend.Teardown.onElementTeardown( + element, function teardown() { + Blaze.destroyView(view, true /* _skipNodes */); + }); + + scheduleRenderedCallback(); + }); + + // tear down the teardown hook + view.onDestroyed(function () { + teardownHook && teardownHook.stop(); + teardownHook = null; + }); + }); + + return domrange; +}; + +// Expands a View to HTMLjs, calling `render` recursively on all +// Views and evaluating any dynamic attributes. Calls the `created` +// callback, but not the `materialized` or `rendered` callbacks. +// Destroys the view immediately, unless called in a Deps Computation, +// in which case the view will be destroyed when the Computation is +// invalidated. If called in a Deps Computation, the result is a +// reactive string; that is, the Computation will be invalidated +// if any changes are made to the view or subviews that might affect +// the HTML. +Blaze._expandView = function (view, parentView) { + view.parentView = (parentView || null); + + if (view.isCreated) + throw new Error("Can't render the same View twice"); + view.isCreated = true; + view.isCreatedForExpansion = true; + + Blaze._fireCallbacks(view, 'created'); + + view.isInRender = true; + var htmljs = Blaze.withCurrentView(view, function () { + return view.render(); + }); + view.isInRender = false; + + var result = Blaze._expand(htmljs, view); + + if (Deps.active) { + Deps.onInvalidate(function () { + Blaze.destroyView(view); + }); + } else { + Blaze.destroyView(view); + } + + return result; +}; + +// Options: `parentView` +Blaze.HTMLJSExpander = HTML.TransformingVisitor.extend(); +Blaze.HTMLJSExpander.def({ + visitObject: function (x) { + if (Blaze.isTemplate(x)) + x = Blaze.runTemplate(x); + if (x instanceof Blaze.View) + return Blaze._expandView(x, this.parentView); + + // this will throw an error; other objects are not allowed! + return HTML.TransformingVisitor.prototype.visitObject.call(this, x); + }, + visitAttributes: function (attrs) { + // expand dynamic attributes + if (typeof attrs === 'function') + attrs = Blaze.withCurrentView(this.parentView, attrs); + + // call super (e.g. for case where `attrs` is an array) + return HTML.TransformingVisitor.prototype.visitAttributes.call(this, attrs); + }, + visitAttribute: function (name, value, tag) { + // expand attribute values that are functions. Any attribute value + // that contains Views must be wrapped in a function. + if (typeof value === 'function') + value = Blaze.withCurrentView(this.parentView, value); + + return HTML.TransformingVisitor.prototype.visitAttribute.call( + this, name, value, tag); + } +}); + +Blaze._expand = function (htmljs, parentView) { + parentView = parentView || Blaze.currentView; + return (new Blaze.HTMLJSExpander( + {parentView: parentView})).visit(htmljs); +}; + +Blaze._expandAttributes = function (attrs, parentView) { + parentView = parentView || Blaze.currentView; + return (new Blaze.HTMLJSExpander( + {parentView: parentView})).visitAttributes(attrs); +}; + +Blaze.destroyView = function (view, _skipNodes) { + if (view.isDestroyed) + return; + view.isDestroyed = true; + + Blaze._fireCallbacks(view, 'destroyed'); + + // Destroy views and elements recursively. If _skipNodes, + // only recurse up to views, not elements, for the case where + // the backend (jQuery) is recursing over the elements already. + + if (view.domrange) + view.domrange.destroyMembers(); +}; + +Blaze.destroyNode = function (node) { + if (node.nodeType === 1) + Blaze.DOMBackend.Teardown.tearDownElement(node); +}; + +// Are the HTMLjs entities `a` and `b` the same? We could be +// more elaborate here but the point is to catch the most basic +// cases. +Blaze._isContentEqual = function (a, b) { + if (a instanceof HTML.Raw) { + return (b instanceof HTML.Raw) && (a.value === b.value); + } else if (a == null) { + return (b == null); + } else { + return (a === b) && + ((typeof a === 'number') || (typeof a === 'boolean') || + (typeof a === 'string')); + } +}; + +Blaze.currentView = null; + +Blaze.withCurrentView = function (view, func) { + var oldView = Blaze.currentView; + try { + Blaze.currentView = view; + return func(); + } finally { + Blaze.currentView = oldView; + } +}; + +Blaze.isTemplate = function (t) { + return t && (typeof t.__makeView === 'function'); +}; + +Blaze.runTemplate = function (t/*, args*/) { + if (! Blaze.isTemplate(t)) + throw new Error("Not a template: " + t); + var restArgs = Array.prototype.slice.call(arguments, 1); + return t.__makeView.apply(t, restArgs); +}; + +Blaze.render = function (content, parentView) { + parentView = parentView || Blaze.currentView; + + var view; + if (typeof content === 'function') { + view = Blaze.View('render', content); + } else if (Blaze.isTemplate(content)) { + view = Blaze.runTemplate(content); + } else { + if (! (content instanceof Blaze.View)) + throw new Error("Expected a function, template, or View in Blaze.render"); + view = content; + } + return Blaze.materializeView(view, parentView); +}; + +Blaze.toHTML = function (htmljs, parentView) { + if (typeof htmljs === 'function') + throw new Error("Blaze.toHTML doesn't take a function, just HTMLjs"); + parentView = parentView || Blaze.currentView; + return HTML.toHTML(Blaze._expand(htmljs, parentView)); +}; + +Blaze.toText = function (htmljs, parentView, textMode) { + if (typeof htmljs === 'function') + throw new Error("Blaze.toText doesn't take a function, just HTMLjs"); + + if ((parentView != null) && ! (parentView instanceof Blaze.View)) { + // omitted parentView argument + textMode = parentView; + parentView = null; + } + parentView = parentView || Blaze.currentView; + + if (! textMode) + throw new Error("textMode required"); + if (! (textMode === HTML.TEXTMODE.STRING || + textMode === HTML.TEXTMODE.RCDATA || + textMode === HTML.TEXTMODE.ATTRIBUTE)) + throw new Error("Unknown textMode: " + textMode); + + return HTML.toText(Blaze._expand(htmljs, parentView), textMode); +}; + +Blaze.getCurrentData = function () { + var theWith = Blaze.getCurrentView('with'); + return theWith ? theWith.dataVar.get() : null; +}; + +// Gets the current view or its nearest ancestor of kind +// `kind`. +Blaze.getCurrentView = function (kind) { + var view = Blaze.currentView; + // Better to fail in cases where it doesn't make sense + // to use Blaze.getCurrentView(). There will be a current + // view anywhere it does. You can check Blaze.currentView + // if you want to know whether there is one or not. + if (! view) + throw new Error("There is no current view"); + + if (kind) { + while (view && view.kind !== kind) + view = view.parentView; + return view || null; + } else { + // Blaze.getCurrentView() with no arguments just returns + // Blaze.currentView. + return view; + } +}; + +// Gets the nearest ancestor view that corresponds to a template +Blaze.getCurrentTemplateView = function () { + var view = Blaze.getCurrentView(); + + while (view && ! view.template) + view = view.parentView; + + return view || null; +}; + +Blaze.getParentView = function (view, kind) { + var v = view.parentView; + + if (kind) { + while (v && v.kind !== kind) + v = v.parentView; + } + + return v || null; +}; + +Blaze.getElementView = function (elem, kind) { + var range = Blaze.DOMRange.forElement(elem); + var view = null; + while (range && ! view) { + view = (range.view || null); + if (! view) { + if (range.parentRange) + range = range.parentRange; + else + range = Blaze.DOMRange.forElement(range.parentElement); + } + } + + if (kind) { + while (view && view.kind !== kind) + view = view.parentView; + return view || null; + } else { + return view; + } +}; + +Blaze.getElementData = function (elem) { + var theWith = Blaze.getElementView(elem, 'with'); + return theWith ? theWith.dataVar.get() : null; +}; + +Blaze.getViewData = function (view) { + var theWith = Blaze.getParentView(view, 'with'); + return theWith ? theWith.dataVar.get() : null; +}; + +Blaze._addEventMap = function (view, eventMap, thisInHandler) { + thisInHandler = (thisInHandler || null); + var handles = []; + + if (! view.domrange) + throw new Error("View must have a DOMRange"); + + view.domrange.onAttached(function attached_eventMaps(range, element) { + _.each(eventMap, function (handler, spec) { + var clauses = spec.split(/,\s+/); + // iterate over clauses of spec, e.g. ['click .foo', 'click .bar'] + _.each(clauses, function (clause) { + var parts = clause.split(/\s+/); + if (parts.length === 0) + return; + + var newEvents = parts.shift(); + var selector = parts.join(' '); + handles.push(Blaze.EventSupport.listen( + element, newEvents, selector, + function (evt) { + if (! range.containsElement(evt.currentTarget)) + return null; + return handler.apply(thisInHandler || this, arguments); + }, + range, function (r) { + return r.parentRange; + })); + }); + }); + }); + + view.onDestroyed(function () { + _.each(handles, function (h) { + h.stop(); + }); + handles.length = 0; + }); +}; diff --git a/packages/html-tools/README.md b/packages/html-tools/README.md index ce0f4e6a7f..60f6f0f9bc 100644 --- a/packages/html-tools/README.md +++ b/packages/html-tools/README.md @@ -5,7 +5,7 @@ object representation. Special hooks allow the syntax to be extended to parse an HTML-like template language like Spacebars. ``` -HTML.parseFragment("
Hello
World
") +HTMLTools.parseFragment("
Hello
World
") => HTML.DIV({'class':'greeting'}, "Hello", HTML.BR(), "World")) ``` @@ -16,36 +16,36 @@ server. ## Invoking the Parser -`HTML.parseFragment(input, options)` - Takes an input string or Scanner object and returns HTMLjs. +`HTMLTools.parseFragment(input, options)` - Takes an input string or Scanner object and returns HTMLjs. In the basic case, where no options are passed, `parseFragment` will consume the entire input (the full string or the rest of the Scanner). The options are as follows: -#### getSpecialTag +#### getTemplateTag This option extends the HTML parser to parse template tags such as `{{foo}}`. -`getSpecialTag: function (scanner, templateTagPosition) { ... }` - A function for the parser to call after every HTML token and at various positions within tags. If the function returns a non-null value, that value is wrapped in an `HTML.Special` node which is inserted into the HTMLjs tree at the appropriate location. The function is expected to advance the scanner if it succeeds at parsing a template tag (see the section on `HTML.Scanner`). +`getTemplateTag: function (scanner, templateTagPosition) { ... }` - A function for the parser to call after every HTML token and at various positions within tags. If the function returns an instanceof `HTMLTools.TemplateTag`, it is inserted into the HTMLjs tree at the appropriate location. The constructor is `HTMLTools.TemplateTag(props)`, where props is an object whose properties are copied to the `TemplateTag` instance. You can also call the constructor with no arguments and assign whatever properties you want, or you can subclass `TemplateTag`. -There are four possible outcomes when `getSpecialTag` is called: +There are four possible outcomes when `getTemplateTag` is called: * Not a template tag - Leave the scanner as is, and return `null`. A quick peek at the next character should bail to this case if the start of a template tag is not seen. -* Bad template tag - Call `scanner.fatal`, which aborts parsing completely. Once the beginning of a template tag is seen, `getSpecialTag` will generally want to commit, and either succeed or fail trying). -* Good template tag - Advance the scanner to the end of the template tag and return an object. +* Bad template tag - Call `scanner.fatal`, which aborts parsing completely. Once the beginning of a template tag is seen, `getTemplateTag` will generally want to commit, and either succeed or fail trying). +* Good template tag - Advance the scanner to the end of the template tag and return an `HTMLTools.TemplateTag` object. * Comment tag - Advance the scanner and return `null`. For example, a Spacebars comment is `{{! foo}}`. -The `templateTagPosition` argument to `getSpecialTag` is one of: +The `templateTagPosition` argument to `getTemplateTag` is one of: -* `HTML.TEMPLATE_TAG_POSITION.ELEMENT` - At "element level," meaning somewhere an HTML tag could be. -* `HTML.TEMPLATE_TAG_POSITION.IN_START_TAG` - Inside a start tag, as in `
`, where you might otherwise find `name=value`. -* `HTML.TEMPLATE_TAG_POSITION.IN_ATTRIBUTE` - Inside the value of an HTML attribute, as in `
`. -* `HTML.TEMPLATE_TAG_POSITION.IN_RCDATA` - Inside a TEXTAREA or a block helper inside an attribute, where character references are allowed ("replaced character data") but not tags. -* `HTML.TEMPLATE_TAG_POSITION.IN_RAWTEXT` - In a context where character references are not parsed, such as a script tag, style tag, or markdown helper. +* `HTMLTools.TEMPLATE_TAG_POSITION.ELEMENT` - At "element level," meaning somewhere an HTML tag could be. +* `HTMLTools.TEMPLATE_TAG_POSITION.IN_START_TAG` - Inside a start tag, as in `
`, where you might otherwise find `name=value`. +* `HTMLTools.TEMPLATE_TAG_POSITION.IN_ATTRIBUTE` - Inside the value of an HTML attribute, as in `
`. +* `HTMLTools.TEMPLATE_TAG_POSITION.IN_RCDATA` - Inside a TEXTAREA or a block helper inside an attribute, where character references are allowed ("replaced character data") but not tags. +* `HTMLTools.TEMPLATE_TAG_POSITION.IN_RAWTEXT` - In a context where character references are not parsed, such as a script tag, style tag, or markdown helper. -It's completely normal for `getSpecialTag` to invoke `HTML.parseFragment` recursively on the same scanner (see `shouldStop`). If it does so, the same value of `getSpecialTag` must be passed to the second invocation. +It's completely normal for `getTemplateTag` to invoke `HTMLTools.parseFragment` recursively on the same scanner (see `shouldStop`). If it does so, the same value of `getTemplateTag` must be passed to the second invocation. -At the moment, template tags must begin with `{`. The parser does not try calling `getSpecialTag` for every character of an HTML document, only at token boundaries, and it knows to always end a token at `{`. +At the moment, template tags must begin with `{`. The parser does not try calling `getTemplateTag` for every character of an HTML document, only at token boundaries, and it knows to always end a token at `{`. **XXX Better error message for `
`.** @@ -68,14 +68,14 @@ The value of `textMode` must be one of: `shouldStop: function (scanner) { ... }` - A function that the parser invokes between tokens to check whether it should stop parsing. The function should return a boolean value. -The `shouldStop` function provides a way to put a "wall" in the input stream for the purpose of parsing HTML content embedded in a template tag. For example, take the template `{{#if happy}}yay{{/if}}`. The scanner will be advanced to the start of the word `yay` before `parseFragment` is called to parse the contents of the tag. (Note that the caller happens to be the `getSpecialTag` function of an enclosing `parseFragment`.) When parsing from `yay`, the `shouldStop` function is used to end the fragment at `{{/if}}`, which, like `{{/blah}}` or `{{else}}`, couldn't possibly be actual content that belongs in the fragment. Even if HTML tags are not closed, as in the malformed template `{{#if foo}}
{{else}}`, the fragment stops at the `{{else}}`, and the error is an unclosed `
` (before the parser notices the unclosed `{{#if}}`). +The `shouldStop` function provides a way to put a "wall" in the input stream for the purpose of parsing HTML content embedded in a template tag. For example, take the template `{{#if happy}}yay{{/if}}`. The scanner will be advanced to the start of the word `yay` before `parseFragment` is called to parse the contents of the tag. (Note that the caller happens to be the `getTemplateTag` function of an enclosing `parseFragment`.) When parsing from `yay`, the `shouldStop` function is used to end the fragment at `{{/if}}`, which, like `{{/blah}}` or `{{else}}`, couldn't possibly be actual content that belongs in the fragment. Even if HTML tags are not closed, as in the malformed template `{{#if foo}}
{{else}}`, the fragment stops at the `{{else}}`, and the error is an unclosed `
` (before the parser notices the unclosed `{{#if}}`). **XXX This option doesn't seem very elegant, or at least the way it's passed around internally isn't.** -## HTML.Scanner class +## HTMLTools.Scanner class -To write `getSpecialTag` and `shouldStop` functions, you have to -interface with the `HTML.Scanner` class used by html-tools. It's a +To write `getTemplateTag` and `shouldStop` functions, you have to +interface with the `HTMLTools.Scanner` class used by html-tools. It's a general class that could be used by any parser/lexer/tokenizer. A Scanner has an immutable source document and a mutable pointer into diff --git a/packages/html-tools/package.js b/packages/html-tools/package.js index da455867fd..9547b224e7 100644 --- a/packages/html-tools/package.js +++ b/packages/html-tools/package.js @@ -14,6 +14,7 @@ Package.on_use(function (api) { 'scanner.js', 'charref.js', 'tokenize.js', + 'templatetag.js', 'parse.js']); }); @@ -21,7 +22,7 @@ Package.on_test(function (api) { api.use('tinytest'); api.use('html-tools'); api.use('underscore'); - api.use('spacebars-compiler'); // for `HTML.toJS` + api.use('blaze-tools'); // for `toJS` api.add_files(['charref_tests.js', 'tokenize_tests.js', 'parse_tests.js']); diff --git a/packages/html-tools/parse.js b/packages/html-tools/parse.js index 1c25bf4225..59332b6a52 100644 --- a/packages/html-tools/parse.js +++ b/packages/html-tools/parse.js @@ -1,20 +1,4 @@ -HTMLTools.Special = function (value) { - if (! (this instanceof HTMLTools.Special)) - // called without `new` - return new HTMLTools.Special(value); - - this.value = value; -}; -HTMLTools.Special.prototype.toJS = function (options) { - // XXX this is weird because toJS is defined in spacebars-compiler. - // Think about where HTMLTools.Special and toJS should go. - return HTML.Tag.prototype.toJS.call({tagName: 'HTMLTools.Special', - attrs: this.value, - children: []}, - options); -}; - // Parse a "fragment" of HTML, up to the end of the input or a particular // template tag (using the "shouldStop" option). HTMLTools.parseFragment = function (input, options) { @@ -23,18 +7,18 @@ HTMLTools.parseFragment = function (input, options) { scanner = new Scanner(input); else // input can be a scanner. We'd better not have a different - // value for the "getSpecialTag" option as when the scanner + // value for the "getTemplateTag" option as when the scanner // was created, because we don't do anything special to reset // the value (which is attached to the scanner). scanner = input; // ``` - // { getSpecialTag: function (scanner, templateTagPosition) { + // { getTemplateTag: function (scanner, templateTagPosition) { // if (templateTagPosition === HTMLTools.TEMPLATE_TAG_POSITION.ELEMENT) { // ... // ``` - if (options && options.getSpecialTag) - scanner.getSpecialTag = options.getSpecialTag; + if (options && options.getTemplateTag) + scanner.getTemplateTag = options.getTemplateTag; // function (scanner) -> boolean var shouldStop = options && options.shouldStop; @@ -62,7 +46,7 @@ HTMLTools.parseFragment = function (input, options) { try { var endTag = getHTMLToken(scanner); } catch (e) { - // ignore errors from getSpecialTag + // ignore errors from getTemplateTag } // XXX we make some assumptions about shouldStop here, like that it @@ -137,9 +121,8 @@ getContent = HTMLTools.Parse.getContent = function (scanner, shouldStopFunc) { items.push(convertCharRef(token)); } else if (token.t === 'Comment') { items.push(HTML.Comment(token.v)); - } else if (token.t === 'Special') { - // token.v is an object `{ ... }` - items.push(HTMLTools.Special(token.v)); + } else if (token.t === 'TemplateTag') { + items.push(token.v); } else if (token.t === 'Tag') { if (token.isEnd) { // Stop when we encounter an end tag at the top level. @@ -173,7 +156,13 @@ getContent = HTMLTools.Parse.getContent = function (scanner, shouldStopFunc) { if (token.n === 'textarea') { if (scanner.peek() === '\n') scanner.pos++; - content = getRCData(scanner, token.n, shouldStopFunc); + attrs = (attrs || {}); + var textareaValue = getRCData(scanner, token.n, shouldStopFunc); + if (HTML.isArray(attrs)) { + attrs.push({value: textareaValue}); + } else { + attrs.value = textareaValue; + } } else { content = getContent(scanner, shouldStopFunc); } @@ -238,9 +227,8 @@ getRCData = HTMLTools.Parse.getRCData = function (scanner, tagName, shouldStopFu pushOrAppendString(items, token.v); } else if (token.t === 'CharRef') { items.push(convertCharRef(token)); - } else if (token.t === 'Special') { - // token.v is an object `{ ... }` - items.push(HTMLTools.Special(token.v)); + } else if (token.t === 'TemplateTag') { + items.push(token.v); } else { // (can't happen) scanner.fatal("Unknown or unexpected token type: " + token.t); @@ -274,9 +262,8 @@ var getRawText = function (scanner, tagName, shouldStopFunc) { if (token.t === 'Chars') { pushOrAppendString(items, token.v); - } else if (token.t === 'Special') { - // token.v is an object `{ ... }` - items.push(HTMLTools.Special(token.v)); + } else if (token.t === 'TemplateTag') { + items.push(token.v); } else { // (can't happen) scanner.fatal("Unknown or unexpected token type: " + token.t); @@ -304,11 +291,12 @@ var convertCharRef = function (token) { // Input is always a dictionary (even if zero attributes) and each // value in the dictionary is an array of `Chars`, `CharRef`, -// and maybe `Special` tokens. +// and maybe `TemplateTag` tokens. // // Output is null if there are zero attributes, and otherwise a -// dictionary. Each value in the dictionary is HTMLjs (e.g. a -// string or an array of `Chars`, `CharRef`, and `Special` +// dictionary, or an array of dictionaries and template tags. +// Each value in the dictionary is HTMLjs (e.g. a +// string or an array of `Chars`, `CharRef`, and `TemplateTag` // nodes). // // An attribute value with no input tokens is represented as "", @@ -317,6 +305,23 @@ var convertCharRef = function (token) { var parseAttrs = function (attrs) { var result = null; + if (HTML.isArray(attrs)) { + // first element is nondynamic attrs, rest are template tags + var nondynamicAttrs = parseAttrs(attrs[0]); + if (nondynamicAttrs) { + result = (result || []); + result.push(nondynamicAttrs); + } + for (var i = 1; i < attrs.length; i++) { + var token = attrs[i]; + if (token.t !== 'TemplateTag') + throw new Error("Expected TemplateTag token"); + result = (result || []); + result.push(token.v); + } + return HTML.Attrs.apply(null, result); + } + for (var k in attrs) { if (! result) result = {}; @@ -327,23 +332,17 @@ var parseAttrs = function (attrs) { var token = inValue[i]; if (token.t === 'CharRef') { outParts.push(convertCharRef(token)); - } else if (token.t === 'Special') { - outParts.push(HTMLTools.Special(token.v)); + } else if (token.t === 'TemplateTag') { + outParts.push(token.v); } else if (token.t === 'Chars') { pushOrAppendString(outParts, token.v); } } - if (k === '$specials') { - // the `$specials` pseudo-attribute should always get an - // array, even if there is only one Special. - result[k] = outParts; - } else { - var outValue = (inValue.length === 0 ? '' : - (outParts.length === 1 ? outParts[0] : outParts)); - var properKey = HTMLTools.properCaseAttributeName(k); - result[properKey] = outValue; - } + var outValue = (inValue.length === 0 ? '' : + (outParts.length === 1 ? outParts[0] : outParts)); + var properKey = HTMLTools.properCaseAttributeName(k); + result[properKey] = outValue; } return result; diff --git a/packages/html-tools/parse_tests.js b/packages/html-tools/parse_tests.js index cd24e134d3..3c5c27ebe5 100644 --- a/packages/html-tools/parse_tests.js +++ b/packages/html-tools/parse_tests.js @@ -3,7 +3,8 @@ var getContent = HTMLTools.Parse.getContent; var CharRef = HTML.CharRef; var Comment = HTML.Comment; -var Special = HTMLTools.Special; +var TemplateTag = HTMLTools.TemplateTag; +var Attrs = HTML.Attrs; var BR = HTML.BR; var HR = HTML.HR; @@ -23,7 +24,7 @@ Tinytest.add("html-tools - parser getContent", function (test) { var scanner = new Scanner(input.replace('^^^', '')); var result = getContent(scanner); test.equal(scanner.pos, endPos); - test.equal(HTML.toJS(result), HTML.toJS(expected)); + test.equal(BlazeTools.toJS(result), BlazeTools.toJS(expected)); }; var fatal = function (input, messageContains) { @@ -108,26 +109,26 @@ Tinytest.add("html-tools - parser getContent", function (test) { fatal('Foo'); fatal('Foo'); - succeed('', TEXTAREA("asdf")); - succeed('', TEXTAREA({x: "y"}, "asdf")); - succeed('', TEXTAREA("

")); + succeed('', TEXTAREA({value: "asdf"})); + succeed('', TEXTAREA({x: "y", value: "asdf"})); + succeed('', TEXTAREA({value: "

"})); succeed('', - TEXTAREA("a", CharRef({html: '&', str: '&'}), "b")); - succeed('', TEXTAREA("', TEXTAREA({value: "\n', TEXTAREA()); - succeed('', TEXTAREA("asdf")); - succeed('', TEXTAREA("\nasdf")); - succeed('', TEXTAREA("\n")); - succeed('', TEXTAREA("asdf\n")); - succeed('', TEXTAREA("")); - succeed('', TEXTAREA("asdf")); + succeed('', TEXTAREA({value: "asdf"})); + succeed('', TEXTAREA({value: "\nasdf"})); + succeed('', TEXTAREA({value: "\n"})); + succeed('', TEXTAREA({value: "asdf\n"})); + succeed('', TEXTAREA({value: ""})); + succeed('', TEXTAREA({value: "asdf"})); fatal(''); - succeed('', TEXTAREA("&")); + succeed('', TEXTAREA({value: "&"})); succeed('asdf', - [TEXTAREA("', BR({x:''})); @@ -139,8 +140,8 @@ Tinytest.add("html-tools - parser getContent", function (test) { succeed('
', BR({x:'y'})); succeed('', Comment('\n')); succeed('', Comment('\n')); - succeed('', TEXTAREA('a\nb\nc')); - succeed('', TEXTAREA('a\nb\nc')); + succeed('', TEXTAREA({value: 'a\nb\nc'})); + succeed('', TEXTAREA({value: 'a\nb\nc'})); succeed('
', BR({x:'\n\n'})); succeed('
', BR({x:'\n\n'})); succeed('
', BR({x:'y'})); @@ -148,8 +149,8 @@ Tinytest.add("html-tools - parser getContent", function (test) { }); Tinytest.add("html-tools - parseFragment", function (test) { - test.equal(HTML.toJS(HTMLTools.parseFragment("

Hello

")), - HTML.toJS(DIV(P({id:'foo'}, 'Hello')))); + test.equal(BlazeTools.toJS(HTMLTools.parseFragment("

Hello

")), + BlazeTools.toJS(DIV(P({id:'foo'}, 'Hello')))); _.each(['asdf
', '{{!foo}}
', '{{!foo}}
', 'asdf', '{{!foo}}', '{{!foo}} '], function (badFrag) { @@ -217,17 +218,17 @@ Tinytest.add("html-tools - parseFragment", function (test) { }); }); -Tinytest.add("html-tools - getSpecialTag", function (test) { +Tinytest.add("html-tools - getTemplateTag", function (test) { // match a simple tag consisting of `{{`, an optional `!`, one // or more ASCII letters, spaces or html tags, and a closing `}}`. var mustache = /^\{\{(!?[a-zA-Z 0-9]+)\}\}/; - // This implementation of `getSpecialTag` looks for "{{" and if it + // This implementation of `getTemplateTag` looks for "{{" and if it // finds it, it will match the regex above or fail fatally trying. // The object it returns is opaque to the tokenizer/parser and can // be anything we want. - var getSpecialTag = function (scanner, position) { + var getTemplateTag = function (scanner, position) { if (! (scanner.peek() === '{' && // one-char peek is just an optimization scanner.rest().slice(0, 2) === '{{')) return null; @@ -241,7 +242,7 @@ Tinytest.add("html-tools - getSpecialTag", function (test) { if (match[1].charAt(0) === '!') return null; // `{{!foo}}` is like a comment - return { stuff: match[1] }; + return TemplateTag({ stuff: match[1] }); }; @@ -252,7 +253,7 @@ Tinytest.add("html-tools - getSpecialTag", function (test) { endPos = input.length; var scanner = new Scanner(input.replace('^^^', '')); - scanner.getSpecialTag = getSpecialTag; + scanner.getTemplateTag = getTemplateTag; var result; try { result = getContent(scanner); @@ -260,12 +261,12 @@ Tinytest.add("html-tools - getSpecialTag", function (test) { result = String(e); } test.equal(scanner.pos, endPos); - test.equal(HTML.toJS(result), HTML.toJS(expected)); + test.equal(BlazeTools.toJS(result), BlazeTools.toJS(expected)); }; var fatal = function (input, messageContains) { var scanner = new Scanner(input); - scanner.getSpecialTag = getSpecialTag; + scanner.getTemplateTag = getTemplateTag; var error; try { getContent(scanner); @@ -278,16 +279,16 @@ Tinytest.add("html-tools - getSpecialTag", function (test) { }; - succeed('{{foo}}', Special({stuff: 'foo'})); + succeed('{{foo}}', TemplateTag({stuff: 'foo'})); succeed('{{foo}}', - A({href: "http://www.apple.com/"}, Special({stuff: 'foo'}))); + A({href: "http://www.apple.com/"}, TemplateTag({stuff: 'foo'}))); // tags not parsed in comments succeed('', Comment("{{foo}}")); succeed('', Comment("{{foo")); - succeed('&am{{foo}}p;', ['&am', Special({stuff: 'foo'}), 'p;']); + succeed('&am{{foo}}p;', ['&am', TemplateTag({stuff: 'foo'}), 'p;']); // can't start a mustache and not finish it fatal('{{foo'); @@ -303,51 +304,51 @@ Tinytest.add("html-tools - getSpecialTag", function (test) { succeed('
', BR({x:'{'})); succeed('
', BR({x:'{foo}'})); - succeed('
', BR({$specials: [Special({stuff: 'x'})]})); - succeed('
', BR({$specials: [Special({stuff: 'x'}), - Special({stuff: 'y'})]})); - succeed('
', BR({$specials: [Special({stuff: 'x'})], y:''})); + succeed('
', BR(Attrs(TemplateTag({stuff: 'x'})))); + succeed('
', BR(Attrs(TemplateTag({stuff: 'x'}), + TemplateTag({stuff: 'y'})))); + succeed('
', BR(Attrs({y: ''}, TemplateTag({stuff: 'x'})))); fatal('
'); fatal('
'); - succeed('
', BR({x: Special({stuff: 'y'}), z: ''})); - succeed('
', BR({x: ['y', Special({stuff: 'z'}), 'w']})); - succeed('
', BR({x: ['y', Special({stuff: 'z'}), 'w']})); - succeed('
', BR({x: ['y ', Special({stuff: 'z'}), - Special({stuff: 'w'}), ' v']})); + succeed('
', BR({x: TemplateTag({stuff: 'y'}), z: ''})); + succeed('
', BR({x: ['y', TemplateTag({stuff: 'z'}), 'w']})); + succeed('
', BR({x: ['y', TemplateTag({stuff: 'z'}), 'w']})); + succeed('
', BR({x: ['y ', TemplateTag({stuff: 'z'}), + TemplateTag({stuff: 'w'}), ' v']})); // Slash is parsed as part of unquoted attribute! This is consistent with // the HTML tokenization spec. It seems odd for some inputs but is probably // for cases like `` or ``. - succeed('
', BR({x: [Special({stuff: 'y'}), '/']})); - succeed('
', BR({x: [Special({stuff: 'z'}), - Special({stuff: 'w'})]})); + succeed('
', BR({x: [TemplateTag({stuff: 'y'}), '/']})); + succeed('
', BR({x: [TemplateTag({stuff: 'z'}), + TemplateTag({stuff: 'w'})]})); fatal('
'); succeed('
', BR({x:CharRef({html: '&', str: '&'})})); // check tokenization of stache tags with spaces - succeed('
', BR({$specials: [Special({stuff: 'x 1'})]})); - succeed('
', BR({$specials: [Special({stuff: 'x 1'}), - Special({stuff: 'y 2'})]})); - succeed('
', BR({$specials: [Special({stuff: 'x 1'})], y:''})); + succeed('
', BR(Attrs(TemplateTag({stuff: 'x 1'})))); + succeed('
', BR(Attrs(TemplateTag({stuff: 'x 1'}), + TemplateTag({stuff: 'y 2'})))); + succeed('
', BR(Attrs({y:''}, TemplateTag({stuff: 'x 1'})))); fatal('
'); fatal('
'); - succeed('
', BR({x: Special({stuff: 'y 2'}), z: ''})); - succeed('
', BR({x: ['y', Special({stuff: 'z 3'}), 'w']})); - succeed('
', BR({x: ['y', Special({stuff: 'z 3'}), 'w']})); - succeed('
', BR({x: ['y ', Special({stuff: 'z 3'}), - Special({stuff: 'w 4'}), ' v']})); - succeed('
', BR({x: [Special({stuff: 'y 2'}), '/']})); - succeed('
', BR({x: [Special({stuff: 'z 3'}), - Special({stuff: 'w 4'})]})); + succeed('
', BR({x: TemplateTag({stuff: 'y 2'}), z: ''})); + succeed('
', BR({x: ['y', TemplateTag({stuff: 'z 3'}), 'w']})); + succeed('
', BR({x: ['y', TemplateTag({stuff: 'z 3'}), 'w']})); + succeed('
', BR({x: ['y ', TemplateTag({stuff: 'z 3'}), + TemplateTag({stuff: 'w 4'}), ' v']})); + succeed('
', BR({x: [TemplateTag({stuff: 'y 2'}), '/']})); + succeed('
', BR({x: [TemplateTag({stuff: 'z 3'}), + TemplateTag({stuff: 'w 4'})]})); succeed('

', P()); - succeed('x{{foo}}{{bar}}y', ['x', Special({stuff: 'foo'}), - Special({stuff: 'bar'}), 'y']); + succeed('x{{foo}}{{bar}}y', ['x', TemplateTag({stuff: 'foo'}), + TemplateTag({stuff: 'bar'}), 'y']); succeed('x{{!foo}}{{!bar}}y', 'xy'); - succeed('x{{!foo}}{{bar}}y', ['x', Special({stuff: 'bar'}), 'y']); - succeed('x{{foo}}{{!bar}}y', ['x', Special({stuff: 'foo'}), 'y']); + succeed('x{{!foo}}{{bar}}y', ['x', TemplateTag({stuff: 'bar'}), 'y']); + succeed('x{{foo}}{{!bar}}y', ['x', TemplateTag({stuff: 'foo'}), 'y']); succeed('
{{!foo}}{{!bar}}
', DIV()); succeed('
{{!foo}}
{{!bar}}
', DIV(BR())); succeed('
{{!foo}} {{!bar}}
', DIV(" ")); diff --git a/packages/html-tools/templatetag.js b/packages/html-tools/templatetag.js new file mode 100644 index 0000000000..f94f9fd214 --- /dev/null +++ b/packages/html-tools/templatetag.js @@ -0,0 +1,29 @@ +// _assign is like _.extend or the upcoming Object.assign. +// Copy src's own, enumerable properties onto tgt and return +// tgt. +var _hasOwnProperty = Object.prototype.hasOwnProperty; +var _assign = function (tgt, src) { + for (var k in src) { + if (_hasOwnProperty.call(src, k)) + tgt[k] = src[k]; + } + return tgt; +}; + + +HTMLTools.TemplateTag = function (props) { + if (! (this instanceof HTMLTools.TemplateTag)) + // called without `new` + return new HTMLTools.TemplateTag; + + if (props) + _assign(this, props); +}; + +_assign(HTMLTools.TemplateTag.prototype, { + constructorName: 'HTMLTools.TemplateTag', + toJS: function (visitor) { + return visitor.generateCall(this.constructorName, + _assign({}, this)); + } +}); diff --git a/packages/html-tools/tokenize.js b/packages/html-tools/tokenize.js index 3bcf82fc75..de0b9c0662 100644 --- a/packages/html-tools/tokenize.js +++ b/packages/html-tools/tokenize.js @@ -20,7 +20,8 @@ // isEnd: Boolean (optional), // isSelfClosing: Boolean (optional), // n: String (tag name, in lowercase or camel case), -// attrs: { String: [zero or more 'Chars' or 'CharRef' objects] } +// attrs: dictionary of { String: [tokens] } +// OR [{ String: [tokens] }, TemplateTag tokens...] // (only for start tags; required) // } // @@ -37,8 +38,8 @@ // Most named entities and all numeric character references are one codepoint // (e.g. "&" is [38]), but a few are two codepoints. // -// { t: 'Special', -// v: { ... anything ... } +// { t: 'TemplateTag', +// v: HTMLTools.TemplateTag // } // The HTML tokenization spec says to preprocess the input stream to replace @@ -199,31 +200,38 @@ getDoctype = HTMLTools.Parse.getDoctype = function (scanner) { // of a Chars, so that we have a chance to detect template tags. var getChars = makeRegexMatcher(/^[^&<\u0000][^&<\u0000{]*/); +var assertIsTemplateTag = function (x) { + if (! (x instanceof HTMLTools.TemplateTag)) + throw new Error("Expected an instance of HTMLTools.TemplateTag"); + return x; +}; + // Returns the next HTML token, or `null` if we reach EOF. // -// Note that if we have a `getSpecialTag` function that sometimes +// Note that if we have a `getTemplateTag` function that sometimes // consumes characters and emits nothing (e.g. in the case of template // comments), we may go from not-at-EOF to at-EOF and return `null`, // while otherwise we always find some token to return. getHTMLToken = HTMLTools.Parse.getHTMLToken = function (scanner, dataMode) { var result = null; - if (scanner.getSpecialTag) { - // Try to parse a "special tag" by calling out to the provided - // `getSpecialTag` function. If the function returns `null` but - // consumes characters, it must have parsed a comment, so we return null - // and allow the lexer to continue. If it ever returns `null` without + if (scanner.getTemplateTag) { + // Try to parse a template tag by calling out to the provided + // `getTemplateTag` function. If the function returns `null` but + // consumes characters, it must have parsed a comment or something, + // so we loop and try it again. If it ever returns `null` without // consuming anything, that means it didn't see anything interesting // so we look for a normal token. If it returns a truthy value, - // the value must be an object. We wrap it in a Special token. + // the value must be instanceof HTMLTools.TemplateTag. We wrap it + // in a Special token. var lastPos = scanner.pos; - result = scanner.getSpecialTag( + result = scanner.getTemplateTag( scanner, (dataMode === 'rcdata' ? TEMPLATE_TAG_POSITION.IN_RCDATA : (dataMode === 'rawtext' ? TEMPLATE_TAG_POSITION.IN_RAWTEXT : TEMPLATE_TAG_POSITION.ELEMENT))); if (result) - return { t: 'Special', v: result }; + return { t: 'TemplateTag', v: assertIsTemplateTag(result) }; else if (scanner.pos > lastPos) return null; } @@ -293,10 +301,13 @@ var handleEndOfTag = function (scanner, tag) { return null; }; -var getQuotedAttributeValue = function (scanner, quote) { - if (scanner.peek() !== quote) - return null; - scanner.pos++; +// Scan a quoted or unquoted attribute value (omit `quote` for unquoted). +var getAttributeValue = function (scanner, quote) { + if (quote) { + if (scanner.peek() !== quote) + return null; + scanner.pos++; + } var tokens = []; var charsTokenToExtend = null; @@ -304,27 +315,29 @@ var getQuotedAttributeValue = function (scanner, quote) { var charRef; while (true) { var ch = scanner.peek(); - var special; + var templateTag; var curPos = scanner.pos; - if (ch === quote) { + if (quote && ch === quote) { scanner.pos++; return tokens; + } else if ((! quote) && (HTML_SPACE.test(ch) || ch === '>')) { + return tokens; } else if (! ch) { - scanner.fatal("Unclosed quoted attribute in tag"); - } else if (ch === '\u0000') { - scanner.fatal("Unexpected NULL character in attribute value"); - } else if (ch === '&' && (charRef = getCharacterReference(scanner, true, quote))) { + scanner.fatal("Unclosed attribute in tag"); + } else if (quote ? ch === '\u0000' : ('\u0000"\'<=`'.indexOf(ch) >= 0)) { + scanner.fatal("Unexpected character in attribute value"); + } else if (ch === '&' && + (charRef = getCharacterReference(scanner, true, + quote || '>'))) { tokens.push(charRef); charsTokenToExtend = null; - } else if (scanner.getSpecialTag && - ((special = scanner.getSpecialTag(scanner, - TEMPLATE_TAG_POSITION.IN_ATTRIBUTE)) || + } else if (scanner.getTemplateTag && + ((templateTag = scanner.getTemplateTag( + scanner, TEMPLATE_TAG_POSITION.IN_ATTRIBUTE)) || scanner.pos > curPos /* `{{! comment}}` */)) { - // note: this code is messy because it turns out to be annoying for getSpecialTag - // to return `null` when it scans a comment. Also, this code should be de-duped - // with getUnquotedAttributeValue - if (special) { - tokens.push({t: 'Special', v: special}); + if (templateTag) { + tokens.push({t: 'TemplateTag', + v: assertIsTemplateTag(templateTag)}); charsTokenToExtend = null; } } else { @@ -334,48 +347,13 @@ var getQuotedAttributeValue = function (scanner, quote) { } charsTokenToExtend.v += (ch === '\r' ? '\n' : ch); scanner.pos++; - if (ch === '\r' && scanner.peek() === '\n') + if (quote && ch === '\r' && scanner.peek() === '\n') scanner.pos++; } } }; -var getUnquotedAttributeValue = function (scanner) { - var tokens = []; - var charsTokenToExtend = null; - - var charRef; - while (true) { - var ch = scanner.peek(); - var special; - var curPos = scanner.pos; - if (HTML_SPACE.test(ch) || ch === '>') { - return tokens; - } else if (! ch) { - scanner.fatal("Unclosed attribute in tag"); - } else if ('\u0000"\'<=`'.indexOf(ch) >= 0) { - scanner.fatal("Unexpected character in attribute value"); - } else if (ch === '&' && (charRef = getCharacterReference(scanner, true, '>'))) { - tokens.push(charRef); - charsTokenToExtend = null; - } else if (scanner.getSpecialTag && - ((special = scanner.getSpecialTag(scanner, - TEMPLATE_TAG_POSITION.IN_ATTRIBUTE)) || - scanner.pos > curPos /* `{{! comment}}` */)) { - if (special) { - tokens.push({t: 'Special', v: special}); - charsTokenToExtend = null; - } - } else { - if (! charsTokenToExtend) { - charsTokenToExtend = { t: 'Chars', v: '' }; - tokens.push(charsTokenToExtend); - } - charsTokenToExtend.v += ch; - scanner.pos++; - } - } -}; +var hasOwnProperty = Object.prototype.hasOwnProperty; getTagToken = HTMLTools.Parse.getTagToken = function (scanner) { if (! (scanner.peek() === '<' && scanner.rest().charAt(1) !== '!')) @@ -419,6 +397,7 @@ getTagToken = HTMLTools.Parse.getTagToken = function (scanner) { scanner.fatal("End tag can't have attributes"); tag.attrs = {}; + var nondynamicAttrs = tag.attrs; while (true) { // Note: at the top of this loop, we've already skipped any spaces. @@ -427,15 +406,17 @@ getTagToken = HTMLTools.Parse.getTagToken = function (scanner) { // require spaces (or else an end of tag, i.e. `>` or `/>`). var spacesRequiredAfter = false; - // first, try for a special tag. + // first, try for a template tag. var curPos = scanner.pos; - var special = (scanner.getSpecialTag && - scanner.getSpecialTag(scanner, - TEMPLATE_TAG_POSITION.IN_START_TAG)); - if (special || (scanner.pos > curPos)) { - if (special) { - tag.attrs.$specials = (tag.attrs.$specials || []); - tag.attrs.$specials.push({ t: 'Special', v: special }); + var templateTag = (scanner.getTemplateTag && + scanner.getTemplateTag( + scanner, TEMPLATE_TAG_POSITION.IN_START_TAG)); + if (templateTag || (scanner.pos > curPos)) { + if (templateTag) { + if (tag.attrs === nondynamicAttrs) + tag.attrs = [nondynamicAttrs]; + tag.attrs.push({ t: 'TemplateTag', + v: assertIsTemplateTag(templateTag) }); } // else, must have scanned a `{{! comment}}` spacesRequiredAfter = true; @@ -452,10 +433,10 @@ getTagToken = HTMLTools.Parse.getTagToken = function (scanner) { scanner.fatal("Unexpected `{` in attribute name."); attributeName = HTMLTools.properCaseAttributeName(attributeName); - if (tag.attrs.hasOwnProperty(attributeName)) + if (hasOwnProperty.call(nondynamicAttrs, attributeName)) scanner.fatal("Duplicate attribute in tag: " + attributeName); - tag.attrs[attributeName] = []; + nondynamicAttrs[attributeName] = []; skipSpaces(scanner); @@ -480,15 +461,15 @@ getTagToken = HTMLTools.Parse.getTagToken = function (scanner) { scanner.fatal("Unexpected character after = in tag"); if ((ch === '"') || (ch === "'")) - tag.attrs[attributeName] = getQuotedAttributeValue(scanner, ch); + nondynamicAttrs[attributeName] = getAttributeValue(scanner, ch); else - tag.attrs[attributeName] = getUnquotedAttributeValue(scanner); + nondynamicAttrs[attributeName] = getAttributeValue(scanner); spacesRequiredAfter = true; } } - // now we are in the "post-attribute" position, whether it was a special attribute - // (like `{{x}}`) or a normal one (like `x` or `x=y`). + // now we are in the "post-attribute" position, whether it was a template tag + // attribute (like `{{x}}`) or a normal one (like `x` or `x=y`). if (handleEndOfTag(scanner, tag)) return tag; diff --git a/packages/htmljs/README.md b/packages/htmljs/README.md index ef2d26ab88..c4cc3e22bb 100644 --- a/packages/htmljs/README.md +++ b/packages/htmljs/README.md @@ -1,7 +1,10 @@ +*This file is automatically generated from [`html.js`](html.js).* + # HTMLjs -A small (500-line) library for expressing HTML trees in a concise -syntax. This library is used at compile time and run time by Meteor UI. +HTMLjs is a small library for expressing HTML trees with a concise +syntax. It is used to render content in Blaze and to represent +templates during compilation. ``` var UL = HTML.UL, LI = HTML.LI, B = HTML.B; @@ -19,219 +22,408 @@ HTML.toHTML( ``` -The functions `UL`, `LI`, and so on are "tag constructors" which -return an object representation that can be used to generate HTML, or, -via other packages, be used to generate DOM (`ui`), be parsed from -HTML (`html-tools`), or serve as the backbone of the intermediate -representation for a template compiler (`spacebars-compiler`). +The functions `UL`, `LI`, and `B` are constructors which +return instances of `HTML.Tag`. These tag objects can +then be converted to an HTML string or directly into DOM nodes. -## Syntax +The flexible structure of HTMLjs allows different kinds of Blaze +directives to be embedded in the tree. HTMLjs does not know about +these directives, which are considered "foreign objects." -Tag constructors take an optional first argument `attrs` followed by -zero or more arguments, the `children`. The first argument is taken -to be `attrs` if it is a "vanilla" JavaScript object such as an object -literal. +# Built-in Types -> Ideally, a "vanilla" object would be one whose direct prototype is -> `Object.prototype`. Since this test is impossible in IE 8, we test -> `obj.constructor === Object`, which is true for object literals -> (except ones like `{constructor: blah}`!) and false for most objects -> with custom prototypes (because JavaScript sets -> `MyClass.prototype.constructor = MyClass` when you create a function -> `MyClass`). +The following types are built into HTMLjs. Built-in methods like +`HTML.toHTML` require a tree consisting only of these types. -Children of a tag may be of any of several built-in types: +* __`null`, `undefined`__ - Render to nothing. -* Tag (HTML.Tag) -* HTML.CharRef -* HTML.Comment -* HTML.Raw -* String -* Boolean or Number (which will be converted to String) -* Array (which will be flattened) -* Null or undefined (which will be ignored) -* Template/Component -* Function returning one of these types +* __boolean, number__ - Render to the string form of the boolean or number. -The set of allowed types is *open* in that any object may be included -in the tree as long as the code consuming the tree can handle it. +* __string__ - Renders to a text node (or part of an attribute value). All characters are safe, and no HTML injection is possible. The string `"
"` renders `<a>` in HTML, and `document.createTextNode("")` in DOM. -Character references (like `&`) are *not* interpreted in strings. -To include a character reference, use `HTML.CharRef({html: -'&', str: '&'})`, specifying both the raw HTML form and the string -form of the character. +* __Array__ - Renders to its elements in order. An array may be empty. Arrays are detected using `HTML.isArray(...)`. -> In other words, string values are of the form you would pass to -`document.createTextNode`, not of the form you would see in an HTML -document. The intent here is to only need to parse and interpret -character references at compile time, making the representation -maximally flexible easy to consume at runtime. -> -> The reason we represent character references at all, rather than -> simply converting them to Unicode when parsing the source HTML -> (and then escaping `&` and `<` at the very end) -> is 1) to preserve the HTML author's intent, and 2) in case there -> is a character-encoding-related reason that a character reference -> is being used. +* __`HTML.Tag`__ - Renders to an HTML element (including start tag, contents, and end tag). -Attribute values can contain character references, using arrays to -hold the string and CharRef parts: +* __`HTML.CharRef({html: ..., str: ...})`__ - Renders to a character reference (such as ` `) when generating HTML. + +* __`HTML.Comment(text)`__ - Renders to an HTML comment. + +* __`HTML.Raw(html)`__ - Renders to a string of HTML to include verbatim. + +The `new` keyword is not required before constructors of HTML object types. + +All objects and arrays should be considered immutable. Instance properties +are public, but they should only be read, not written. Arrays should not +be spliced in place. This convention allows for clean patterns of +processing and transforming HTMLjs trees. + + +## HTML.Tag + +An `HTML.Tag` is created using a tag-specific constructor, like +`HTML.P` for a `

` tag or `HTML.INPUT` for an `` tag. The +resulting object is `instanceof HTML.Tag`. (The `HTML.Tag` +constructor should not be called directly.) + +Tag constructors take an optional attributes dictionary followed +by zero or more children: ``` -var amp = HTML.CharRef({html: '&', str: '&'}); +HTML.HR() -HTML.toHTML(HTML.SPAN({title: ['M', amp, 'Ms']}, - 'M', amp, 'M candies')) +HTML.DIV(HTML.P("First paragraph"), + HTML.P("Second paragraph")) + +HTML.INPUT({type: "text"}) + +HTML.SPAN({'class': "foo"}, "Some text") ``` -``` -M&M candies -``` +### Instance properties -A comment looks like `HTML.Comment("value here")`, where the value -should not contain two consecutive hyphen (`-`) characters or an -initial or final hyphen (or they will be stripped out). +Tags have the following properties: -A "raw" object like `HTML.Raw("
")` represents raw HTML to insert -into the document. The HTML should be known to be safe and contain -balanced tags! It will be injected without any parsing or checking -when the representation is converted to an HTML string. If the -representation is used to generate DOM directly, the "raw" node will -be materialized using an innerHTML-like method. +* `tagName` - The tag name in lowercase (or camelCase) +* `children` - An array of children (always present) +* `attrs` - An attributes dictionary, `null`, or an array (see below) -Functions in the tree are used as reactivity boundaries when -generating DOM directly. When generating HTML, they are simply called -for their return value. Functions are passed no arguments and are -given no particular value of `this`. -Templates/components like `Template.foo` can also be included in the -representation. HTMLjs has very limited knowledge of what a component -is. It knows components have an `instantiate` method that returns -something with a `render` method. Operations that realize an HTMLjs -tree as HTML, DOM, or some other form have a bit of boilerplate that -they use to detect and instantiate components: +### Special forms of attributes + +The attributes of a Tag may be an array of dictionaries. In order +for a tag constructor to recognize an array as the attributes argument, +it must be written as `HTML.Attrs(attrs1, attrs2, ...)`, as in this +example: ``` -HTML.toHTML = function (node, parentComponent) { - // ... handle various types of `node` - if (typeof node.instantiate === 'function') { - // component - var instance = node.instantiate(parentComponent || null); - var content = instance.render(); - // recurse with a new value for parentComponent - return HTML.toHTML(content, instance); - } - // ... -}; +var extraAttrs = {'class': "container"}; + +var div = HTML.DIV(HTML.Attrs({id: "main"}, extraAttrs), + "This is the content."); + +div.attrs // => [{id: "main"}, {'class': "container"}] ``` -The argument `parentComponent` is used to set a pointer that points -from each component to its parent, used for name lookups. +`HTML.Attrs` may also be used to pass a foreign object in place of +an attributes dictionary of a tag. -## "Known" and Custom Tags -All the usual HTML and HTML5 tags are available as `HTML.A`, -`HTML.ABBR`, `HTML.ADDRESS`, etc. These tags are called "known" tags -and have predefined tag constructors. If you want to use a custom -tag, you'll have to create the tag constructor using `getTag` or `ensureTag`. + +### Normalized Case for Tag Names + +The `tagName` field is always in "normalized case," which is the +official case for that particular element name (usually lowercase). +For example, `HTML.DIV().tagName` is `"div"`. For some elements +used in inline SVG graphics, the correct case is "camelCase." For +example, there is an element named `clipPath`. + +Web browsers have a confusing policy about case. They perform case +normalization when parsing HTML, but not when creating SVG elements +at runtime; the correct case is required. + +Therefore, in order to avoid ever having to normalize case at +runtime, the policy of HTMLjs is to put the burden on the caller +of functions like `HTML.ensureTag` -- for example, a template +engine -- of supplying correct normalized case. + +Briefly put, normlized case is usually lowercase, except for certain +elements where it is camelCase. + + +### Known Elements + +HTMLjs comes preloaded with constructors for all "known" HTML and +SVG elements. You can use `HTML.P`, `HTML.DIV`, and so on out of +the box. If you want to create a tag like `` for some reason, +you have to tell HTMLjs to create the `HTML.FOO` constructor for you +using `HTML.ensureTag` or `HTML.getTag`. + +HTMLjs's lists of known elements are public because they are useful to +other packages that provide additional functions not found here, like +functions for normalizing case. + + + +## Foreign objects + +Arbitrary objects are allowed in HTMLjs trees, which is useful for +adapting HTMLjs to a wide variety of uses. Such objects are called +foreign objects. + +The one restriction on foreign objects is that they must be +instances of a class -- so-called "constructed objects" (see +`HTML.isConstructedObject`) -- so that they can be distinguished +from the vanilla JS objects that represent attributes dictionaries +when constructing Tags. + +Functions are also considered foreign objects. + +## HTML.getTag(tagName) + +* `tagName` - A string in normalized case + +Creates a tag constructor for `tagName`, assigns it to the `HTML` +namespace object, and returns it. + +For example, `HTML.getTag("p")` returns `HTML.P`. `HTML.getTag("foo")` +will create and return `HTML.FOO`. + +It's very important that `tagName` be in normalized case, or else +an incorrect tag constructor will be registered and used henceforth. + + +## HTML.ensureTag(tagName) + +* `tagName` - A string in normalized case + +Ensures that a tag constructor (like `HTML.FOO`) exists for a tag +name (like `"foo"`), creating it if necessary. Like `HTML.getTag` +but does not return the tag constructor. + + +## HTML.isTagEnsured(tagName) + +* `tagName` - A string in normalized case + +Returns whether a particular tag is guaranteed to be available on +the `HTML` object (under the name returned by `HTML.getSymbolName`). + +Useful for code generators. + + +## HTML.getSymbolName(tagName) + +* `tagName` - A string in normalized case + +Returns the name of the all-caps constructor (like `"FOO"`) for a +tag name in normalized case (like `"foo"`). + +In addition to converting `tagName` to all-caps, hyphens (`-`) in +tag names are converted to underscores (`_`). + +Useful for code generators. + + +## HTML.knownElementNames + +An array of all known HTML5 and SVG element names in normalized case. + + +## HTML.knownSVGElementNames + +An array of all known SVG element names in normalized case. + +The `"a"` element is not included because it is primarily a non-SVG +element. + + +## HTML.voidElementNames + +An array of all "void" element names in normalized case. Void +elements are elements with a start tag and no end tag, such as BR, +HR, IMG, and INPUT. + +The HTML spec defines a closed class of void elements. + + +## HTML.isKnownElement(tagName) + +* `tagName` - A string in normalized case + +Returns whether `tagName` is a known HTML5 or SVG element. + + +## HTML.isKnownSVGElement(tagName) + +* `tagName` - A string in normalized case + +Returns whether `tagName` is the name of a known SVG element. + + +## HTML.isVoidElement(tagName) + +* `tagName` - A string in normalized case + +Returns whether `tagName` is the name of a void element. + + +## HTML.CharRef({html: ..., str: ...}) + +Represents a character reference like ` `. + +A CharRef is not required for escaping special characters like `<`, +which are automatically escaped by HTMLjs. For example, +`HTML.toHTML("<")` is `"<"`. Also, now that browsers speak +Unicode, non-ASCII characters typically do not need to be expressed +as character references either. The purpose of `CharRef` is offer +control over the generated HTML, allowing template engines to +preserve any character references that they come across. + +Constructing a CharRef requires two strings, the uninterpreted +"HTML" form and the interpreted "string" form. Both are required +to be present, and it is up to the caller to make sure the +information is accurate. + +Examples of valid CharRefs: + +* `HTML.CharRef({html: '&', str: '&'})` +* `HTML.CharRef({html: ' ', str: '\u00A0'}) + +Instance properties: `.html`, `.str` + + +## HTML.Comment(value) + +* `value` - String + +Represents an HTML Comment. For example, `HTML.Comment("foo")` represents +the comment ``. + +The value string should not contain two consecutive hyphens (`--`) or start +or end with a hyphen. If it does, the offending hyphens will be stripped +before generating HTML. + +Instance properties: `value` + + +## HTML.Raw(value) + +* `value` - String + +Represents HTML code to be inserted verbatim. `value` must consist +of a valid, complete fragment of HTML, with all tags closed and +all required end tags present. + +No security checks are performed, and no special characters are +escaped. `Raw` should not be used if there are other ways of +accomplishing the same result. HTML supplied by an application +user should not be rendered unless the user is trusted, and even +then, there could be strange results if required end tags are +missing. + +Instance properties: `value` + + +## HTML.isArray(x) + +Returns whether `x` is considered an array for the purposes of +HTMLjs. An array is an object created using `[...]` or +`new Array`. + +This function is provided because there are several common ways to +determine whether an object should be treated as an array in +JavaScript. + + +## HTML.isConstructedObject(x) + +Returns whether `x` is a "constructed object," which is (loosely +speaking) an object that was created with `new Foo` (for some `Foo`) +rather than with `{...}` (a vanilla object). Vanilla objects are used +as attribute dictionaries when constructing tags, while constructed +objects are used as children. + +For example, in `HTML.DIV({id:"foo"})`, `{id:"foo"}` is a vanilla +object. In `HTML.DIV(HTML.SPAN("text"))`, the `HTML.SPAN` is a +constructed object. + +A simple constructed object can be created as follows: ``` -var SPAN = HTML.SPAN; -var FOO = HTML.getTag('FOO'); +var Foo = function () {}; +var x = new Foo; // x is a constructed object ``` -``` -HTML.ensureTag('FOO'); -var SPAN = HTML.SPAN; -var FOO = HTML.FOO; -``` +In particular, the test is that `x` is an object and `x.constructor` +is set, but on a prototype of the object, not the object itself. +The above example works because JavaScript sets +`Foo.prototype.constructor = Foo` when you create a function `Foo`. -All of these functions handle case conversion of `tagName` as -appropriate, so whether you provide `foo` or `Foo` or `FOO`, the -symbol on `HTML` will be `HTML.FOO`, while generated HTML and DOM will use -the lowercase name `foo`. -`HTML.getTag(tagName)` - Returns a tag constructor for `tagName`, calling `ensureTag` if it doesn't exist. +## HTML.isNully(content) -`HTML.ensureTag(tagName)` - Creates a tag constructor for `tagName` if one doesn't exist and attaches it to the `HTML` object. +Returns true if `content` is `null`, `undefined`, an empty array, +or an array of only "nully" elements. -`HTML.isTagEnsured(tagName)` - Returns true if `tagName` has a built-in, predefined constructor. Useful for code generators that want to know if they should emit a call to `ensureTag`. -## Object Representation +## HTML.isValidAttributeName(name) -Tag constructors follow an object-oriented paradigm with optional -`new`. The returned objects are `instanceof` the tag constructor and -also of `HTML.Tag`. In other words, all of the following are true: +Returns whether `name` is a valid name for an attribute of an HTML tag +or element. `name` must: + +* Start with `:`, `_`, `A-Z` or `a-z` +* Consist only of those characters plus `-`, `.`, and `0-9`. + +Discussion: The HTML spec and the DOM API (`setAttribute`) have different +definitions of what characters are legal in an attribute. The HTML +parser is extremely permissive (allowing, for example, `
`), while +`setAttribute` seems to use something like the XML grammar for names (and +throws an error if a name is invalid, making that attribute unsettable). +If we knew exactly what grammar browsers used for `setAttribute`, we could +include various Unicode ranges in what's legal. For now, we allow ASCII chars +that are known to be valid XML, valid HTML, and settable via `setAttribute`. + +See and +. + + +## HTML.flattenAttributes(attrs) + +If `attrs` is an array, the attribute dictionaries in the array are +combined into a single attributes dictionary, which is returned. +Any "nully" attribute values (see `HTML.isNully`) are ignored in +the process. If `attrs` is a single attribute dictionary, a copy +is returned with any nully attributes removed. If `attrs` is +equal to null or an empty array, `null` is returned. + +Attribute dictionaries are combined by assigning the name/value +pairs in array order, with later values overwriting previous +values. + +`attrs` must not contain any foreign objects. + + +## HTML.toHTML(content) + +* `content` - any HTMLjs content + +Returns a string of HTML generated from `content`. + +For example: ``` -HTML.P() instanceof HTML.P -HTML.P() instanceof HTML.Tag -(new HTML.P) instanceof HTML.P -(new HTML.P) instanceof HTML.Tag +HTML.toHTML(HTML.HR()) // => "


" ``` -Similarly, objects constructed with `HTML.Comment` are instances of `HTML.Comment`, and so on. +Foreign objects are not allowed in `content`. To generate HTML +containing foreign objects, create a subclass of +`HTML.ToHTMLVisitor` and override `visitObject`. -In general, HTMLjs objects should be considered immutable. -HTML.Tag objects have these properties: +## HTML.toText(content, textMode) -* `tagName` - the uppercase tag name -* `attrs` - an object or null -* `children` - an array of zero or more children +* `content` - any HTMLjs content +* `textMode` - the type of text to generate; one of + `HTML.TEXTMODE.STRING`, `HTML.TEXTMODE.RCDATA`, or + `HTML.TEXTMODE.ATTRIBUTE` -HTML.CharRef objects have `html` and `str` properties, specified by -the object passed to the constructor. +Generating HTML or DOM from HTMLjs content requires generating text +for attribute values and for the contents of TEXTAREA elements, +among others. The input content may contain strings, arrays, +booleans, numbers, nulls, and CharRefs. Behavior on other types +is undefined. -HTML.Comment and HTML.Raw objects have a `value` property. - -### Attributes - -Attribute values can contain most kinds of HTMLjs content, but not Tag, Comment, or Raw. They may contain functions and components, even though these functions won't ever establish reactivity boundaries at a finer level than an entire attribute value. - -The attributes dictionary of a tag can have a special entry `$dynamic`, which holds additional attributes dictionaries to combine with the main dictionary. These additional dictionaries may be computed by functions, lending generality to the calculation of the attributes dictionary that would not otherwise be present. - -Specifically, the value of `$dynamic` must be an array, each element of which is either an attributes dictionary or a function returning an attributes dictionary. (These dictionaries may not themselves have a `$dynamic` key.) When calculating the final attributes dictionary for a tag, each dictionary obtained from the `$dynamic` array is used to modify the existing dictionary by copying the new attribute entries over it, except for entries with a "nully" value. A "nully" value is one that is either `null`, `undefined`, `[]`, or an array of nully values. Before checking if the dynamic attribute value is nully, all functions and components are evaluated (i.e. the functions are called and the components are instantiated, such that no functions or components remain). - -The `$dynamic` feature is designed to support writing `
` in templates. - -`HTML.evaluateDynamicAttributes(attrs, parentComponent)` - Returns the final attributes dictionary for a tag after interpreting the `$dynamic` property, if present. Takes a tag's `attrs` property and a `parentComponent` (used to instantiate any components in the attributes). `attrs` may be null. - -`tag.evaluateDynamicAttributes(parentComponent)` - Shorthand for `HTML.evaluateDynamicAttributes(tag.attrs, parentComponent)`. - -`HTML.isNully(value)` - Returns true if `value` is a nully value, i.e. one of `null`, `undefined`, `[]`, or an array of nully values. - -`HTML.evaluate(node, parentComponent)` - Calls all functions and instantiates all components in an HTMLjs tree. - -## toHTML and toText - -`HTML.toHTML(node, parentComponent)` - Converts the HTMLjs content `node` to an HTML string, using `parentComponent` as the parent scope pointer when instantiating components. - -`HTML.toText(node, textMode, parentComponent)` - Converts the HTMLjs content `node` into text, suitable for being included as part of an attribute value or textarea, for example. `node` must not contain Tags or Comments, but may contain CharRefs, functions, and components. The required argument `textMode` specifies what sort of text to generate, which affects how charater references are handled and which characters are escaped. - -* `HTML.TEXTMODE.STRING` - JavaScript string suitable for `document.createTextNode` or `element.setAttribute`. Character references are replaced by the characters they represent. No escaping is performed. - -* `HTML.TEXTMODE.ATTRIBUTE` - HTML string suitable for a quoted attribute. Character references are included in raw HTML form (i.e. `&foo;`). `&` and `"` are escaped when found in strings in the HTMLjs tree. - -* `HTML.TEXTMODE.RCDATA` - HTML string suitable for the content of a TEXTAREA element, for example. (RCDATA stands for "replaced character data" as in the HTML syntax spec.) Character references are included in raw HTML form. `&` and `<` are escaped when found in strings in the HTMLjs tree. - -> The reason to perform the escaping as part of `HTML.toText` rather than as a post-processing step is in order to support `HTML.CharRef`, allowing the HTML author's choice of character reference encoding to be passed through. If we only had `STRING` mode, we would lose the original form of the character references. If we only had `RCDATA` mode, say, we would have to interpret the character references at runtime to use the DOM API. On a related note, we don't allow `HTML.Raw` because character references are the only "raw" thing there is in text mode (and, again, we don't want to interpret them at runtime). `HTML.CharRef` is sort of like a one-character version of `Raw`. - -## Name Utilities - -All of these functions take case-insensitive input. - -`HTML.properCaseTagName(tagName)` - Case-convert a tag name for inclusion in HTML or passing to `document.createElement`. Most tags belong in lowercase, but there are some camel-case SVG tags. HTML processors must know the proper case for tag names, because HTML is case-insensitive but the DOM is sometimes case-sensitive. - -`HTML.properCaseAttributeName(name)` - Case-convert an attribute name for inclusion in HTML or passing to `element.setAttribute`. See `HTML.properCaseTagName`. - -`HTML.isValidAttributeName(name)` - Returns true if `name` conforms to a restricted set of legal characters known to work both in HTML and the DOM APIs. Allows at least ASCII numbers and letters, hyphens, and underscores, where the first character can't be a number or a hyphen. - -`HTML.isKnownElement(tagName)` - Returns true if `tagName` is a known HTML/HTML5 element, excluding SVG and other foreign elements. - -`HTML.isKnownSVGElement(tagName)` - Returns true if `tagName` is a known SVG element. - -`HTML.isVoidElement(tagName)` - Returns true if `tagName` is a known void element such as `BR`, `HR`, or `INPUT`. Void elements are output as `
` instead of `

`. Note that neither HTML4 nor HTML5 has true self-closing tags (except when parsing SVG). `
` is the same as `
` and `
` is the same as `
`. It was only the now-abandoned XHTML standard that said otherwise, which was a backwards-incompatible change. Modern browsers refer to the list of void elements instead. - -`HTML.asciiLowerCase(str)` - "ASCII-lowercases" `str`, converting `A-Z` to `a-z`. The case-insensitive parts of the HTML spec use this operation for case folding. +The required `textMode` argument specifies the type of text to +generate: +* `HTML.TEXTMODE.STRING` - a string with no special + escaping or encoding performed, suitable for passing to + `setAttribute` or `document.createTextNode`. +* `HTML.TEXTMODE.RCDATA` - a string with `<` and `&` encoded + as character references (and CharRefs included in their + "HTML" form), suitable for including in a string of HTML +* `HTML.TEXTMODE.ATTRIBUTE` - a string with `"` and `&` encoded + as character references (and CharRefs included in their + "HTML" form), suitable for including in an HTML attribute + value surrounded by double quotes diff --git a/packages/htmljs/html.js b/packages/htmljs/html.js index 1b41d8e5a9..1b1281b47b 100644 --- a/packages/htmljs/html.js +++ b/packages/htmljs/html.js @@ -1,32 +1,102 @@ +///!README + +/** + * # HTMLjs + * + * HTMLjs is a small library for expressing HTML trees with a concise + * syntax. It is used to render content in Blaze and to represent + * templates during compilation. + * +``` +var UL = HTML.UL, LI = HTML.LI, B = HTML.B; + +HTML.toHTML( + UL({id: 'mylist'}, + LI({'class': 'item'}, "Hello ", B("world"), "!"), + LI({'class': 'item'}, "Goodbye, world"))) +``` + +``` +
    +
  • Hello world!
  • +
  • Goodbye, world
  • +
+``` + * + * The functions `UL`, `LI`, and `B` are constructors which + * return instances of `HTML.Tag`. These tag objects can + * then be converted to an HTML string or directly into DOM nodes. + * + * The flexible structure of HTMLjs allows different kinds of Blaze + * directives to be embedded in the tree. HTMLjs does not know about + * these directives, which are considered "foreign objects." + * + * # Built-in Types + * + * The following types are built into HTMLjs. Built-in methods like + * `HTML.toHTML` require a tree consisting only of these types. + * + * * __`null`, `undefined`__ - Render to nothing. + * + * * __boolean, number__ - Render to the string form of the boolean or number. + * + * * __string__ - Renders to a text node (or part of an attribute value). All characters are safe, and no HTML injection is possible. The string `"
"` renders `<a>` in HTML, and `document.createTextNode("")` in DOM. + * + * * __Array__ - Renders to its elements in order. An array may be empty. Arrays are detected using `HTML.isArray(...)`. + * + * * __`HTML.Tag`__ - Renders to an HTML element (including start tag, contents, and end tag). + * + * * __`HTML.CharRef({html: ..., str: ...})`__ - Renders to a character reference (such as ` `) when generating HTML. + * + * * __`HTML.Comment(text)`__ - Renders to an HTML comment. + * + * * __`HTML.Raw(html)`__ - Renders to a string of HTML to include verbatim. + * + * The `new` keyword is not required before constructors of HTML object types. + * + * All objects and arrays should be considered immutable. Instance properties + * are public, but they should only be read, not written. Arrays should not + * be spliced in place. This convention allows for clean patterns of + * processing and transforming HTMLjs trees. + */ + +/** + * ## HTML.Tag + * + * An `HTML.Tag` is created using a tag-specific constructor, like + * `HTML.P` for a `

` tag or `HTML.INPUT` for an `` tag. The + * resulting object is `instanceof HTML.Tag`. (The `HTML.Tag` + * constructor should not be called directly.) + * + * Tag constructors take an optional attributes dictionary followed + * by zero or more children: + * + * ``` + * HTML.HR() + * + * HTML.DIV(HTML.P("First paragraph"), + * HTML.P("Second paragraph")) + * + * HTML.INPUT({type: "text"}) + * + * HTML.SPAN({'class': "foo"}, "Some text") + * ``` + * + * ### Instance properties + * + * Tags have the following properties: + * + * * `tagName` - The tag name in lowercase (or camelCase) + * * `children` - An array of children (always present) + * * `attrs` - An attributes dictionary, `null`, or an array (see below) + */ + -// Tag instances are `instanceof HTML.Tag`. -// -// Tag objects should be considered immutable. -// -// This is a private constructor of an abstract class; don't call it. HTML.Tag = function () {}; HTML.Tag.prototype.tagName = ''; // this will be set per Tag subclass HTML.Tag.prototype.attrs = null; HTML.Tag.prototype.children = Object.freeze ? Object.freeze([]) : []; - -// Given "p", create and assign `HTML.P` if it doesn't already exist. -// Then return it. `tagName` must have proper case (usually all lowercase). -HTML.getTag = function (tagName) { - var symbolName = HTML.getSymbolName(tagName); - if (symbolName === tagName) // all-caps tagName - throw new Error("Use the lowercase or camelCase form of '" + tagName + "' here"); - - if (! HTML[symbolName]) - HTML[symbolName] = makeTagConstructor(tagName); - - return HTML[symbolName]; -}; - -// Given "p", make sure `HTML.P` exists. `tagName` must have proper case -// (usually all lowercase). -HTML.ensureTag = function (tagName) { - HTML.getTag(tagName); // don't return it -}; +HTML.Tag.prototype.htmljsType = HTML.Tag.htmljsType = ['Tag']; // Given "p" create the function `HTML.P`. var makeTagConstructor = function (tagName) { @@ -39,18 +109,29 @@ var makeTagConstructor = function (tagName) { var i = 0; var attrs = arguments.length && arguments[0]; - if (attrs && (typeof attrs === 'object') && - (attrs.constructor === Object)) { - instance.attrs = attrs; - i++; + if (attrs && (typeof attrs === 'object')) { + // Treat vanilla JS object as an attributes dictionary. + if (! HTML.isConstructedObject(attrs)) { + instance.attrs = attrs; + i++; + } else if (attrs instanceof HTML.Attrs) { + var array = attrs.value; + if (array.length === 1) { + instance.attrs = array[0]; + } else if (array.length > 1) { + instance.attrs = array; + } + i++; + } } + // If no children, don't create an array at all, use the prototype's // (frozen, empty) array. This way we don't create an empty array // every time someone creates a tag without `new` and this constructor // calls itself with no arguments (above). if (i < arguments.length) - instance.children = Array.prototype.slice.call(arguments, i); + instance.children = SLICE.call(arguments, i); return instance; }; @@ -61,46 +142,195 @@ var makeTagConstructor = function (tagName) { return HTMLTag; }; -var CharRef = HTML.CharRef = function (attrs) { - if (! (this instanceof CharRef)) - // called without `new` - return new CharRef(attrs); +/** + * ### Special forms of attributes + * + * The attributes of a Tag may be an array of dictionaries. In order + * for a tag constructor to recognize an array as the attributes argument, + * it must be written as `HTML.Attrs(attrs1, attrs2, ...)`, as in this + * example: + * + * ``` + * var extraAttrs = {'class': "container"}; + * + * var div = HTML.DIV(HTML.Attrs({id: "main"}, extraAttrs), + * "This is the content."); + * + * div.attrs // => [{id: "main"}, {'class': "container"}] + * ``` + * + * `HTML.Attrs` may also be used to pass a foreign object in place of + * an attributes dictionary of a tag. + * + */ +// Not an HTMLjs node, but a wrapper to pass multiple attrs dictionaries +// to a tag (for the purpose of implementing dynamic attributes). +var Attrs = HTML.Attrs = function (/*attrs dictionaries*/) { + // Work with or without `new`. If not called with `new`, + // perform instantiation by recursively calling this constructor. + // We can't pass varargs, so pass no args. + var instance = (this instanceof Attrs) ? this : new Attrs; - if (! (attrs && attrs.html && attrs.str)) - throw new Error( - "HTML.CharRef must be constructed with ({html:..., str:...})"); + instance.value = SLICE.call(arguments); - this.html = attrs.html; - this.str = attrs.str; + return instance; }; -var Comment = HTML.Comment = function (value) { - if (! (this instanceof Comment)) - // called without `new` - return new Comment(value); +/** + * ### Normalized Case for Tag Names + * + * The `tagName` field is always in "normalized case," which is the + * official case for that particular element name (usually lowercase). + * For example, `HTML.DIV().tagName` is `"div"`. For some elements + * used in inline SVG graphics, the correct case is "camelCase." For + * example, there is an element named `clipPath`. + * + * Web browsers have a confusing policy about case. They perform case + * normalization when parsing HTML, but not when creating SVG elements + * at runtime; the correct case is required. + * + * Therefore, in order to avoid ever having to normalize case at + * runtime, the policy of HTMLjs is to put the burden on the caller + * of functions like `HTML.ensureTag` -- for example, a template + * engine -- of supplying correct normalized case. + * + * Briefly put, normlized case is usually lowercase, except for certain + * elements where it is camelCase. + */ - if (typeof value !== 'string') - throw new Error('HTML.Comment must be constructed with a string'); +////////////////////////////// KNOWN ELEMENTS - this.value = value; - // Kill illegal hyphens in comment value (no way to escape them in HTML) - this.sanitizedValue = value.replace(/^-|--+|-$/g, ''); +/** + * ### Known Elements + * + * HTMLjs comes preloaded with constructors for all "known" HTML and + * SVG elements. You can use `HTML.P`, `HTML.DIV`, and so on out of + * the box. If you want to create a tag like `` for some reason, + * you have to tell HTMLjs to create the `HTML.FOO` constructor for you + * using `HTML.ensureTag` or `HTML.getTag`. + * + * HTMLjs's lists of known elements are public because they are useful to + * other packages that provide additional functions not found here, like + * functions for normalizing case. + * + */ + +/** + * ## Foreign objects + * + * Arbitrary objects are allowed in HTMLjs trees, which is useful for + * adapting HTMLjs to a wide variety of uses. Such objects are called + * foreign objects. + * + * The one restriction on foreign objects is that they must be + * instances of a class -- so-called "constructed objects" (see + * `HTML.isConstructedObject`) -- so that they can be distinguished + * from the vanilla JS objects that represent attributes dictionaries + * when constructing Tags. + * + * Functions are also considered foreign objects. + */ + +/** + * ## HTML.getTag(tagName) + * + * * `tagName` - A string in normalized case + * + * Creates a tag constructor for `tagName`, assigns it to the `HTML` + * namespace object, and returns it. + * + * For example, `HTML.getTag("p")` returns `HTML.P`. `HTML.getTag("foo")` + * will create and return `HTML.FOO`. + * + * It's very important that `tagName` be in normalized case, or else + * an incorrect tag constructor will be registered and used henceforth. + */ +HTML.getTag = function (tagName) { + var symbolName = HTML.getSymbolName(tagName); + if (symbolName === tagName) // all-caps tagName + throw new Error("Use the lowercase or camelCase form of '" + tagName + "' here"); + + if (! HTML[symbolName]) + HTML[symbolName] = makeTagConstructor(tagName); + + return HTML[symbolName]; +}; + +/** + * ## HTML.ensureTag(tagName) + * + * * `tagName` - A string in normalized case + * + * Ensures that a tag constructor (like `HTML.FOO`) exists for a tag + * name (like `"foo"`), creating it if necessary. Like `HTML.getTag` + * but does not return the tag constructor. + */ +HTML.ensureTag = function (tagName) { + HTML.getTag(tagName); // don't return it +}; + +/** + * ## HTML.isTagEnsured(tagName) + * + * * `tagName` - A string in normalized case + * + * Returns whether a particular tag is guaranteed to be available on + * the `HTML` object (under the name returned by `HTML.getSymbolName`). + * + * Useful for code generators. + */ +HTML.isTagEnsured = function (tagName) { + return HTML.isKnownElement(tagName); +}; + +/** + * ## HTML.getSymbolName(tagName) + * + * * `tagName` - A string in normalized case + * + * Returns the name of the all-caps constructor (like `"FOO"`) for a + * tag name in normalized case (like `"foo"`). + * + * In addition to converting `tagName` to all-caps, hyphens (`-`) in + * tag names are converted to underscores (`_`). + * + * Useful for code generators. + */ +HTML.getSymbolName = function (tagName) { + // "foo-bar" -> "FOO_BAR" + return tagName.toUpperCase().replace(/-/g, '_'); }; -//---------- KNOWN ELEMENTS - -// These lists of known elements are public. You can use them, for example, to -// write a helper that determines the proper case for an SVG element name. -// Such helpers that may not be needed at runtime are not provided here. - +/** + * ## HTML.knownElementNames + * + * An array of all known HTML5 and SVG element names in normalized case. + */ HTML.knownElementNames = 'a abbr acronym address applet area b base basefont bdo big blockquote body br button caption center cite code col colgroup dd del dfn dir div dl dt em fieldset font form frame frameset h1 h2 h3 h4 h5 h6 head hr html i iframe img input ins isindex kbd label legend li link map menu meta noframes noscript object ol optgroup option p param pre q s samp script select small span strike strong style sub sup table tbody td textarea tfoot th thead title tr tt u ul var article aside audio bdi canvas command data datagrid datalist details embed eventsource figcaption figure footer header hgroup keygen mark meter nav output progress ruby rp rt section source summary time track video wbr'.split(' '); +// (we add the SVG ones below) -// omitted because also an HTML element: "a" +/** + * ## HTML.knownSVGElementNames + * + * An array of all known SVG element names in normalized case. + * + * The `"a"` element is not included because it is primarily a non-SVG + * element. + */ HTML.knownSVGElementNames = 'altGlyph altGlyphDef altGlyphItem animate animateColor animateMotion animateTransform circle clipPath color-profile cursor defs desc ellipse feBlend feColorMatrix feComponentTransfer feComposite feConvolveMatrix feDiffuseLighting feDisplacementMap feDistantLight feFlood feFuncA feFuncB feFuncG feFuncR feGaussianBlur feImage feMerge feMergeNode feMorphology feOffset fePointLight feSpecularLighting feSpotLight feTile feTurbulence filter font font-face font-face-format font-face-name font-face-src font-face-uri foreignObject g glyph glyphRef hkern image line linearGradient marker mask metadata missing-glyph path pattern polygon polyline radialGradient rect script set stop style svg switch symbol text textPath title tref tspan use view vkern'.split(' '); // Append SVG element names to list of known element names HTML.knownElementNames = HTML.knownElementNames.concat(HTML.knownSVGElementNames); +/** + * ## HTML.voidElementNames + * + * An array of all "void" element names in normalized case. Void + * elements are elements with a start tag and no end tag, such as BR, + * HR, IMG, and INPUT. + * + * The HTML spec defines a closed class of void elements. + */ HTML.voidElementNames = 'area base br col command embed hr img input keygen link meta param source track wbr'.split(' '); // Speed up search through lists of known elements by creating internal "sets" @@ -116,175 +346,352 @@ var voidElementSet = makeSet(HTML.voidElementNames); var knownElementSet = makeSet(HTML.knownElementNames); var knownSVGElementSet = makeSet(HTML.knownSVGElementNames); -// Is the given element (in proper case) a known HTML element? -// This includes SVG elements. -HTML.isKnownElement = function (name) { - return knownElementSet[name] === YES; +/** + * ## HTML.isKnownElement(tagName) + * + * * `tagName` - A string in normalized case + * + * Returns whether `tagName` is a known HTML5 or SVG element. + */ +HTML.isKnownElement = function (tagName) { + return knownElementSet[tagName] === YES; }; -// Is the given element (in proper case) an element with no end tag -// in HTML, like "br", "hr", or "input"? -HTML.isVoidElement = function (name) { - return voidElementSet[name] === YES; +/** + * ## HTML.isKnownSVGElement(tagName) + * + * * `tagName` - A string in normalized case + * + * Returns whether `tagName` is the name of a known SVG element. + */ +HTML.isKnownSVGElement = function (tagName) { + return knownSVGElementSet[tagName] === YES; }; -// Is the given element (in proper case) a known SVG element? -HTML.isKnownSVGElement = function (name) { - return knownSVGElementSet[name] === YES; +/** + * ## HTML.isVoidElement(tagName) + * + * * `tagName` - A string in normalized case + * + * Returns whether `tagName` is the name of a void element. + */ +HTML.isVoidElement = function (tagName) { + return voidElementSet[tagName] === YES; }; -// For code generators, is a particular tag (in proper case) guaranteed -// to be available on the HTML object (under the name returned by -// getSymbolName)? -HTML.isTagEnsured = function (t) { - return HTML.isKnownElement(t); -}; - -// For code generators, take a tagName like "p" and return an uppercase -// symbol name like "P" which is available on the "HTML" object for -// known elements or after calling getTag or ensureTag. -HTML.getSymbolName = function (tagName) { - // "foo-bar" -> "FOO_BAR" - return tagName.toUpperCase().replace(/-/g, '_'); -}; // Ensure tags for all known elements for (var i = 0; i < HTML.knownElementNames.length; i++) HTML.ensureTag(HTML.knownElementNames[i]); -//////////////////////////////////////////////////////////////////////////////// -callReactiveFunction = function (func) { - var result; - var cc = Deps.currentComputation; - var h = Deps.autorun(function (c) { - result = func(); - }); - h.onInvalidate(function () { - if (cc) - cc.invalidate(); - }); - if (Deps.active) { - Deps.onInvalidate(function () { - h.stop(); - func.stop && func.stop(); - }); - } else { - h.stop(); - func.stop && func.stop(); - } - return result; -}; - -stopWithLater = function (instance) { - if (instance.materialized && instance.materialized.isWith) { - if (Deps.active) { - instance.materialized(); - } else { - if (instance.data) // `UI.With` - instance.data.stop(); - else if (instance.v) // `Spacebars.With` - instance.v.stop(); - } - } -}; - -// Call all functions and instantiate all components, when fine-grained -// reactivity is not needed (for example, in attributes). -HTML.evaluate = function (node, parentComponent) { - if (node == null) { - return node; - } else if (typeof node === 'function') { - return HTML.evaluate(callReactiveFunction(node), parentComponent); - } else if (node instanceof Array) { - var result = []; - for (var i = 0; i < node.length; i++) - result.push(HTML.evaluate(node[i], parentComponent)); - return result; - } else if (typeof node.instantiate === 'function') { - // component - var instance = node.instantiate(parentComponent || null); - var content = instance.render('STATIC'); - stopWithLater(instance); - return HTML.evaluate(content, instance); - } else if (node instanceof HTML.Tag) { - var newChildren = []; - for (var i = 0; i < node.children.length; i++) - newChildren.push(HTML.evaluate(node.children[i], parentComponent)); - var newTag = HTML.getTag(node.tagName).apply(null, newChildren); - newTag.attrs = {}; - for (var k in node.attrs) - newTag.attrs[k] = HTML.evaluate(node.attrs[k], parentComponent); - return newTag; - } else { - return node; - } -}; - -var extendAttrs = function (tgt, src, parentComponent) { - for (var k in src) { - if (k === '$dynamic') - continue; - if (! HTML.isValidAttributeName(k)) - throw new Error("Illegal HTML attribute name: " + k); - var value = HTML.evaluate(src[k], parentComponent); - if (! HTML.isNully(value)) - tgt[k] = value; - } -}; - -// Process the `attrs.$dynamic` directive, if present, returning the final -// attributes dictionary. The value of `attrs.$dynamic` must be an array -// of attributes dictionaries or functions returning attribute dictionaries. -// These attributes are used to extend `attrs` as long as they are non-nully. -// All attributes are "evaluated," calling functions and instantiating -// components. -HTML.evaluateAttributes = function (attrs, parentComponent) { - if (! attrs) - return attrs; - - var result = {}; - extendAttrs(result, attrs, parentComponent); - - if ('$dynamic' in attrs) { - if (! (attrs.$dynamic instanceof Array)) - throw new Error("$dynamic must be an array"); - // iterate over attrs.$dynamic, calling each element if it - // is a function and then using it to extend `result`. - var dynamics = attrs.$dynamic; - for (var i = 0; i < dynamics.length; i++) { - var moreAttrs = dynamics[i]; - if (typeof moreAttrs === 'function') - moreAttrs = moreAttrs(); - extendAttrs(result, moreAttrs, parentComponent); - } - } - - return result; -}; - -HTML.Tag.prototype.evaluateAttributes = function (parentComponent) { - return HTML.evaluateAttributes(this.attrs, parentComponent); -}; - -HTML.Raw = function (value) { - if (! (this instanceof HTML.Raw)) +/** + * ## HTML.CharRef({html: ..., str: ...}) + * + * Represents a character reference like ` `. + * + * A CharRef is not required for escaping special characters like `<`, + * which are automatically escaped by HTMLjs. For example, + * `HTML.toHTML("<")` is `"<"`. Also, now that browsers speak + * Unicode, non-ASCII characters typically do not need to be expressed + * as character references either. The purpose of `CharRef` is offer + * control over the generated HTML, allowing template engines to + * preserve any character references that they come across. + * + * Constructing a CharRef requires two strings, the uninterpreted + * "HTML" form and the interpreted "string" form. Both are required + * to be present, and it is up to the caller to make sure the + * information is accurate. + * + * Examples of valid CharRefs: + * + * * `HTML.CharRef({html: '&', str: '&'})` + * * `HTML.CharRef({html: ' ', str: '\u00A0'}) + * + * Instance properties: `.html`, `.str` + */ +var CharRef = HTML.CharRef = function (attrs) { + if (! (this instanceof CharRef)) // called without `new` - return new HTML.Raw(value); + return new CharRef(attrs); + + if (! (attrs && attrs.html && attrs.str)) + throw new Error( + "HTML.CharRef must be constructed with ({html:..., str:...})"); + + this.html = attrs.html; + this.str = attrs.str; +}; +CharRef.prototype.htmljsType = CharRef.htmljsType = ['CharRef']; + +/** + * ## HTML.Comment(value) + * + * * `value` - String + * + * Represents an HTML Comment. For example, `HTML.Comment("foo")` represents + * the comment ``. + * + * The value string should not contain two consecutive hyphens (`--`) or start + * or end with a hyphen. If it does, the offending hyphens will be stripped + * before generating HTML. + * + * Instance properties: `value` + */ +var Comment = HTML.Comment = function (value) { + if (! (this instanceof Comment)) + // called without `new` + return new Comment(value); + + if (typeof value !== 'string') + throw new Error('HTML.Comment must be constructed with a string'); + + this.value = value; + // Kill illegal hyphens in comment value (no way to escape them in HTML) + this.sanitizedValue = value.replace(/^-|--+|-$/g, ''); +}; +Comment.prototype.htmljsType = Comment.htmljsType = ['Comment']; + +/** + * ## HTML.Raw(value) + * + * * `value` - String + * + * Represents HTML code to be inserted verbatim. `value` must consist + * of a valid, complete fragment of HTML, with all tags closed and + * all required end tags present. + * + * No security checks are performed, and no special characters are + * escaped. `Raw` should not be used if there are other ways of + * accomplishing the same result. HTML supplied by an application + * user should not be rendered unless the user is trusted, and even + * then, there could be strange results if required end tags are + * missing. + * + * Instance properties: `value` + */ +var Raw = HTML.Raw = function (value) { + if (! (this instanceof Raw)) + // called without `new` + return new Raw(value); if (typeof value !== 'string') throw new Error('HTML.Raw must be constructed with a string'); this.value = value; }; +Raw.prototype.htmljsType = Raw.htmljsType = ['Raw']; -HTML.EmitCode = function (value) { - if (! (this instanceof HTML.EmitCode)) - // called without `new` - return new HTML.EmitCode(value); - if (typeof value !== 'string') - throw new Error('HTML.EmitCode must be constructed with a string'); - - this.value = value; +/** + * ## HTML.isArray(x) + * + * Returns whether `x` is considered an array for the purposes of + * HTMLjs. An array is an object created using `[...]` or + * `new Array`. + * + * This function is provided because there are several common ways to + * determine whether an object should be treated as an array in + * JavaScript. + */ +HTML.isArray = function (x) { + // could change this to use the more convoluted Object.prototype.toString + // approach that works when objects are passed between frames, but does + // it matter? + return (x instanceof Array); +}; + +/** + * ## HTML.isConstructedObject(x) + * + * Returns whether `x` is a "constructed object," which is (loosely + * speaking) an object that was created with `new Foo` (for some `Foo`) + * rather than with `{...}` (a vanilla object). Vanilla objects are used + * as attribute dictionaries when constructing tags, while constructed + * objects are used as children. + * + * For example, in `HTML.DIV({id:"foo"})`, `{id:"foo"}` is a vanilla + * object. In `HTML.DIV(HTML.SPAN("text"))`, the `HTML.SPAN` is a + * constructed object. + * + * A simple constructed object can be created as follows: + * + * ``` + * var Foo = function () {}; + * var x = new Foo; // x is a constructed object + * ``` + * + * In particular, the test is that `x` is an object and `x.constructor` + * is set, but on a prototype of the object, not the object itself. + * The above example works because JavaScript sets + * `Foo.prototype.constructor = Foo` when you create a function `Foo`. + */ +HTML.isConstructedObject = function (x) { + return (x && (typeof x === 'object') && + (x.constructor !== Object) && + (! Object.prototype.hasOwnProperty.call(x, 'constructor'))); +}; + +/** + * ## HTML.isNully(content) + * + * Returns true if `content` is `null`, `undefined`, an empty array, + * or an array of only "nully" elements. + */ +HTML.isNully = function (node) { + if (node == null) + // null or undefined + return true; + + if (HTML.isArray(node)) { + // is it an empty array or an array of all nully items? + for (var i = 0; i < node.length; i++) + if (! HTML.isNully(node[i])) + return false; + return true; + } + + return false; +}; + +/** + * ## HTML.isValidAttributeName(name) + * + * Returns whether `name` is a valid name for an attribute of an HTML tag + * or element. `name` must: + * + * * Start with `:`, `_`, `A-Z` or `a-z` + * * Consist only of those characters plus `-`, `.`, and `0-9`. + * + * Discussion: The HTML spec and the DOM API (`setAttribute`) have different + * definitions of what characters are legal in an attribute. The HTML + * parser is extremely permissive (allowing, for example, ``), while + * `setAttribute` seems to use something like the XML grammar for names (and + * throws an error if a name is invalid, making that attribute unsettable). + * If we knew exactly what grammar browsers used for `setAttribute`, we could + * include various Unicode ranges in what's legal. For now, we allow ASCII chars + * that are known to be valid XML, valid HTML, and settable via `setAttribute`. + * + * See and + * . + */ +HTML.isValidAttributeName = function (name) { + return /^[:_A-Za-z][:_A-Za-z0-9.\-]*/.test(name); +}; + +/** + * ## HTML.flattenAttributes(attrs) + * + * If `attrs` is an array, the attribute dictionaries in the array are + * combined into a single attributes dictionary, which is returned. + * Any "nully" attribute values (see `HTML.isNully`) are ignored in + * the process. If `attrs` is a single attribute dictionary, a copy + * is returned with any nully attributes removed. If `attrs` is + * equal to null or an empty array, `null` is returned. + * + * Attribute dictionaries are combined by assigning the name/value + * pairs in array order, with later values overwriting previous + * values. + * + * `attrs` must not contain any foreign objects. + */ +// If `attrs` is an array of attributes dictionaries, combines them +// into one. Removes attributes that are "nully." +HTML.flattenAttributes = function (attrs) { + if (! attrs) + return attrs; + + var isArray = HTML.isArray(attrs); + if (isArray && attrs.length === 0) + return null; + + var result = {}; + for (var i = 0, N = (isArray ? attrs.length : 1); i < N; i++) { + var oneAttrs = (isArray ? attrs[i] : attrs); + if ((typeof oneAttrs !== 'object') || + HTML.isConstructedObject(oneAttrs)) + throw new Error("Expected plain JS object as attrs, found: " + oneAttrs); + for (var name in oneAttrs) { + if (! HTML.isValidAttributeName(name)) + throw new Error("Illegal HTML attribute name: " + name); + var value = oneAttrs[name]; + if (! HTML.isNully(value)) + result[name] = value; + } + } + + return result; +}; + + + +////////////////////////////// TOHTML + +/** + * ## HTML.toHTML(content) + * + * * `content` - any HTMLjs content + * + * Returns a string of HTML generated from `content`. + * + * For example: + * + * ``` + * HTML.toHTML(HTML.HR()) // => "


" + * ``` + * + * Foreign objects are not allowed in `content`. To generate HTML + * containing foreign objects, create a subclass of + * `HTML.ToHTMLVisitor` and override `visitObject`. + */ +HTML.toHTML = function (content) { + return (new HTML.ToHTMLVisitor).visit(content); +}; + +// Escaping modes for outputting text when generating HTML. +HTML.TEXTMODE = { + STRING: 1, + RCDATA: 2, + ATTRIBUTE: 3 +}; + +/** + * ## HTML.toText(content, textMode) + * + * * `content` - any HTMLjs content + * * `textMode` - the type of text to generate; one of + * `HTML.TEXTMODE.STRING`, `HTML.TEXTMODE.RCDATA`, or + * `HTML.TEXTMODE.ATTRIBUTE` + * + * Generating HTML or DOM from HTMLjs content requires generating text + * for attribute values and for the contents of TEXTAREA elements, + * among others. The input content may contain strings, arrays, + * booleans, numbers, nulls, and CharRefs. Behavior on other types + * is undefined. + * + * The required `textMode` argument specifies the type of text to + * generate: + * + * * `HTML.TEXTMODE.STRING` - a string with no special + * escaping or encoding performed, suitable for passing to + * `setAttribute` or `document.createTextNode`. + * * `HTML.TEXTMODE.RCDATA` - a string with `<` and `&` encoded + * as character references (and CharRefs included in their + * "HTML" form), suitable for including in a string of HTML + * * `HTML.TEXTMODE.ATTRIBUTE` - a string with `"` and `&` encoded + * as character references (and CharRefs included in their + * "HTML" form), suitable for including in an HTML attribute + * value surrounded by double quotes + */ + +HTML.toText = function (content, textMode) { + if (! textMode) + throw new Error("textMode required for HTML.toText"); + if (! (textMode === HTML.TEXTMODE.STRING || + textMode === HTML.TEXTMODE.RCDATA || + textMode === HTML.TEXTMODE.ATTRIBUTE)) + throw new Error("Unknown textMode: " + textMode); + + var visitor = new HTML.ToTextVisitor({textMode: textMode});; + return visitor.visit(content); }; diff --git a/packages/htmljs/htmljs_test.js b/packages/htmljs/htmljs_test.js index 65cf28ecaa..a8657fab83 100644 --- a/packages/htmljs/htmljs_test.js +++ b/packages/htmljs/htmljs_test.js @@ -79,24 +79,6 @@ Tinytest.add("htmljs - utils", function (test) { }); -Tinytest.add("htmljs - attributes", function (test) { - var SPAN = HTML.SPAN; - var amp = HTML.CharRef({html: '&', str: '&'}); - - test.equal(HTML.toHTML(SPAN({title: ['M', amp, 'Ms']}, 'M', amp, 'M candies')), - 'M&M candies'); - - // test that evaluateAttributes calls functions in both normal and dynamic attributes - test.equal(HTML.evaluateAttributes({x: function () { return 'abc'; }}), - { x: 'abc' }); - test.equal(HTML.evaluateAttributes({x: function () { return 'abc'; }, - $dynamic: []}), - { x: 'abc' }); - test.equal(HTML.evaluateAttributes({x: function () { return 'abc'; }, - $dynamic: [{ x: function () { return 'def'; }}]}), - { x: 'def' }); -}); - Tinytest.add("htmljs - details", function (test) { test.equal(HTML.toHTML(false), "false"); }); \ No newline at end of file diff --git a/packages/htmljs/package.js b/packages/htmljs/package.js index 04bcb4b0f8..1824b02c44 100644 --- a/packages/htmljs/package.js +++ b/packages/htmljs/package.js @@ -6,7 +6,9 @@ Package.describe({ Package.on_use(function (api) { api.export('HTML'); - api.add_files(['utils.js', 'html.js', 'tohtml.js']); + api.add_files(['preamble.js', + 'visitors.js', + 'html.js']); }); Package.on_test(function (api) { diff --git a/packages/htmljs/preamble.js b/packages/htmljs/preamble.js new file mode 100644 index 0000000000..160e038041 --- /dev/null +++ b/packages/htmljs/preamble.js @@ -0,0 +1,4 @@ +HTML = {}; + +IDENTITY = function (x) { return x; }; +SLICE = Array.prototype.slice; diff --git a/packages/htmljs/tohtml.js b/packages/htmljs/tohtml.js deleted file mode 100644 index 2d9e9453f3..0000000000 --- a/packages/htmljs/tohtml.js +++ /dev/null @@ -1,159 +0,0 @@ - -HTML.toHTML = function (node, parentComponent) { - if (node == null) { - // null or undefined - return ''; - } else if ((typeof node === 'string') || (typeof node === 'boolean') || (typeof node === 'number')) { - // string; escape special chars - return HTML.escapeData(String(node)); - } else if (node instanceof Array) { - // array - var parts = []; - for (var i = 0; i < node.length; i++) - parts.push(HTML.toHTML(node[i], parentComponent)); - return parts.join(''); - } else if (typeof node.instantiate === 'function') { - // component - var instance = node.instantiate(parentComponent || null); - var content = instance.render('STATIC'); - stopWithLater(instance); - // recurse with a new value for parentComponent - return HTML.toHTML(content, instance); - } else if (typeof node === 'function') { - return HTML.toHTML(callReactiveFunction(node), parentComponent); - } else if (node.toHTML) { - // Tag or something else - return node.toHTML(parentComponent); - } else { - throw new Error("Expected tag, string, array, component, null, undefined, or " + - "object with a toHTML method; found: " + node); - } -}; - -HTML.Comment.prototype.toHTML = function () { - return ''; -}; - -HTML.CharRef.prototype.toHTML = function () { - return this.html; -}; - -HTML.Raw.prototype.toHTML = function () { - return this.value; -}; - -HTML.Tag.prototype.toHTML = function (parentComponent) { - var attrStrs = []; - var attrs = this.evaluateAttributes(parentComponent); - if (attrs) { - for (var k in attrs) { - var v = HTML.toText(attrs[k], HTML.TEXTMODE.ATTRIBUTE, parentComponent); - attrStrs.push(' ' + k + '="' + v + '"'); - } - } - - var tagName = this.tagName; - var startTag = '<' + tagName + attrStrs.join('') + '>'; - - var childStrs = []; - var content; - if (tagName === 'textarea') { - for (var i = 0; i < this.children.length; i++) - childStrs.push(HTML.toText(this.children[i], HTML.TEXTMODE.RCDATA, parentComponent)); - - content = childStrs.join(''); - if (content.slice(0, 1) === '\n') - // TEXTAREA will absorb a newline, so if we see one, add - // another one. - content = '\n' + content; - - } else { - for (var i = 0; i < this.children.length; i++) - childStrs.push(HTML.toHTML(this.children[i], parentComponent)); - - content = childStrs.join(''); - } - - var result = startTag + content; - - if (this.children.length || ! HTML.isVoidElement(tagName)) { - // "Void" elements like BR are the only ones that don't get a close - // tag in HTML5. They shouldn't have contents, either, so we could - // throw an error upon seeing contents here. - result += ''; - } - - return result; -}; - -HTML.TEXTMODE = { - ATTRIBUTE: 1, - RCDATA: 2, - STRING: 3 -}; - -HTML.toText = function (node, textMode, parentComponent) { - if (node == null) { - // null or undefined - return ''; - } else if ((typeof node === 'string') || (typeof node === 'boolean') || (typeof node === 'number')) { - node = String(node); - // string - if (textMode === HTML.TEXTMODE.STRING) { - return node; - } else if (textMode === HTML.TEXTMODE.RCDATA) { - return HTML.escapeData(node); - } else if (textMode === HTML.TEXTMODE.ATTRIBUTE) { - // escape `&` and `"` this time, not `&` and `<` - return node.replace(/&/g, '&').replace(/"/g, '"'); - } else { - throw new Error("Unknown TEXTMODE: " + textMode); - } - } else if (node instanceof Array) { - // array - var parts = []; - for (var i = 0; i < node.length; i++) - parts.push(HTML.toText(node[i], textMode, parentComponent)); - return parts.join(''); - } else if (typeof node === 'function') { - return HTML.toText(callReactiveFunction(node), textMode, parentComponent); - } else if (typeof node.instantiate === 'function') { - // component - var instance = node.instantiate(parentComponent || null); - var content = instance.render('STATIC'); - var result = HTML.toText(content, textMode, instance); - stopWithLater(instance); - return result; - } else if (node.toText) { - // Something else - return node.toText(textMode, parentComponent); - } else { - throw new Error("Expected tag, string, array, component, null, undefined, or " + - "object with a toText method; found: " + node); - } - -}; - -HTML.Raw.prototype.toText = function () { - return this.value; -}; - -// used when including templates within {{#markdown}} -HTML.Tag.prototype.toText = function (textMode, parentComponent) { - if (textMode === HTML.TEXTMODE.STRING) - // stringify the tag as HTML, then convert to text - return HTML.toText(this.toHTML(parentComponent), textMode); - else - throw new Error("Can't insert tags in attributes or TEXTAREA elements"); -}; - -HTML.CharRef.prototype.toText = function (textMode) { - if (textMode === HTML.TEXTMODE.STRING) - return this.str; - else if (textMode === HTML.TEXTMODE.RCDATA) - return this.html; - else if (textMode === HTML.TEXTMODE.ATTRIBUTE) - return this.html; - else - throw new Error("Unknown TEXTMODE: " + textMode); -}; diff --git a/packages/htmljs/utils.js b/packages/htmljs/utils.js deleted file mode 100644 index 94b31d5c5f..0000000000 --- a/packages/htmljs/utils.js +++ /dev/null @@ -1,42 +0,0 @@ - -HTML = {}; - -HTML.isNully = function (node) { - if (node == null) - // null or undefined - return true; - - if (node instanceof Array) { - // is it an empty array or an array of all nully items? - for (var i = 0; i < node.length; i++) - if (! HTML.isNully(node[i])) - return false; - return true; - } - - return false; -}; - -HTML.escapeData = function (str) { - // string; escape the two special chars in HTML data and RCDATA - return str.replace(/&/g, '&').replace(/`), while -// `setAttribute` seems to use something like the XML grammar for names (and -// throws an error if a name is invalid, making that attribute unsettable). -// If we knew exactly what grammar browsers used for `setAttribute`, we could -// include various Unicode ranges in what's legal. For now, allow ASCII chars -// that are known to be valid XML, valid HTML, and settable via `setAttribute`: -// -// * Starts with `:`, `_`, `A-Z` or `a-z` -// * Consists of any of those plus `-`, `.`, and `0-9`. -// -// See and -// . -HTML.isValidAttributeName = function (name) { - return /^[:_A-Za-z][:_A-Za-z0-9.\-]*/.test(name); -}; diff --git a/packages/htmljs/visitors.js b/packages/htmljs/visitors.js new file mode 100644 index 0000000000..97201d1259 --- /dev/null +++ b/packages/htmljs/visitors.js @@ -0,0 +1,331 @@ +////////////////////////////// VISITORS + +// _assign is like _.extend or the upcoming Object.assign. +// Copy src's own, enumerable properties onto tgt and return +// tgt. +var _hasOwnProperty = Object.prototype.hasOwnProperty; +var _assign = function (tgt, src) { + for (var k in src) { + if (_hasOwnProperty.call(src, k)) + tgt[k] = src[k]; + } + return tgt; +}; + +HTML.Visitor = function (props) { + _assign(this, props); +}; + +HTML.Visitor.def = function (options) { + _assign(this.prototype, options); +}; + +HTML.Visitor.extend = function (options) { + var curType = this; + var subType = function HTMLVisitorSubtype(/*arguments*/) { + HTML.Visitor.apply(this, arguments); + }; + subType.prototype = new curType; + subType.extend = curType.extend; + subType.def = curType.def; + if (options) + _assign(subType.prototype, options); + return subType; +}; + +HTML.Visitor.def({ + visit: function (content/*, ...*/) { + if (content == null) + // null or undefined. + return this.visitNull.apply(this, arguments); + + if (typeof content === 'object') { + if (content.htmljsType) { + switch (content.htmljsType) { + case HTML.Tag.htmljsType: + return this.visitTag.apply(this, arguments); + case HTML.CharRef.htmljsType: + return this.visitCharRef.apply(this, arguments); + case HTML.Comment.htmljsType: + return this.visitComment.apply(this, arguments); + case HTML.Raw.htmljsType: + return this.visitRaw.apply(this, arguments); + default: + throw new Error("Unknown htmljs type: " + content.htmljsType); + } + } + + if (HTML.isArray(content)) + return this.visitArray.apply(this, arguments); + + return this.visitObject.apply(this, arguments); + + } else if ((typeof content === 'string') || + (typeof content === 'boolean') || + (typeof content === 'number')) { + return this.visitPrimitive.apply(this, arguments); + + } else if (typeof content === 'function') { + return this.visitFunction.apply(this, arguments); + } + + throw new Error("Unexpected object in htmljs: " + content); + + }, + visitNull: function (nullOrUndefined/*, ...*/) {}, + visitPrimitive: function (stringBooleanOrNumber/*, ...*/) {}, + visitArray: function (array/*, ...*/) {}, + visitComment: function (comment/*, ...*/) {}, + visitCharRef: function (charRef/*, ...*/) {}, + visitRaw: function (raw/*, ...*/) {}, + visitTag: function (tag/*, ...*/) {}, + visitObject: function (obj/*, ...*/) { + throw new Error("Unexpected object in htmljs: " + obj); + }, + visitFunction: function (obj/*, ...*/) { + throw new Error("Unexpected function in htmljs: " + obj); + } +}); + +HTML.TransformingVisitor = HTML.Visitor.extend(); +HTML.TransformingVisitor.def({ + visitNull: IDENTITY, + visitPrimitive: IDENTITY, + visitArray: function (array/*, ...*/) { + var argsCopy = SLICE.call(arguments); + var result = array; + for (var i = 0; i < array.length; i++) { + var oldItem = array[i]; + argsCopy[0] = oldItem; + var newItem = this.visit.apply(this, argsCopy); + if (newItem !== oldItem) { + // copy `array` on write + if (result === array) + result = array.slice(); + result[i] = newItem; + } + } + return result; + }, + visitComment: IDENTITY, + visitCharRef: IDENTITY, + visitRaw: IDENTITY, + visitObject: IDENTITY, + visitFunction: IDENTITY, + visitTag: function (tag/*, ...*/) { + var oldChildren = tag.children; + var argsCopy = SLICE.call(arguments); + argsCopy[0] = oldChildren; + var newChildren = this.visitChildren.apply(this, argsCopy); + + var oldAttrs = tag.attrs; + argsCopy[0] = oldAttrs; + var newAttrs = this.visitAttributes.apply(this, argsCopy); + + if (newAttrs === oldAttrs && newChildren === oldChildren) + return tag; + + var newTag = HTML.getTag(tag.tagName).apply(null, newChildren); + newTag.attrs = newAttrs; + return newTag; + }, + visitChildren: function (children/*, ...*/) { + return this.visitArray.apply(this, arguments); + }, + // Transform the `.attrs` property of a tag, which may be a dictionary, + // an array, or in some uses, a foreign object (such as + // a template tag). + visitAttributes: function (attrs/*, ...*/) { + if (HTML.isArray(attrs)) { + var argsCopy = SLICE.call(arguments); + var result = attrs; + for (var i = 0; i < attrs.length; i++) { + var oldItem = attrs[i]; + argsCopy[0] = oldItem; + var newItem = this.visitAttributes.apply(this, argsCopy); + if (newItem !== oldItem) { + // copy on write + if (result === attrs) + result = attrs.slice(); + result[i] = newItem; + } + } + return result; + } + + if (attrs && HTML.isConstructedObject(attrs)) { + throw new Error("The basic HTML.TransformingVisitor does not support " + + "foreign objects in attributes. Define a custom " + + "visitAttributes for this case."); + } + + var oldAttrs = attrs; + var newAttrs = oldAttrs; + if (oldAttrs) { + var attrArgs = [null, null]; + attrArgs.push.apply(attrArgs, arguments); + for (var k in oldAttrs) { + var oldValue = oldAttrs[k]; + attrArgs[0] = k; + attrArgs[1] = oldValue; + var newValue = this.visitAttribute.apply(this, attrArgs); + if (newValue !== oldValue) { + // copy on write + if (newAttrs === oldAttrs) + newAttrs = _assign({}, oldAttrs); + newAttrs[k] = newValue; + } + } + } + + return newAttrs; + }, + // Transform the value of one attribute name/value in an + // attributes dictionary. + visitAttribute: function (name, value, tag/*, ...*/) { + var args = SLICE.call(arguments, 2); + args[0] = value; + return this.visit.apply(this, args); + } +}); + + +HTML.ToTextVisitor = HTML.Visitor.extend(); +HTML.ToTextVisitor.def({ + visitNull: function (nullOrUndefined) { + return ''; + }, + visitPrimitive: function (stringBooleanOrNumber) { + var str = String(stringBooleanOrNumber); + if (this.textMode === HTML.TEXTMODE.RCDATA) { + return str.replace(/&/g, '&').replace(/`), we hackishly support HTML tags in markdown + // in templates by parsing them and stringifying them. + return this.visit(this.toHTML(tag)); + }, + visitObject: function (x) { + throw new Error("Unexpected object in htmljs in toText: " + x); + }, + toHTML: function (node) { + return HTML.toHTML(node); + } +}); + + + +HTML.ToHTMLVisitor = HTML.Visitor.extend(); +HTML.ToHTMLVisitor.def({ + visitNull: function (nullOrUndefined) { + return ''; + }, + visitPrimitive: function (stringBooleanOrNumber) { + var str = String(stringBooleanOrNumber); + return str.replace(/&/g, '&').replace(/'; + }, + visitCharRef: function (charRef) { + return charRef.html; + }, + visitRaw: function (raw) { + return raw.value; + }, + visitTag: function (tag) { + var attrStrs = []; + + var tagName = tag.tagName; + var children = tag.children; + + var attrs = tag.attrs; + if (attrs) { + attrs = HTML.flattenAttributes(attrs); + for (var k in attrs) { + if (k === 'value' && tagName === 'textarea') { + children = [attrs[k], children]; + } else { + var v = this.toText(attrs[k], HTML.TEXTMODE.ATTRIBUTE); + attrStrs.push(' ' + k + '="' + v + '"'); + } + } + } + + var startTag = '<' + tagName + attrStrs.join('') + '>'; + + var childStrs = []; + var content; + if (tagName === 'textarea') { + + for (var i = 0; i < children.length; i++) + childStrs.push(this.toText(children[i], HTML.TEXTMODE.RCDATA)); + + content = childStrs.join(''); + if (content.slice(0, 1) === '\n') + // TEXTAREA will absorb a newline, so if we see one, add + // another one. + content = '\n' + content; + + } else { + for (var i = 0; i < children.length; i++) + childStrs.push(this.visit(children[i])); + + content = childStrs.join(''); + } + + var result = startTag + content; + + if (children.length || ! HTML.isVoidElement(tagName)) { + // "Void" elements like BR are the only ones that don't get a close + // tag in HTML5. They shouldn't have contents, either, so we could + // throw an error upon seeing contents here. + result += ''; + } + + return result; + }, + visitObject: function (x) { + throw new Error("Unexpected object in htmljs in toHTML: " + x); + }, + toText: function (node, textMode) { + return HTML.toText(node, textMode); + } +}); diff --git a/packages/less/less_tests.js b/packages/less/less_tests.js index b341761c9b..29464c8e6e 100644 --- a/packages/less/less_tests.js +++ b/packages/less/less_tests.js @@ -2,7 +2,7 @@ Tinytest.add("less - presence", function(test) { var div = document.createElement('div'); - UI.materialize(Template.less_test_presence, div); + Blaze.render(Template.less_test_presence).attach(div); div.style.display = 'block'; document.body.appendChild(div); diff --git a/packages/observe-sequence/observe_sequence_tests.js b/packages/observe-sequence/observe_sequence_tests.js index ca4bc2e7cd..340de1179c 100644 --- a/packages/observe-sequence/observe_sequence_tests.js +++ b/packages/observe-sequence/observe_sequence_tests.js @@ -93,7 +93,7 @@ runOneObserveSequenceTestCase = function (test, sequenceFunc, compress(EJSON.stringify(expectedCallbacks, {canonical: true, indent: true}))); }; -Tinytest.add('observe sequence - initial data for all sequence types', function (test) { +Tinytest.add('observe-sequence - initial data for all sequence types', function (test) { runOneObserveSequenceTestCase(test, function () { return null; }, function () {}, []); @@ -140,7 +140,7 @@ Tinytest.add('observe sequence - initial data for all sequence types', function ], /*numExpectedWarnings = */1); }); -Tinytest.add('observe sequence - array to other array', function (test) { +Tinytest.add('observe-sequence - array to other array', function (test) { var dep = new Deps.Dependency; var seq = [{_id: "13", foo: 1}, {_id: "37", bar: 2}]; @@ -159,7 +159,7 @@ Tinytest.add('observe sequence - array to other array', function (test) { ]); }); -Tinytest.add('observe sequence - array to other array, strings', function (test) { +Tinytest.add('observe-sequence - array to other array, strings', function (test) { var dep = new Deps.Dependency; var seq = ["A", "B"]; @@ -177,7 +177,7 @@ Tinytest.add('observe sequence - array to other array, strings', function (test) ]); }); -Tinytest.add('observe sequence - array to other array, objects without ids', function (test) { +Tinytest.add('observe-sequence - array to other array, objects without ids', function (test) { var dep = new Deps.Dependency; var seq = [{foo: 1}, {bar: 2}]; @@ -195,7 +195,7 @@ Tinytest.add('observe sequence - array to other array, objects without ids', fun ]); }); -Tinytest.add('observe sequence - array to other array, changes', function (test) { +Tinytest.add('observe-sequence - array to other array, changes', function (test) { var dep = new Deps.Dependency; var seq = [{_id: "13", foo: 1}, {_id: "37", bar: 2}, {_id: "42", baz: 42}]; @@ -218,7 +218,7 @@ Tinytest.add('observe sequence - array to other array, changes', function (test) ]); }); -Tinytest.add('observe sequence - array to other array, movedTo', function (test) { +Tinytest.add('observe-sequence - array to other array, movedTo', function (test) { var dep = new Deps.Dependency; var seq = [{_id: "13", foo: 1}, {_id: "37", bar: 2}, {_id: "42", baz: 42}, {_id: "43", baz: 43}]; @@ -244,7 +244,7 @@ Tinytest.add('observe sequence - array to other array, movedTo', function (test) ]); }); -Tinytest.add('observe sequence - array to null', function (test) { +Tinytest.add('observe-sequence - array to null', function (test) { var dep = new Deps.Dependency; var seq = [{_id: "13", foo: 1}, {_id: "37", bar: 2}]; @@ -262,7 +262,7 @@ Tinytest.add('observe sequence - array to null', function (test) { ]); }); -Tinytest.add('observe sequence - array to cursor', function (test) { +Tinytest.add('observe-sequence - array to cursor', function (test) { var dep = new Deps.Dependency; var seq = [{_id: "13", foo: 1}, {_id: "37", bar: 2}]; @@ -286,7 +286,7 @@ Tinytest.add('observe sequence - array to cursor', function (test) { }); -Tinytest.add('observe sequence - cursor to null', function (test) { +Tinytest.add('observe-sequence - cursor to null', function (test) { var dep = new Deps.Dependency; var coll = new Meteor.Collection(null); coll.insert({_id: "13", foo: 1}); @@ -308,7 +308,7 @@ Tinytest.add('observe sequence - cursor to null', function (test) { ]); }); -Tinytest.add('observe sequence - cursor to array', function (test) { +Tinytest.add('observe-sequence - cursor to array', function (test) { var dep = new Deps.Dependency; var coll = new Meteor.Collection(null); coll.insert({_id: "13", foo: 1}); @@ -331,7 +331,7 @@ Tinytest.add('observe sequence - cursor to array', function (test) { ]); }); -Tinytest.add('observe sequence - cursor', function (test) { +Tinytest.add('observe-sequence - cursor', function (test) { var coll = new Meteor.Collection(null); coll.insert({_id: "13", rank: 1}); var cursor = coll.find({}, {sort: {rank: 1}}); @@ -361,7 +361,7 @@ Tinytest.add('observe sequence - cursor', function (test) { ]); }); -Tinytest.add('observe sequence - cursor to other cursor', function (test) { +Tinytest.add('observe-sequence - cursor to other cursor', function (test) { var dep = new Deps.Dependency; var coll = new Meteor.Collection(null); coll.insert({_id: "13", foo: 1}); @@ -389,7 +389,7 @@ Tinytest.add('observe sequence - cursor to other cursor', function (test) { ]); }); -Tinytest.add('observe sequence - cursor to other cursor with transform', function (test) { +Tinytest.add('observe-sequence - cursor to other cursor with transform', function (test) { var dep = new Deps.Dependency; var transform = function(doc) { return _.extend({idCopy: doc._id}, doc); @@ -421,7 +421,7 @@ Tinytest.add('observe sequence - cursor to other cursor with transform', functio ]); }); -Tinytest.add('observe sequence - cursor to same cursor', function (test) { +Tinytest.add('observe-sequence - cursor to same cursor', function (test) { var coll = new Meteor.Collection(null); coll.insert({_id: "13", rank: 1}); var cursor = coll.find({}, {sort: {rank: 1}}); @@ -448,7 +448,7 @@ Tinytest.add('observe sequence - cursor to same cursor', function (test) { ]); }); -Tinytest.add('observe sequence - string arrays', function (test) { +Tinytest.add('observe-sequence - string arrays', function (test) { var seq = ['A', 'B']; var dep = new Deps.Dependency; @@ -466,7 +466,7 @@ Tinytest.add('observe sequence - string arrays', function (test) { ]); }); -Tinytest.add('observe sequence - number arrays', function (test) { +Tinytest.add('observe-sequence - number arrays', function (test) { var seq = [1, 1, 2]; var dep = new Deps.Dependency; @@ -486,7 +486,7 @@ Tinytest.add('observe sequence - number arrays', function (test) { ]); }); -Tinytest.add('observe sequence - cursor to other cursor, same collection', function (test) { +Tinytest.add('observe-sequence - cursor to other cursor, same collection', function (test) { var dep = new Deps.Dependency; var coll = new Meteor.Collection(null); coll.insert({_id: "13", foo: 1}); diff --git a/packages/showdown/package.js b/packages/showdown/package.js index 07463796e0..b4ea61ed20 100644 --- a/packages/showdown/package.js +++ b/packages/showdown/package.js @@ -10,7 +10,7 @@ Package.on_use(function (api) { api.add_files("showdown.js"); api.export('Showdown'); - api.use("ui", "client", {weak: true}); + api.use("templating", "client", {weak: true}); api.add_files('template-integration.js', 'client'); }); diff --git a/packages/showdown/showdown.js b/packages/showdown/showdown.js index f5590cb861..50c0660442 100644 --- a/packages/showdown/showdown.js +++ b/packages/showdown/showdown.js @@ -1,1455 +1,1455 @@ -// -// showdown.js -- A javascript port of Markdown. -// -// Copyright (c) 2007 John Fraser. -// -// Original Markdown Copyright (c) 2004-2005 John Gruber -// -// -// Redistributable under a BSD-style open source license. -// See license.txt for more information. -// -// The full source distribution is at: -// -// A A L -// T C A -// T K B -// -// -// - -// -// Wherever possible, Showdown is a straight, line-by-line port -// of the Perl version of Markdown. -// -// This is not a normal parser design; it's basically just a -// series of string substitutions. It's hard to read and -// maintain this way, but keeping Showdown close to the original -// design makes it easier to port new features. -// -// More importantly, Showdown behaves like markdown.pl in most -// edge cases. So web applications can do client-side preview -// in Javascript, and then build identical HTML on the server. -// -// This port needs the new RegExp functionality of ECMA 262, -// 3rd Edition (i.e. Javascript 1.5). Most modern web browsers -// should do fine. Even with the new regular expression features, -// We do a lot of work to emulate Perl's regex functionality. -// The tricky changes in this file mostly have the "attacklab:" -// label. Major or self-explanatory changes don't. -// -// Smart diff tools like Araxis Merge will be able to match up -// this file with markdown.pl in a useful way. A little tweaking -// helps: in a copy of markdown.pl, replace "#" with "//" and -// replace "$text" with "text". Be sure to ignore whitespace -// and line endings. -// - - -// -// Showdown usage: -// -// var text = "Markdown *rocks*."; -// -// var converter = new Showdown.converter(); -// var html = converter.makeHtml(text); -// -// alert(html); -// -// Note: move the sample code to the bottom of this -// file before uncommenting it. -// - - -// -// Showdown namespace -// -// METEOR CHANGE: remove "var" so that this isn't file-local. -Showdown = { extensions: {} }; - -// -// forEach -// -var forEach = Showdown.forEach = function(obj, callback) { - if (typeof obj.forEach === 'function') { - obj.forEach(callback); - } else { - var i, len = obj.length; - for (i = 0; i < len; i++) { - callback(obj[i], i, obj); - } - } -}; - -// -// Standard extension naming -// -var stdExtName = function(s) { - return s.replace(/[_-]||\s/g, '').toLowerCase(); -}; - -// -// converter -// -// Wraps all "globals" so that the only thing -// exposed is makeHtml(). -// -Showdown.converter = function(converter_options) { - -// -// Globals: -// - -// Global hashes, used by various utility routines -var g_urls; -var g_titles; -var g_html_blocks; - -// Used to track when we're inside an ordered or unordered list -// (see _ProcessListItems() for details): -var g_list_level = 0; - -// Global extensions -var g_lang_extensions = []; -var g_output_modifiers = []; - - -// -// Automatic Extension Loading (node only): -// - -if (typeof module !== 'undefind' && typeof exports !== 'undefined' && typeof require !== 'undefind') { - var fs = require('fs'); - - if (fs) { - // Search extensions folder - var extensions = fs.readdirSync((__dirname || '.')+'/extensions').filter(function(file){ - return ~file.indexOf('.js'); - }).map(function(file){ - return file.replace(/\.js$/, ''); - }); - // Load extensions into Showdown namespace - Showdown.forEach(extensions, function(ext){ - var name = stdExtName(ext); - Showdown.extensions[name] = require('./extensions/' + ext); - }); - } -} - -this.makeHtml = function(text) { -// -// Main function. The order in which other subs are called here is -// essential. Link and image substitutions need to happen before -// _EscapeSpecialCharsWithinTagAttributes(), so that any *'s or _'s in the -// and tags get encoded. -// - - // Clear the global hashes. If we don't clear these, you get conflicts - // from other articles when generating a page which contains more than - // one article (e.g. an index page that shows the N most recent - // articles): - g_urls = {}; - g_titles = {}; - g_html_blocks = []; - - // attacklab: Replace ~ with ~T - // This lets us use tilde as an escape char to avoid md5 hashes - // The choice of character is arbitray; anything that isn't - // magic in Markdown will work. - text = text.replace(/~/g,"~T"); - - // attacklab: Replace $ with ~D - // RegExp interprets $ as a special character - // when it's in a replacement string - text = text.replace(/\$/g,"~D"); - - // Standardize line endings - text = text.replace(/\r\n/g,"\n"); // DOS to Unix - text = text.replace(/\r/g,"\n"); // Mac to Unix - - // Make sure text begins and ends with a couple of newlines: - text = "\n\n" + text + "\n\n"; - - // Convert all tabs to spaces. - text = _Detab(text); - - // Strip any lines consisting only of spaces and tabs. - // This makes subsequent regexen easier to write, because we can - // match consecutive blank lines with /\n+/ instead of something - // contorted like /[ \t]*\n+/ . - text = text.replace(/^[ \t]+$/mg,""); - - // Run language extensions - Showdown.forEach(g_lang_extensions, function(x){ - text = _ExecuteExtension(x, text); - }); - - // Handle github codeblocks prior to running HashHTML so that - // HTML contained within the codeblock gets escaped propertly - text = _DoGithubCodeBlocks(text); - - // Turn block-level HTML blocks into hash entries - text = _HashHTMLBlocks(text); - - // Strip link definitions, store in hashes. - text = _StripLinkDefinitions(text); - - text = _RunBlockGamut(text); - - text = _UnescapeSpecialChars(text); - - // attacklab: Restore dollar signs - text = text.replace(/~D/g,"$$"); - - // attacklab: Restore tildes - text = text.replace(/~T/g,"~"); - - // Run output modifiers - Showdown.forEach(g_output_modifiers, function(x){ - text = _ExecuteExtension(x, text); - }); - - return text; -}; -// -// Options: -// - -// Parse extensions options into separate arrays -if (converter_options && converter_options.extensions) { - - var self = this; - - // Iterate over each plugin - Showdown.forEach(converter_options.extensions, function(plugin){ - - // Assume it's a bundled plugin if a string is given - if (typeof plugin === 'string') { - plugin = Showdown.extensions[stdExtName(plugin)]; - } - - if (typeof plugin === 'function') { - // Iterate over each extension within that plugin - Showdown.forEach(plugin(self), function(ext){ - // Sort extensions by type - if (ext.type) { - if (ext.type === 'language' || ext.type === 'lang') { - g_lang_extensions.push(ext); - } else if (ext.type === 'output' || ext.type === 'html') { - g_output_modifiers.push(ext); - } - } else { - // Assume language extension - g_output_modifiers.push(ext); - } - }); - } else { - throw "Extension '" + plugin + "' could not be loaded. It was either not found or is not a valid extension."; - } - }); -} - - -var _ExecuteExtension = function(ext, text) { - if (ext.regex) { - var re = new RegExp(ext.regex, 'g'); - return text.replace(re, ext.replace); - } else if (ext.filter) { - return ext.filter(text); - } -}; - -var _StripLinkDefinitions = function(text) { -// -// Strips link definitions from text, stores the URLs and titles in -// hash references. -// - - // Link defs are in the form: ^[id]: url "optional title" - - /* - var text = text.replace(/ - ^[ ]{0,3}\[(.+)\]: // id = $1 attacklab: g_tab_width - 1 - [ \t]* - \n? // maybe *one* newline - [ \t]* - ? // url = $2 - [ \t]* - \n? // maybe one newline - [ \t]* - (?: - (\n*) // any lines skipped = $3 attacklab: lookbehind removed - ["(] - (.+?) // title = $4 - [")] - [ \t]* - )? // title is optional - (?:\n+|$) - /gm, - function(){...}); - */ - - // attacklab: sentinel workarounds for lack of \A and \Z, safari\khtml bug - text += "~0"; - - text = text.replace(/^[ ]{0,3}\[(.+)\]:[ \t]*\n?[ \t]*?[ \t]*\n?[ \t]*(?:(\n*)["(](.+?)[")][ \t]*)?(?:\n+|(?=~0))/gm, - function (wholeMatch,m1,m2,m3,m4) { - m1 = m1.toLowerCase(); - g_urls[m1] = _EncodeAmpsAndAngles(m2); // Link IDs are case-insensitive - if (m3) { - // Oops, found blank lines, so it's not a title. - // Put back the parenthetical statement we stole. - return m3+m4; - } else if (m4) { - g_titles[m1] = m4.replace(/"/g,"""); - } - - // Completely remove the definition from the text - return ""; - } - ); - - // attacklab: strip sentinel - text = text.replace(/~0/,""); - - return text; -} - - -var _HashHTMLBlocks = function(text) { - // attacklab: Double up blank lines to reduce lookaround - text = text.replace(/\n/g,"\n\n"); - - // Hashify HTML blocks: - // We only want to do this for block-level HTML tags, such as headers, - // lists, and tables. That's because we still want to wrap

s around - // "paragraphs" that are wrapped in non-block-level tags, such as anchors, - // phrase emphasis, and spans. The list of tags we're looking for is - // hard-coded: - var block_tags_a = "p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del|style|section|header|footer|nav|article|aside"; - var block_tags_b = "p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|style|section|header|footer|nav|article|aside"; - - // First, look for nested blocks, e.g.: - //

- //
- // tags for inner block must be indented. - //
- //
- // - // The outermost tags must start at the left margin for this to match, and - // the inner nested divs must be indented. - // We need to do this before the next, more liberal match, because the next - // match will start at the first `
` and stop at the first `
`. - - // attacklab: This regex can be expensive when it fails. - /* - var text = text.replace(/ - ( // save in $1 - ^ // start of line (with /m) - <($block_tags_a) // start tag = $2 - \b // word break - // attacklab: hack around khtml/pcre bug... - [^\r]*?\n // any number of lines, minimally matching - // the matching end tag - [ \t]* // trailing spaces/tabs - (?=\n+) // followed by a newline - ) // attacklab: there are sentinel newlines at end of document - /gm,function(){...}}; - */ - text = text.replace(/^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del)\b[^\r]*?\n<\/\2>[ \t]*(?=\n+))/gm,hashElement); - - // - // Now match more liberally, simply from `\n` to `\n` - // - - /* - var text = text.replace(/ - ( // save in $1 - ^ // start of line (with /m) - <($block_tags_b) // start tag = $2 - \b // word break - // attacklab: hack around khtml/pcre bug... - [^\r]*? // any number of lines, minimally matching - // the matching end tag - [ \t]* // trailing spaces/tabs - (?=\n+) // followed by a newline - ) // attacklab: there are sentinel newlines at end of document - /gm,function(){...}}; - */ - text = text.replace(/^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|style|section|header|footer|nav|article|aside)\b[^\r]*?<\/\2>[ \t]*(?=\n+)\n)/gm,hashElement); - - // Special case just for
. It was easier to make a special case than - // to make the other regex more complicated. - - /* - text = text.replace(/ - ( // save in $1 - \n\n // Starting after a blank line - [ ]{0,3} - (<(hr) // start tag = $2 - \b // word break - ([^<>])*? // - \/?>) // the matching end tag - [ \t]* - (?=\n{2,}) // followed by a blank line - ) - /g,hashElement); - */ - text = text.replace(/(\n[ ]{0,3}(<(hr)\b([^<>])*?\/?>)[ \t]*(?=\n{2,}))/g,hashElement); - - // Special case for standalone HTML comments: - - /* - text = text.replace(/ - ( // save in $1 - \n\n // Starting after a blank line - [ ]{0,3} // attacklab: g_tab_width - 1 - - [ \t]* - (?=\n{2,}) // followed by a blank line - ) - /g,hashElement); - */ - text = text.replace(/(\n\n[ ]{0,3}[ \t]*(?=\n{2,}))/g,hashElement); - - // PHP and ASP-style processor instructions ( and <%...%>) - - /* - text = text.replace(/ - (?: - \n\n // Starting after a blank line - ) - ( // save in $1 - [ ]{0,3} // attacklab: g_tab_width - 1 - (?: - <([?%]) // $2 - [^\r]*? - \2> - ) - [ \t]* - (?=\n{2,}) // followed by a blank line - ) - /g,hashElement); - */ - text = text.replace(/(?:\n\n)([ ]{0,3}(?:<([?%])[^\r]*?\2>)[ \t]*(?=\n{2,}))/g,hashElement); - - // attacklab: Undo double lines (see comment at top of this function) - text = text.replace(/\n\n/g,"\n"); - return text; -} - -var hashElement = function(wholeMatch,m1) { - var blockText = m1; - - // Undo double lines - blockText = blockText.replace(/\n\n/g,"\n"); - blockText = blockText.replace(/^\n/,""); - - // strip trailing blank lines - blockText = blockText.replace(/\n+$/g,""); - - // Replace the element text with a marker ("~KxK" where x is its key) - blockText = "\n\n~K" + (g_html_blocks.push(blockText)-1) + "K\n\n"; - - return blockText; -}; - -var _RunBlockGamut = function(text) { -// -// These are all the transformations that form block-level -// tags like paragraphs, headers, and list items. -// - text = _DoHeaders(text); - - // Do Horizontal Rules: - var key = hashBlock("
"); - text = text.replace(/^[ ]{0,2}([ ]?\*[ ]?){3,}[ \t]*$/gm,key); - text = text.replace(/^[ ]{0,2}([ ]?\-[ ]?){3,}[ \t]*$/gm,key); - text = text.replace(/^[ ]{0,2}([ ]?\_[ ]?){3,}[ \t]*$/gm,key); - - text = _DoLists(text); - text = _DoCodeBlocks(text); - text = _DoBlockQuotes(text); - - // We already ran _HashHTMLBlocks() before, in Markdown(), but that - // was to escape raw HTML in the original Markdown source. This time, - // we're escaping the markup we've just created, so that we don't wrap - //

tags around block-level tags. - text = _HashHTMLBlocks(text); - text = _FormParagraphs(text); - - return text; -}; - - -var _RunSpanGamut = function(text) { -// -// These are all the transformations that occur *within* block-level -// tags like paragraphs, headers, and list items. -// - - text = _DoCodeSpans(text); - text = _EscapeSpecialCharsWithinTagAttributes(text); - text = _EncodeBackslashEscapes(text); - - // Process anchor and image tags. Images must come first, - // because ![foo][f] looks like an anchor. - text = _DoImages(text); - text = _DoAnchors(text); - - // Make links out of things like `` - // Must come after _DoAnchors(), because you can use < and > - // delimiters in inline links like [this](). - text = _DoAutoLinks(text); - text = _EncodeAmpsAndAngles(text); - text = _DoItalicsAndBold(text); - - // Do hard breaks: - text = text.replace(/ +\n/g,"
\n"); - - return text; -} - -var _EscapeSpecialCharsWithinTagAttributes = function(text) { -// -// Within tags -- meaning between < and > -- encode [\ ` * _] so they -// don't conflict with their use in Markdown for code, italics and strong. -// - - // Build a regex to find HTML tags and comments. See Friedl's - // "Mastering Regular Expressions", 2nd Ed., pp. 200-201. - var regex = /(<[a-z\/!$]("[^"]*"|'[^']*'|[^'">])*>|)/gi; - - text = text.replace(regex, function(wholeMatch) { - var tag = wholeMatch.replace(/(.)<\/?code>(?=.)/g,"$1`"); - tag = escapeCharacters(tag,"\\`*_"); - return tag; - }); - - return text; -} - -var _DoAnchors = function(text) { -// -// Turn Markdown link shortcuts into XHTML
tags. -// - // - // First, handle reference-style links: [link text] [id] - // - - /* - text = text.replace(/ - ( // wrap whole match in $1 - \[ - ( - (?: - \[[^\]]*\] // allow brackets nested one level - | - [^\[] // or anything else - )* - ) - \] - - [ ]? // one optional space - (?:\n[ ]*)? // one optional newline followed by spaces - - \[ - (.*?) // id = $3 - \] - )()()()() // pad remaining backreferences - /g,_DoAnchors_callback); - */ - text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g,writeAnchorTag); - - // - // Next, inline-style links: [link text](url "optional title") - // - - /* - text = text.replace(/ - ( // wrap whole match in $1 - \[ - ( - (?: - \[[^\]]*\] // allow brackets nested one level - | - [^\[\]] // or anything else - ) - ) - \] - \( // literal paren - [ \t]* - () // no id, so leave $3 empty - ? // href = $4 - [ \t]* - ( // $5 - (['"]) // quote char = $6 - (.*?) // Title = $7 - \6 // matching quote - [ \t]* // ignore any spaces/tabs between closing quote and ) - )? // title is optional - \) - ) - /g,writeAnchorTag); - */ - text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\]\([ \t]*()?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g,writeAnchorTag); - - // - // Last, handle reference-style shortcuts: [link text] - // These must come last in case you've also got [link test][1] - // or [link test](/foo) - // - - /* - text = text.replace(/ - ( // wrap whole match in $1 - \[ - ([^\[\]]+) // link text = $2; can't contain '[' or ']' - \] - )()()()()() // pad rest of backreferences - /g, writeAnchorTag); - */ - text = text.replace(/(\[([^\[\]]+)\])()()()()()/g, writeAnchorTag); - - return text; -} - -var writeAnchorTag = function(wholeMatch,m1,m2,m3,m4,m5,m6,m7) { - if (m7 == undefined) m7 = ""; - var whole_match = m1; - var link_text = m2; - var link_id = m3.toLowerCase(); - var url = m4; - var title = m7; - - if (url == "") { - if (link_id == "") { - // lower-case and turn embedded newlines into spaces - link_id = link_text.toLowerCase().replace(/ ?\n/g," "); - } - url = "#"+link_id; - - if (g_urls[link_id] != undefined) { - url = g_urls[link_id]; - if (g_titles[link_id] != undefined) { - title = g_titles[link_id]; - } - } - else { - if (whole_match.search(/\(\s*\)$/m)>-1) { - // Special case for explicit empty url - url = ""; - } else { - return whole_match; - } - } - } - - url = escapeCharacters(url,"*_"); - var result = ""; - - return result; -} - - -var _DoImages = function(text) { -// -// Turn Markdown image shortcuts into tags. -// - - // - // First, handle reference-style labeled images: ![alt text][id] - // - - /* - text = text.replace(/ - ( // wrap whole match in $1 - !\[ - (.*?) // alt text = $2 - \] - - [ ]? // one optional space - (?:\n[ ]*)? // one optional newline followed by spaces - - \[ - (.*?) // id = $3 - \] - )()()()() // pad rest of backreferences - /g,writeImageTag); - */ - text = text.replace(/(!\[(.*?)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g,writeImageTag); - - // - // Next, handle inline images: ![alt text](url "optional title") - // Don't forget: encode * and _ - - /* - text = text.replace(/ - ( // wrap whole match in $1 - !\[ - (.*?) // alt text = $2 - \] - \s? // One optional whitespace character - \( // literal paren - [ \t]* - () // no id, so leave $3 empty - ? // src url = $4 - [ \t]* - ( // $5 - (['"]) // quote char = $6 - (.*?) // title = $7 - \6 // matching quote - [ \t]* - )? // title is optional - \) - ) - /g,writeImageTag); - */ - text = text.replace(/(!\[(.*?)\]\s?\([ \t]*()?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g,writeImageTag); - - return text; -} - -var writeImageTag = function(wholeMatch,m1,m2,m3,m4,m5,m6,m7) { - var whole_match = m1; - var alt_text = m2; - var link_id = m3.toLowerCase(); - var url = m4; - var title = m7; - - if (!title) title = ""; - - if (url == "") { - if (link_id == "") { - // lower-case and turn embedded newlines into spaces - link_id = alt_text.toLowerCase().replace(/ ?\n/g," "); - } - url = "#"+link_id; - - if (g_urls[link_id] != undefined) { - url = g_urls[link_id]; - if (g_titles[link_id] != undefined) { - title = g_titles[link_id]; - } - } - else { - return whole_match; - } - } - - alt_text = alt_text.replace(/"/g,"""); - url = escapeCharacters(url,"*_"); - var result = "\""' + _RunSpanGamut(m1) + "");}); - - text = text.replace(/^(.+)[ \t]*\n-+[ \t]*\n+/gm, - function(matchFound,m1){return hashBlock('

' + _RunSpanGamut(m1) + "

");}); - - // atx-style headers: - // # Header 1 - // ## Header 2 - // ## Header 2 with closing hashes ## - // ... - // ###### Header 6 - // - - /* - text = text.replace(/ - ^(\#{1,6}) // $1 = string of #'s - [ \t]* - (.+?) // $2 = Header text - [ \t]* - \#* // optional closing #'s (not counted) - \n+ - /gm, function() {...}); - */ - - text = text.replace(/^(\#{1,6})[ \t]*(.+?)[ \t]*\#*\n+/gm, - function(wholeMatch,m1,m2) { - var h_level = m1.length; - return hashBlock("' + _RunSpanGamut(m2) + ""); - }); - - function headerId(m) { - return m.replace(/[^\w]/g, '').toLowerCase(); - } - return text; -} - -// This declaration keeps Dojo compressor from outputting garbage: -var _ProcessListItems; - -var _DoLists = function(text) { -// -// Form HTML ordered (numbered) and unordered (bulleted) lists. -// - - // attacklab: add sentinel to hack around khtml/safari bug: - // http://bugs.webkit.org/show_bug.cgi?id=11231 - text += "~0"; - - // Re-usable pattern to match any entirel ul or ol list: - - /* - var whole_list = / - ( // $1 = whole list - ( // $2 - [ ]{0,3} // attacklab: g_tab_width - 1 - ([*+-]|\d+[.]) // $3 = first list item marker - [ \t]+ - ) - [^\r]+? - ( // $4 - ~0 // sentinel for workaround; should be $ - | - \n{2,} - (?=\S) - (?! // Negative lookahead for another list item marker - [ \t]* - (?:[*+-]|\d+[.])[ \t]+ - ) - ) - )/g - */ - var whole_list = /^(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/gm; - - if (g_list_level) { - text = text.replace(whole_list,function(wholeMatch,m1,m2) { - var list = m1; - var list_type = (m2.search(/[*+-]/g)>-1) ? "ul" : "ol"; - - // Turn double returns into triple returns, so that we can make a - // paragraph for the last item in a list, if necessary: - list = list.replace(/\n{2,}/g,"\n\n\n");; - var result = _ProcessListItems(list); - - // Trim any trailing whitespace, to put the closing `` - // up on the preceding line, to get it past the current stupid - // HTML block parser. This is a hack to work around the terrible - // hack that is the HTML block parser. - result = result.replace(/\s+$/,""); - result = "<"+list_type+">" + result + "\n"; - return result; - }); - } else { - whole_list = /(\n\n|^\n?)(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/g; - text = text.replace(whole_list,function(wholeMatch,m1,m2,m3) { - var runup = m1; - var list = m2; - - var list_type = (m3.search(/[*+-]/g)>-1) ? "ul" : "ol"; - // Turn double returns into triple returns, so that we can make a - // paragraph for the last item in a list, if necessary: - var list = list.replace(/\n{2,}/g,"\n\n\n");; - var result = _ProcessListItems(list); - result = runup + "<"+list_type+">\n" + result + "\n"; - return result; - }); - } - - // attacklab: strip sentinel - text = text.replace(/~0/,""); - - return text; -} - -_ProcessListItems = function(list_str) { -// -// Process the contents of a single ordered or unordered list, splitting it -// into individual list items. -// - // The $g_list_level global keeps track of when we're inside a list. - // Each time we enter a list, we increment it; when we leave a list, - // we decrement. If it's zero, we're not in a list anymore. - // - // We do this because when we're not inside a list, we want to treat - // something like this: - // - // I recommend upgrading to version - // 8. Oops, now this line is treated - // as a sub-list. - // - // As a single paragraph, despite the fact that the second line starts - // with a digit-period-space sequence. - // - // Whereas when we're inside a list (or sub-list), that line will be - // treated as the start of a sub-list. What a kludge, huh? This is - // an aspect of Markdown's syntax that's hard to parse perfectly - // without resorting to mind-reading. Perhaps the solution is to - // change the syntax rules such that sub-lists must start with a - // starting cardinal number; e.g. "1." or "a.". - - g_list_level++; - - // trim trailing blank lines: - list_str = list_str.replace(/\n{2,}$/,"\n"); - - // attacklab: add sentinel to emulate \z - list_str += "~0"; - - /* - list_str = list_str.replace(/ - (\n)? // leading line = $1 - (^[ \t]*) // leading whitespace = $2 - ([*+-]|\d+[.]) [ \t]+ // list marker = $3 - ([^\r]+? // list item text = $4 - (\n{1,2})) - (?= \n* (~0 | \2 ([*+-]|\d+[.]) [ \t]+)) - /gm, function(){...}); - */ - list_str = list_str.replace(/(\n)?(^[ \t]*)([*+-]|\d+[.])[ \t]+([^\r]+?(\n{1,2}))(?=\n*(~0|\2([*+-]|\d+[.])[ \t]+))/gm, - function(wholeMatch,m1,m2,m3,m4){ - var item = m4; - var leading_line = m1; - var leading_space = m2; - - if (leading_line || (item.search(/\n{2,}/)>-1)) { - item = _RunBlockGamut(_Outdent(item)); - } - else { - // Recursion for sub-lists: - item = _DoLists(_Outdent(item)); - item = item.replace(/\n$/,""); // chomp(item) - item = _RunSpanGamut(item); - } - - return "
  • " + item + "
  • \n"; - } - ); - - // attacklab: strip sentinel - list_str = list_str.replace(/~0/g,""); - - g_list_level--; - return list_str; -} - - -var _DoCodeBlocks = function(text) { -// -// Process Markdown `
    ` blocks.
    -//
    -
    -	/*
    -		text = text.replace(text,
    -			/(?:\n\n|^)
    -			(								// $1 = the code block -- one or more lines, starting with a space/tab
    -				(?:
    -					(?:[ ]{4}|\t)			// Lines must start with a tab or a tab-width of spaces - attacklab: g_tab_width
    -					.*\n+
    -				)+
    -			)
    -			(\n*[ ]{0,3}[^ \t\n]|(?=~0))	// attacklab: g_tab_width
    -		/g,function(){...});
    -	*/
    -
    -	// attacklab: sentinel workarounds for lack of \A and \Z, safari\khtml bug
    -	text += "~0";
    -
    -	text = text.replace(/(?:\n\n|^)((?:(?:[ ]{4}|\t).*\n+)+)(\n*[ ]{0,3}[^ \t\n]|(?=~0))/g,
    -		function(wholeMatch,m1,m2) {
    -			var codeblock = m1;
    -			var nextChar = m2;
    -
    -			codeblock = _EncodeCode( _Outdent(codeblock));
    -			codeblock = _Detab(codeblock);
    -			codeblock = codeblock.replace(/^\n+/g,""); // trim leading newlines
    -			codeblock = codeblock.replace(/\n+$/g,""); // trim trailing whitespace
    -
    -			codeblock = "
    " + codeblock + "\n
    "; - - return hashBlock(codeblock) + nextChar; - } - ); - - // attacklab: strip sentinel - text = text.replace(/~0/,""); - - return text; -}; - -var _DoGithubCodeBlocks = function(text) { -// -// Process Github-style code blocks -// Example: -// ```ruby -// def hello_world(x) -// puts "Hello, #{x}" -// end -// ``` -// - - - // attacklab: sentinel workarounds for lack of \A and \Z, safari\khtml bug - text += "~0"; - - text = text.replace(/(?:^|\n)```(.*)\n([\s\S]*?)\n```/g, - function(wholeMatch,m1,m2) { - var language = m1; - var codeblock = m2; - - codeblock = _EncodeCode(codeblock); - codeblock = _Detab(codeblock); - codeblock = codeblock.replace(/^\n+/g,""); // trim leading newlines - codeblock = codeblock.replace(/\n+$/g,""); // trim trailing whitespace - - codeblock = "
    " + codeblock + "\n
    "; - - return hashBlock(codeblock); - } - ); - - // attacklab: strip sentinel - text = text.replace(/~0/,""); - - return text; -} - -var hashBlock = function(text) { - text = text.replace(/(^\n+|\n+$)/g,""); - return "\n\n~K" + (g_html_blocks.push(text)-1) + "K\n\n"; -} - -var _DoCodeSpans = function(text) { -// -// * Backtick quotes are used for spans. -// -// * You can use multiple backticks as the delimiters if you want to -// include literal backticks in the code span. So, this input: -// -// Just type ``foo `bar` baz`` at the prompt. -// -// Will translate to: -// -//

    Just type foo `bar` baz at the prompt.

    -// -// There's no arbitrary limit to the number of backticks you -// can use as delimters. If you need three consecutive backticks -// in your code, use four for delimiters, etc. -// -// * You can use spaces to get literal backticks at the edges: -// -// ... type `` `bar` `` ... -// -// Turns to: -// -// ... type `bar` ... -// - - /* - text = text.replace(/ - (^|[^\\]) // Character before opening ` can't be a backslash - (`+) // $2 = Opening run of ` - ( // $3 = The code block - [^\r]*? - [^`] // attacklab: work around lack of lookbehind - ) - \2 // Matching closer - (?!`) - /gm, function(){...}); - */ - - text = text.replace(/(^|[^\\])(`+)([^\r]*?[^`])\2(?!`)/gm, - function(wholeMatch,m1,m2,m3,m4) { - var c = m3; - c = c.replace(/^([ \t]*)/g,""); // leading whitespace - c = c.replace(/[ \t]*$/g,""); // trailing whitespace - c = _EncodeCode(c); - return m1+""+c+""; - }); - - return text; -} - -var _EncodeCode = function(text) { -// -// Encode/escape certain characters inside Markdown code runs. -// The point is that in code, these characters are literals, -// and lose their special Markdown meanings. -// - // Encode all ampersands; HTML entities are not - // entities within a Markdown code span. - text = text.replace(/&/g,"&"); - - // Do the angle bracket song and dance: - text = text.replace(//g,">"); - - // Now, escape characters that are magic in Markdown: - text = escapeCharacters(text,"\*_{}[]\\",false); - -// jj the line above breaks this: -//--- - -//* Item - -// 1. Subitem - -// special char: * -//--- - - return text; -} - - -var _DoItalicsAndBold = function(text) { - - // must go first: - text = text.replace(/(\*\*|__)(?=\S)([^\r]*?\S[*_]*)\1/g, - "$2"); - - text = text.replace(/(\*|_)(?=\S)([^\r]*?\S)\1/g, - "$2"); - - return text; -} - - -var _DoBlockQuotes = function(text) { - - /* - text = text.replace(/ - ( // Wrap whole match in $1 - ( - ^[ \t]*>[ \t]? // '>' at the start of a line - .+\n // rest of the first line - (.+\n)* // subsequent consecutive lines - \n* // blanks - )+ - ) - /gm, function(){...}); - */ - - text = text.replace(/((^[ \t]*>[ \t]?.+\n(.+\n)*\n*)+)/gm, - function(wholeMatch,m1) { - var bq = m1; - - // attacklab: hack around Konqueror 3.5.4 bug: - // "----------bug".replace(/^-/g,"") == "bug" - - bq = bq.replace(/^[ \t]*>[ \t]?/gm,"~0"); // trim one level of quoting - - // attacklab: clean up hack - bq = bq.replace(/~0/g,""); - - bq = bq.replace(/^[ \t]+$/gm,""); // trim whitespace-only lines - bq = _RunBlockGamut(bq); // recurse - - bq = bq.replace(/(^|\n)/g,"$1 "); - // These leading spaces screw with
     content, so we need to fix that:
    -			bq = bq.replace(
    -					/(\s*
    [^\r]+?<\/pre>)/gm,
    -				function(wholeMatch,m1) {
    -					var pre = m1;
    -					// attacklab: hack around Konqueror 3.5.4 bug:
    -					pre = pre.replace(/^  /mg,"~0");
    -					pre = pre.replace(/~0/g,"");
    -					return pre;
    -				});
    -
    -			return hashBlock("
    \n" + bq + "\n
    "); - }); - return text; -} - - -var _FormParagraphs = function(text) { -// -// Params: -// $text - string to process with html

    tags -// - - // Strip leading and trailing lines: - text = text.replace(/^\n+/g,""); - text = text.replace(/\n+$/g,""); - - var grafs = text.split(/\n{2,}/g); - var grafsOut = []; - - // - // Wrap

    tags. - // - var end = grafs.length; - for (var i=0; i= 0) { - grafsOut.push(str); - } - else if (str.search(/\S/) >= 0) { - str = _RunSpanGamut(str); - str = str.replace(/^([ \t]*)/g,"

    "); - str += "

    " - grafsOut.push(str); - } - - } - - // - // Unhashify HTML blocks - // - end = grafsOut.length; - for (var i=0; i= 0) { - var blockText = g_html_blocks[RegExp.$1]; - blockText = blockText.replace(/\$/g,"$$$$"); // Escape any dollar signs - grafsOut[i] = grafsOut[i].replace(/~K\d+K/,blockText); - } - } - - return grafsOut.join("\n\n"); -} - - -var _EncodeAmpsAndAngles = function(text) { -// Smart processing for ampersands and angle brackets that need to be encoded. - - // Ampersand-encoding based entirely on Nat Irons's Amputator MT plugin: - // http://bumppo.net/projects/amputator/ - text = text.replace(/&(?!#?[xX]?(?:[0-9a-fA-F]+|\w+);)/g,"&"); - - // Encode naked <'s - text = text.replace(/<(?![a-z\/?\$!])/gi,"<"); - - return text; -} - - -var _EncodeBackslashEscapes = function(text) { -// -// Parameter: String. -// Returns: The string, with after processing the following backslash -// escape sequences. -// - - // attacklab: The polite way to do this is with the new - // escapeCharacters() function: - // - // text = escapeCharacters(text,"\\",true); - // text = escapeCharacters(text,"`*_{}[]()>#+-.!",true); - // - // ...but we're sidestepping its use of the (slow) RegExp constructor - // as an optimization for Firefox. This function gets called a LOT. - - text = text.replace(/\\(\\)/g,escapeCharacters_callback); - text = text.replace(/\\([`*_{}\[\]()>#+-.!])/g,escapeCharacters_callback); - return text; -} - - -var _DoAutoLinks = function(text) { - - text = text.replace(/<((https?|ftp|dict):[^'">\s]+)>/gi,"
    $1"); - - // Email addresses: - - /* - text = text.replace(/ - < - (?:mailto:)? - ( - [-.\w]+ - \@ - [-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+ - ) - > - /gi, _DoAutoLinks_callback()); - */ - text = text.replace(/<(?:mailto:)?([-.\w]+\@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+)>/gi, - function(wholeMatch,m1) { - return _EncodeEmailAddress( _UnescapeSpecialChars(m1) ); - } - ); - - return text; -} - - -var _EncodeEmailAddress = function(addr) { -// -// Input: an email address, e.g. "foo@example.com" -// -// Output: the email address as a mailto link, with each character -// of the address encoded as either a decimal or hex entity, in -// the hopes of foiling most address harvesting spam bots. E.g.: -// -// foo -// @example.com -// -// Based on a filter by Matthew Wickline, posted to the BBEdit-Talk -// mailing list: -// - - var encode = [ - function(ch){return "&#"+ch.charCodeAt(0)+";";}, - function(ch){return "&#x"+ch.charCodeAt(0).toString(16)+";";}, - function(ch){return ch;} - ]; - - addr = "mailto:" + addr; - - addr = addr.replace(/./g, function(ch) { - if (ch == "@") { - // this *must* be encoded. I insist. - ch = encode[Math.floor(Math.random()*2)](ch); - } else if (ch !=":") { - // leave ':' alone (to spot mailto: later) - var r = Math.random(); - // roughly 10% raw, 45% hex, 45% dec - ch = ( - r > .9 ? encode[2](ch) : - r > .45 ? encode[1](ch) : - encode[0](ch) - ); - } - return ch; - }); - - addr = "" + addr + ""; - addr = addr.replace(/">.+:/g,"\">"); // strip the mailto: from the visible part - - return addr; -} - - -var _UnescapeSpecialChars = function(text) { -// -// Swap back in all the special characters we've hidden. -// - text = text.replace(/~E(\d+)E/g, - function(wholeMatch,m1) { - var charCodeToReplace = parseInt(m1); - return String.fromCharCode(charCodeToReplace); - } - ); - return text; -} - - -var _Outdent = function(text) { -// -// Remove one level of line-leading tabs or spaces -// - - // attacklab: hack around Konqueror 3.5.4 bug: - // "----------bug".replace(/^-/g,"") == "bug" - - text = text.replace(/^(\t|[ ]{1,4})/gm,"~0"); // attacklab: g_tab_width - - // attacklab: clean up hack - text = text.replace(/~0/g,"") - - return text; -} - -var _Detab = function(text) { -// attacklab: Detab's completely rewritten for speed. -// In perl we could fix it by anchoring the regexp with \G. -// In javascript we're less fortunate. - - // expand first n-1 tabs - text = text.replace(/\t(?=\t)/g," "); // attacklab: g_tab_width - - // replace the nth with two sentinels - text = text.replace(/\t/g,"~A~B"); - - // use the sentinel to anchor our regex so it doesn't explode - text = text.replace(/~B(.+?)~A/g, - function(wholeMatch,m1,m2) { - var leadingText = m1; - var numSpaces = 4 - leadingText.length % 4; // attacklab: g_tab_width - - // there *must* be a better way to do this: - for (var i=0; i +// +// Redistributable under a BSD-style open source license. +// See license.txt for more information. +// +// The full source distribution is at: +// +// A A L +// T C A +// T K B +// +// +// + +// +// Wherever possible, Showdown is a straight, line-by-line port +// of the Perl version of Markdown. +// +// This is not a normal parser design; it's basically just a +// series of string substitutions. It's hard to read and +// maintain this way, but keeping Showdown close to the original +// design makes it easier to port new features. +// +// More importantly, Showdown behaves like markdown.pl in most +// edge cases. So web applications can do client-side preview +// in Javascript, and then build identical HTML on the server. +// +// This port needs the new RegExp functionality of ECMA 262, +// 3rd Edition (i.e. Javascript 1.5). Most modern web browsers +// should do fine. Even with the new regular expression features, +// We do a lot of work to emulate Perl's regex functionality. +// The tricky changes in this file mostly have the "attacklab:" +// label. Major or self-explanatory changes don't. +// +// Smart diff tools like Araxis Merge will be able to match up +// this file with markdown.pl in a useful way. A little tweaking +// helps: in a copy of markdown.pl, replace "#" with "//" and +// replace "$text" with "text". Be sure to ignore whitespace +// and line endings. +// + + +// +// Showdown usage: +// +// var text = "Markdown *rocks*."; +// +// var converter = new Showdown.converter(); +// var html = converter.makeHtml(text); +// +// alert(html); +// +// Note: move the sample code to the bottom of this +// file before uncommenting it. +// + + +// +// Showdown namespace +// +// METEOR CHANGE: remove "var" so that this isn't file-local. +Showdown = { extensions: {} }; + +// +// forEach +// +var forEach = Showdown.forEach = function(obj, callback) { + if (typeof obj.forEach === 'function') { + obj.forEach(callback); + } else { + var i, len = obj.length; + for (i = 0; i < len; i++) { + callback(obj[i], i, obj); + } + } +}; + +// +// Standard extension naming +// +var stdExtName = function(s) { + return s.replace(/[_-]||\s/g, '').toLowerCase(); +}; + +// +// converter +// +// Wraps all "globals" so that the only thing +// exposed is makeHtml(). +// +Showdown.converter = function(converter_options) { + +// +// Globals: +// + +// Global hashes, used by various utility routines +var g_urls; +var g_titles; +var g_html_blocks; + +// Used to track when we're inside an ordered or unordered list +// (see _ProcessListItems() for details): +var g_list_level = 0; + +// Global extensions +var g_lang_extensions = []; +var g_output_modifiers = []; + + +// +// Automatic Extension Loading (node only): +// + +if (typeof module !== 'undefind' && typeof exports !== 'undefined' && typeof require !== 'undefind') { + var fs = require('fs'); + + if (fs) { + // Search extensions folder + var extensions = fs.readdirSync((__dirname || '.')+'/extensions').filter(function(file){ + return ~file.indexOf('.js'); + }).map(function(file){ + return file.replace(/\.js$/, ''); + }); + // Load extensions into Showdown namespace + Showdown.forEach(extensions, function(ext){ + var name = stdExtName(ext); + Showdown.extensions[name] = require('./extensions/' + ext); + }); + } +} + +this.makeHtml = function(text) { +// +// Main function. The order in which other subs are called here is +// essential. Link and image substitutions need to happen before +// _EscapeSpecialCharsWithinTagAttributes(), so that any *'s or _'s in the +// and tags get encoded. +// + + // Clear the global hashes. If we don't clear these, you get conflicts + // from other articles when generating a page which contains more than + // one article (e.g. an index page that shows the N most recent + // articles): + g_urls = {}; + g_titles = {}; + g_html_blocks = []; + + // attacklab: Replace ~ with ~T + // This lets us use tilde as an escape char to avoid md5 hashes + // The choice of character is arbitray; anything that isn't + // magic in Markdown will work. + text = text.replace(/~/g,"~T"); + + // attacklab: Replace $ with ~D + // RegExp interprets $ as a special character + // when it's in a replacement string + text = text.replace(/\$/g,"~D"); + + // Standardize line endings + text = text.replace(/\r\n/g,"\n"); // DOS to Unix + text = text.replace(/\r/g,"\n"); // Mac to Unix + + // Make sure text begins and ends with a couple of newlines: + text = "\n\n" + text + "\n\n"; + + // Convert all tabs to spaces. + text = _Detab(text); + + // Strip any lines consisting only of spaces and tabs. + // This makes subsequent regexen easier to write, because we can + // match consecutive blank lines with /\n+/ instead of something + // contorted like /[ \t]*\n+/ . + text = text.replace(/^[ \t]+$/mg,""); + + // Run language extensions + Showdown.forEach(g_lang_extensions, function(x){ + text = _ExecuteExtension(x, text); + }); + + // Handle github codeblocks prior to running HashHTML so that + // HTML contained within the codeblock gets escaped propertly + text = _DoGithubCodeBlocks(text); + + // Turn block-level HTML blocks into hash entries + text = _HashHTMLBlocks(text); + + // Strip link definitions, store in hashes. + text = _StripLinkDefinitions(text); + + text = _RunBlockGamut(text); + + text = _UnescapeSpecialChars(text); + + // attacklab: Restore dollar signs + text = text.replace(/~D/g,"$$"); + + // attacklab: Restore tildes + text = text.replace(/~T/g,"~"); + + // Run output modifiers + Showdown.forEach(g_output_modifiers, function(x){ + text = _ExecuteExtension(x, text); + }); + + return text; +}; +// +// Options: +// + +// Parse extensions options into separate arrays +if (converter_options && converter_options.extensions) { + + var self = this; + + // Iterate over each plugin + Showdown.forEach(converter_options.extensions, function(plugin){ + + // Assume it's a bundled plugin if a string is given + if (typeof plugin === 'string') { + plugin = Showdown.extensions[stdExtName(plugin)]; + } + + if (typeof plugin === 'function') { + // Iterate over each extension within that plugin + Showdown.forEach(plugin(self), function(ext){ + // Sort extensions by type + if (ext.type) { + if (ext.type === 'language' || ext.type === 'lang') { + g_lang_extensions.push(ext); + } else if (ext.type === 'output' || ext.type === 'html') { + g_output_modifiers.push(ext); + } + } else { + // Assume language extension + g_output_modifiers.push(ext); + } + }); + } else { + throw "Extension '" + plugin + "' could not be loaded. It was either not found or is not a valid extension."; + } + }); +} + + +var _ExecuteExtension = function(ext, text) { + if (ext.regex) { + var re = new RegExp(ext.regex, 'g'); + return text.replace(re, ext.replace); + } else if (ext.filter) { + return ext.filter(text); + } +}; + +var _StripLinkDefinitions = function(text) { +// +// Strips link definitions from text, stores the URLs and titles in +// hash references. +// + + // Link defs are in the form: ^[id]: url "optional title" + + /* + var text = text.replace(/ + ^[ ]{0,3}\[(.+)\]: // id = $1 attacklab: g_tab_width - 1 + [ \t]* + \n? // maybe *one* newline + [ \t]* + ? // url = $2 + [ \t]* + \n? // maybe one newline + [ \t]* + (?: + (\n*) // any lines skipped = $3 attacklab: lookbehind removed + ["(] + (.+?) // title = $4 + [")] + [ \t]* + )? // title is optional + (?:\n+|$) + /gm, + function(){...}); + */ + + // attacklab: sentinel workarounds for lack of \A and \Z, safari\khtml bug + text += "~0"; + + text = text.replace(/^[ ]{0,3}\[(.+)\]:[ \t]*\n?[ \t]*?[ \t]*\n?[ \t]*(?:(\n*)["(](.+?)[")][ \t]*)?(?:\n+|(?=~0))/gm, + function (wholeMatch,m1,m2,m3,m4) { + m1 = m1.toLowerCase(); + g_urls[m1] = _EncodeAmpsAndAngles(m2); // Link IDs are case-insensitive + if (m3) { + // Oops, found blank lines, so it's not a title. + // Put back the parenthetical statement we stole. + return m3+m4; + } else if (m4) { + g_titles[m1] = m4.replace(/"/g,"""); + } + + // Completely remove the definition from the text + return ""; + } + ); + + // attacklab: strip sentinel + text = text.replace(/~0/,""); + + return text; +} + + +var _HashHTMLBlocks = function(text) { + // attacklab: Double up blank lines to reduce lookaround + text = text.replace(/\n/g,"\n\n"); + + // Hashify HTML blocks: + // We only want to do this for block-level HTML tags, such as headers, + // lists, and tables. That's because we still want to wrap

    s around + // "paragraphs" that are wrapped in non-block-level tags, such as anchors, + // phrase emphasis, and spans. The list of tags we're looking for is + // hard-coded: + var block_tags_a = "p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del|style|section|header|footer|nav|article|aside"; + var block_tags_b = "p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|style|section|header|footer|nav|article|aside"; + + // First, look for nested blocks, e.g.: + //

    + //
    + // tags for inner block must be indented. + //
    + //
    + // + // The outermost tags must start at the left margin for this to match, and + // the inner nested divs must be indented. + // We need to do this before the next, more liberal match, because the next + // match will start at the first `
    ` and stop at the first `
    `. + + // attacklab: This regex can be expensive when it fails. + /* + var text = text.replace(/ + ( // save in $1 + ^ // start of line (with /m) + <($block_tags_a) // start tag = $2 + \b // word break + // attacklab: hack around khtml/pcre bug... + [^\r]*?\n // any number of lines, minimally matching + // the matching end tag + [ \t]* // trailing spaces/tabs + (?=\n+) // followed by a newline + ) // attacklab: there are sentinel newlines at end of document + /gm,function(){...}}; + */ + text = text.replace(/^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del)\b[^\r]*?\n<\/\2>[ \t]*(?=\n+))/gm,hashElement); + + // + // Now match more liberally, simply from `\n` to `\n` + // + + /* + var text = text.replace(/ + ( // save in $1 + ^ // start of line (with /m) + <($block_tags_b) // start tag = $2 + \b // word break + // attacklab: hack around khtml/pcre bug... + [^\r]*? // any number of lines, minimally matching + // the matching end tag + [ \t]* // trailing spaces/tabs + (?=\n+) // followed by a newline + ) // attacklab: there are sentinel newlines at end of document + /gm,function(){...}}; + */ + text = text.replace(/^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|style|section|header|footer|nav|article|aside)\b[^\r]*?<\/\2>[ \t]*(?=\n+)\n)/gm,hashElement); + + // Special case just for
    . It was easier to make a special case than + // to make the other regex more complicated. + + /* + text = text.replace(/ + ( // save in $1 + \n\n // Starting after a blank line + [ ]{0,3} + (<(hr) // start tag = $2 + \b // word break + ([^<>])*? // + \/?>) // the matching end tag + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,hashElement); + */ + text = text.replace(/(\n[ ]{0,3}(<(hr)\b([^<>])*?\/?>)[ \t]*(?=\n{2,}))/g,hashElement); + + // Special case for standalone HTML comments: + + /* + text = text.replace(/ + ( // save in $1 + \n\n // Starting after a blank line + [ ]{0,3} // attacklab: g_tab_width - 1 + + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,hashElement); + */ + text = text.replace(/(\n\n[ ]{0,3}[ \t]*(?=\n{2,}))/g,hashElement); + + // PHP and ASP-style processor instructions ( and <%...%>) + + /* + text = text.replace(/ + (?: + \n\n // Starting after a blank line + ) + ( // save in $1 + [ ]{0,3} // attacklab: g_tab_width - 1 + (?: + <([?%]) // $2 + [^\r]*? + \2> + ) + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,hashElement); + */ + text = text.replace(/(?:\n\n)([ ]{0,3}(?:<([?%])[^\r]*?\2>)[ \t]*(?=\n{2,}))/g,hashElement); + + // attacklab: Undo double lines (see comment at top of this function) + text = text.replace(/\n\n/g,"\n"); + return text; +} + +var hashElement = function(wholeMatch,m1) { + var blockText = m1; + + // Undo double lines + blockText = blockText.replace(/\n\n/g,"\n"); + blockText = blockText.replace(/^\n/,""); + + // strip trailing blank lines + blockText = blockText.replace(/\n+$/g,""); + + // Replace the element text with a marker ("~KxK" where x is its key) + blockText = "\n\n~K" + (g_html_blocks.push(blockText)-1) + "K\n\n"; + + return blockText; +}; + +var _RunBlockGamut = function(text) { +// +// These are all the transformations that form block-level +// tags like paragraphs, headers, and list items. +// + text = _DoHeaders(text); + + // Do Horizontal Rules: + var key = hashBlock("
    "); + text = text.replace(/^[ ]{0,2}([ ]?\*[ ]?){3,}[ \t]*$/gm,key); + text = text.replace(/^[ ]{0,2}([ ]?\-[ ]?){3,}[ \t]*$/gm,key); + text = text.replace(/^[ ]{0,2}([ ]?\_[ ]?){3,}[ \t]*$/gm,key); + + text = _DoLists(text); + text = _DoCodeBlocks(text); + text = _DoBlockQuotes(text); + + // We already ran _HashHTMLBlocks() before, in Markdown(), but that + // was to escape raw HTML in the original Markdown source. This time, + // we're escaping the markup we've just created, so that we don't wrap + //

    tags around block-level tags. + text = _HashHTMLBlocks(text); + text = _FormParagraphs(text); + + return text; +}; + + +var _RunSpanGamut = function(text) { +// +// These are all the transformations that occur *within* block-level +// tags like paragraphs, headers, and list items. +// + + text = _DoCodeSpans(text); + text = _EscapeSpecialCharsWithinTagAttributes(text); + text = _EncodeBackslashEscapes(text); + + // Process anchor and image tags. Images must come first, + // because ![foo][f] looks like an anchor. + text = _DoImages(text); + text = _DoAnchors(text); + + // Make links out of things like `` + // Must come after _DoAnchors(), because you can use < and > + // delimiters in inline links like [this](). + text = _DoAutoLinks(text); + text = _EncodeAmpsAndAngles(text); + text = _DoItalicsAndBold(text); + + // Do hard breaks: + text = text.replace(/ +\n/g,"
    \n"); + + return text; +} + +var _EscapeSpecialCharsWithinTagAttributes = function(text) { +// +// Within tags -- meaning between < and > -- encode [\ ` * _] so they +// don't conflict with their use in Markdown for code, italics and strong. +// + + // Build a regex to find HTML tags and comments. See Friedl's + // "Mastering Regular Expressions", 2nd Ed., pp. 200-201. + var regex = /(<[a-z\/!$]("[^"]*"|'[^']*'|[^'">])*>|)/gi; + + text = text.replace(regex, function(wholeMatch) { + var tag = wholeMatch.replace(/(.)<\/?code>(?=.)/g,"$1`"); + tag = escapeCharacters(tag,"\\`*_"); + return tag; + }); + + return text; +} + +var _DoAnchors = function(text) { +// +// Turn Markdown link shortcuts into XHTML
    tags. +// + // + // First, handle reference-style links: [link text] [id] + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ( + (?: + \[[^\]]*\] // allow brackets nested one level + | + [^\[] // or anything else + )* + ) + \] + + [ ]? // one optional space + (?:\n[ ]*)? // one optional newline followed by spaces + + \[ + (.*?) // id = $3 + \] + )()()()() // pad remaining backreferences + /g,_DoAnchors_callback); + */ + text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g,writeAnchorTag); + + // + // Next, inline-style links: [link text](url "optional title") + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ( + (?: + \[[^\]]*\] // allow brackets nested one level + | + [^\[\]] // or anything else + ) + ) + \] + \( // literal paren + [ \t]* + () // no id, so leave $3 empty + ? // href = $4 + [ \t]* + ( // $5 + (['"]) // quote char = $6 + (.*?) // Title = $7 + \6 // matching quote + [ \t]* // ignore any spaces/tabs between closing quote and ) + )? // title is optional + \) + ) + /g,writeAnchorTag); + */ + text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\]\([ \t]*()?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g,writeAnchorTag); + + // + // Last, handle reference-style shortcuts: [link text] + // These must come last in case you've also got [link test][1] + // or [link test](/foo) + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ([^\[\]]+) // link text = $2; can't contain '[' or ']' + \] + )()()()()() // pad rest of backreferences + /g, writeAnchorTag); + */ + text = text.replace(/(\[([^\[\]]+)\])()()()()()/g, writeAnchorTag); + + return text; +} + +var writeAnchorTag = function(wholeMatch,m1,m2,m3,m4,m5,m6,m7) { + if (m7 == undefined) m7 = ""; + var whole_match = m1; + var link_text = m2; + var link_id = m3.toLowerCase(); + var url = m4; + var title = m7; + + if (url == "") { + if (link_id == "") { + // lower-case and turn embedded newlines into spaces + link_id = link_text.toLowerCase().replace(/ ?\n/g," "); + } + url = "#"+link_id; + + if (g_urls[link_id] != undefined) { + url = g_urls[link_id]; + if (g_titles[link_id] != undefined) { + title = g_titles[link_id]; + } + } + else { + if (whole_match.search(/\(\s*\)$/m)>-1) { + // Special case for explicit empty url + url = ""; + } else { + return whole_match; + } + } + } + + url = escapeCharacters(url,"*_"); + var result = ""; + + return result; +} + + +var _DoImages = function(text) { +// +// Turn Markdown image shortcuts into tags. +// + + // + // First, handle reference-style labeled images: ![alt text][id] + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + !\[ + (.*?) // alt text = $2 + \] + + [ ]? // one optional space + (?:\n[ ]*)? // one optional newline followed by spaces + + \[ + (.*?) // id = $3 + \] + )()()()() // pad rest of backreferences + /g,writeImageTag); + */ + text = text.replace(/(!\[(.*?)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g,writeImageTag); + + // + // Next, handle inline images: ![alt text](url "optional title") + // Don't forget: encode * and _ + + /* + text = text.replace(/ + ( // wrap whole match in $1 + !\[ + (.*?) // alt text = $2 + \] + \s? // One optional whitespace character + \( // literal paren + [ \t]* + () // no id, so leave $3 empty + ? // src url = $4 + [ \t]* + ( // $5 + (['"]) // quote char = $6 + (.*?) // title = $7 + \6 // matching quote + [ \t]* + )? // title is optional + \) + ) + /g,writeImageTag); + */ + text = text.replace(/(!\[(.*?)\]\s?\([ \t]*()?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g,writeImageTag); + + return text; +} + +var writeImageTag = function(wholeMatch,m1,m2,m3,m4,m5,m6,m7) { + var whole_match = m1; + var alt_text = m2; + var link_id = m3.toLowerCase(); + var url = m4; + var title = m7; + + if (!title) title = ""; + + if (url == "") { + if (link_id == "") { + // lower-case and turn embedded newlines into spaces + link_id = alt_text.toLowerCase().replace(/ ?\n/g," "); + } + url = "#"+link_id; + + if (g_urls[link_id] != undefined) { + url = g_urls[link_id]; + if (g_titles[link_id] != undefined) { + title = g_titles[link_id]; + } + } + else { + return whole_match; + } + } + + alt_text = alt_text.replace(/"/g,"""); + url = escapeCharacters(url,"*_"); + var result = "\""' + _RunSpanGamut(m1) + "");}); + + text = text.replace(/^(.+)[ \t]*\n-+[ \t]*\n+/gm, + function(matchFound,m1){return hashBlock('

    ' + _RunSpanGamut(m1) + "

    ");}); + + // atx-style headers: + // # Header 1 + // ## Header 2 + // ## Header 2 with closing hashes ## + // ... + // ###### Header 6 + // + + /* + text = text.replace(/ + ^(\#{1,6}) // $1 = string of #'s + [ \t]* + (.+?) // $2 = Header text + [ \t]* + \#* // optional closing #'s (not counted) + \n+ + /gm, function() {...}); + */ + + text = text.replace(/^(\#{1,6})[ \t]*(.+?)[ \t]*\#*\n+/gm, + function(wholeMatch,m1,m2) { + var h_level = m1.length; + return hashBlock("' + _RunSpanGamut(m2) + ""); + }); + + function headerId(m) { + return m.replace(/[^\w]/g, '').toLowerCase(); + } + return text; +} + +// This declaration keeps Dojo compressor from outputting garbage: +var _ProcessListItems; + +var _DoLists = function(text) { +// +// Form HTML ordered (numbered) and unordered (bulleted) lists. +// + + // attacklab: add sentinel to hack around khtml/safari bug: + // http://bugs.webkit.org/show_bug.cgi?id=11231 + text += "~0"; + + // Re-usable pattern to match any entirel ul or ol list: + + /* + var whole_list = / + ( // $1 = whole list + ( // $2 + [ ]{0,3} // attacklab: g_tab_width - 1 + ([*+-]|\d+[.]) // $3 = first list item marker + [ \t]+ + ) + [^\r]+? + ( // $4 + ~0 // sentinel for workaround; should be $ + | + \n{2,} + (?=\S) + (?! // Negative lookahead for another list item marker + [ \t]* + (?:[*+-]|\d+[.])[ \t]+ + ) + ) + )/g + */ + var whole_list = /^(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/gm; + + if (g_list_level) { + text = text.replace(whole_list,function(wholeMatch,m1,m2) { + var list = m1; + var list_type = (m2.search(/[*+-]/g)>-1) ? "ul" : "ol"; + + // Turn double returns into triple returns, so that we can make a + // paragraph for the last item in a list, if necessary: + list = list.replace(/\n{2,}/g,"\n\n\n");; + var result = _ProcessListItems(list); + + // Trim any trailing whitespace, to put the closing `` + // up on the preceding line, to get it past the current stupid + // HTML block parser. This is a hack to work around the terrible + // hack that is the HTML block parser. + result = result.replace(/\s+$/,""); + result = "<"+list_type+">" + result + "\n"; + return result; + }); + } else { + whole_list = /(\n\n|^\n?)(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/g; + text = text.replace(whole_list,function(wholeMatch,m1,m2,m3) { + var runup = m1; + var list = m2; + + var list_type = (m3.search(/[*+-]/g)>-1) ? "ul" : "ol"; + // Turn double returns into triple returns, so that we can make a + // paragraph for the last item in a list, if necessary: + var list = list.replace(/\n{2,}/g,"\n\n\n");; + var result = _ProcessListItems(list); + result = runup + "<"+list_type+">\n" + result + "\n"; + return result; + }); + } + + // attacklab: strip sentinel + text = text.replace(/~0/,""); + + return text; +} + +_ProcessListItems = function(list_str) { +// +// Process the contents of a single ordered or unordered list, splitting it +// into individual list items. +// + // The $g_list_level global keeps track of when we're inside a list. + // Each time we enter a list, we increment it; when we leave a list, + // we decrement. If it's zero, we're not in a list anymore. + // + // We do this because when we're not inside a list, we want to treat + // something like this: + // + // I recommend upgrading to version + // 8. Oops, now this line is treated + // as a sub-list. + // + // As a single paragraph, despite the fact that the second line starts + // with a digit-period-space sequence. + // + // Whereas when we're inside a list (or sub-list), that line will be + // treated as the start of a sub-list. What a kludge, huh? This is + // an aspect of Markdown's syntax that's hard to parse perfectly + // without resorting to mind-reading. Perhaps the solution is to + // change the syntax rules such that sub-lists must start with a + // starting cardinal number; e.g. "1." or "a.". + + g_list_level++; + + // trim trailing blank lines: + list_str = list_str.replace(/\n{2,}$/,"\n"); + + // attacklab: add sentinel to emulate \z + list_str += "~0"; + + /* + list_str = list_str.replace(/ + (\n)? // leading line = $1 + (^[ \t]*) // leading whitespace = $2 + ([*+-]|\d+[.]) [ \t]+ // list marker = $3 + ([^\r]+? // list item text = $4 + (\n{1,2})) + (?= \n* (~0 | \2 ([*+-]|\d+[.]) [ \t]+)) + /gm, function(){...}); + */ + list_str = list_str.replace(/(\n)?(^[ \t]*)([*+-]|\d+[.])[ \t]+([^\r]+?(\n{1,2}))(?=\n*(~0|\2([*+-]|\d+[.])[ \t]+))/gm, + function(wholeMatch,m1,m2,m3,m4){ + var item = m4; + var leading_line = m1; + var leading_space = m2; + + if (leading_line || (item.search(/\n{2,}/)>-1)) { + item = _RunBlockGamut(_Outdent(item)); + } + else { + // Recursion for sub-lists: + item = _DoLists(_Outdent(item)); + item = item.replace(/\n$/,""); // chomp(item) + item = _RunSpanGamut(item); + } + + return "
  • " + item + "
  • \n"; + } + ); + + // attacklab: strip sentinel + list_str = list_str.replace(/~0/g,""); + + g_list_level--; + return list_str; +} + + +var _DoCodeBlocks = function(text) { +// +// Process Markdown `
    ` blocks.
    +//
    +
    +	/*
    +		text = text.replace(text,
    +			/(?:\n\n|^)
    +			(								// $1 = the code block -- one or more lines, starting with a space/tab
    +				(?:
    +					(?:[ ]{4}|\t)			// Lines must start with a tab or a tab-width of spaces - attacklab: g_tab_width
    +					.*\n+
    +				)+
    +			)
    +			(\n*[ ]{0,3}[^ \t\n]|(?=~0))	// attacklab: g_tab_width
    +		/g,function(){...});
    +	*/
    +
    +	// attacklab: sentinel workarounds for lack of \A and \Z, safari\khtml bug
    +	text += "~0";
    +
    +	text = text.replace(/(?:\n\n|^)((?:(?:[ ]{4}|\t).*\n+)+)(\n*[ ]{0,3}[^ \t\n]|(?=~0))/g,
    +		function(wholeMatch,m1,m2) {
    +			var codeblock = m1;
    +			var nextChar = m2;
    +
    +			codeblock = _EncodeCode( _Outdent(codeblock));
    +			codeblock = _Detab(codeblock);
    +			codeblock = codeblock.replace(/^\n+/g,""); // trim leading newlines
    +			codeblock = codeblock.replace(/\n+$/g,""); // trim trailing whitespace
    +
    +			codeblock = "
    " + codeblock + "\n
    "; + + return hashBlock(codeblock) + nextChar; + } + ); + + // attacklab: strip sentinel + text = text.replace(/~0/,""); + + return text; +}; + +var _DoGithubCodeBlocks = function(text) { +// +// Process Github-style code blocks +// Example: +// ```ruby +// def hello_world(x) +// puts "Hello, #{x}" +// end +// ``` +// + + + // attacklab: sentinel workarounds for lack of \A and \Z, safari\khtml bug + text += "~0"; + + text = text.replace(/(?:^|\n)```(.*)\n([\s\S]*?)\n```/g, + function(wholeMatch,m1,m2) { + var language = m1; + var codeblock = m2; + + codeblock = _EncodeCode(codeblock); + codeblock = _Detab(codeblock); + codeblock = codeblock.replace(/^\n+/g,""); // trim leading newlines + codeblock = codeblock.replace(/\n+$/g,""); // trim trailing whitespace + + codeblock = "
    " + codeblock + "\n
    "; + + return hashBlock(codeblock); + } + ); + + // attacklab: strip sentinel + text = text.replace(/~0/,""); + + return text; +} + +var hashBlock = function(text) { + text = text.replace(/(^\n+|\n+$)/g,""); + return "\n\n~K" + (g_html_blocks.push(text)-1) + "K\n\n"; +} + +var _DoCodeSpans = function(text) { +// +// * Backtick quotes are used for spans. +// +// * You can use multiple backticks as the delimiters if you want to +// include literal backticks in the code span. So, this input: +// +// Just type ``foo `bar` baz`` at the prompt. +// +// Will translate to: +// +//

    Just type foo `bar` baz at the prompt.

    +// +// There's no arbitrary limit to the number of backticks you +// can use as delimters. If you need three consecutive backticks +// in your code, use four for delimiters, etc. +// +// * You can use spaces to get literal backticks at the edges: +// +// ... type `` `bar` `` ... +// +// Turns to: +// +// ... type `bar` ... +// + + /* + text = text.replace(/ + (^|[^\\]) // Character before opening ` can't be a backslash + (`+) // $2 = Opening run of ` + ( // $3 = The code block + [^\r]*? + [^`] // attacklab: work around lack of lookbehind + ) + \2 // Matching closer + (?!`) + /gm, function(){...}); + */ + + text = text.replace(/(^|[^\\])(`+)([^\r]*?[^`])\2(?!`)/gm, + function(wholeMatch,m1,m2,m3,m4) { + var c = m3; + c = c.replace(/^([ \t]*)/g,""); // leading whitespace + c = c.replace(/[ \t]*$/g,""); // trailing whitespace + c = _EncodeCode(c); + return m1+""+c+""; + }); + + return text; +} + +var _EncodeCode = function(text) { +// +// Encode/escape certain characters inside Markdown code runs. +// The point is that in code, these characters are literals, +// and lose their special Markdown meanings. +// + // Encode all ampersands; HTML entities are not + // entities within a Markdown code span. + text = text.replace(/&/g,"&"); + + // Do the angle bracket song and dance: + text = text.replace(//g,">"); + + // Now, escape characters that are magic in Markdown: + text = escapeCharacters(text,"\*_{}[]\\",false); + +// jj the line above breaks this: +//--- + +//* Item + +// 1. Subitem + +// special char: * +//--- + + return text; +} + + +var _DoItalicsAndBold = function(text) { + + // must go first: + text = text.replace(/(\*\*|__)(?=\S)([^\r]*?\S[*_]*)\1/g, + "$2"); + + text = text.replace(/(\*|_)(?=\S)([^\r]*?\S)\1/g, + "$2"); + + return text; +} + + +var _DoBlockQuotes = function(text) { + + /* + text = text.replace(/ + ( // Wrap whole match in $1 + ( + ^[ \t]*>[ \t]? // '>' at the start of a line + .+\n // rest of the first line + (.+\n)* // subsequent consecutive lines + \n* // blanks + )+ + ) + /gm, function(){...}); + */ + + text = text.replace(/((^[ \t]*>[ \t]?.+\n(.+\n)*\n*)+)/gm, + function(wholeMatch,m1) { + var bq = m1; + + // attacklab: hack around Konqueror 3.5.4 bug: + // "----------bug".replace(/^-/g,"") == "bug" + + bq = bq.replace(/^[ \t]*>[ \t]?/gm,"~0"); // trim one level of quoting + + // attacklab: clean up hack + bq = bq.replace(/~0/g,""); + + bq = bq.replace(/^[ \t]+$/gm,""); // trim whitespace-only lines + bq = _RunBlockGamut(bq); // recurse + + bq = bq.replace(/(^|\n)/g,"$1 "); + // These leading spaces screw with
     content, so we need to fix that:
    +			bq = bq.replace(
    +					/(\s*
    [^\r]+?<\/pre>)/gm,
    +				function(wholeMatch,m1) {
    +					var pre = m1;
    +					// attacklab: hack around Konqueror 3.5.4 bug:
    +					pre = pre.replace(/^  /mg,"~0");
    +					pre = pre.replace(/~0/g,"");
    +					return pre;
    +				});
    +
    +			return hashBlock("
    \n" + bq + "\n
    "); + }); + return text; +} + + +var _FormParagraphs = function(text) { +// +// Params: +// $text - string to process with html

    tags +// + + // Strip leading and trailing lines: + text = text.replace(/^\n+/g,""); + text = text.replace(/\n+$/g,""); + + var grafs = text.split(/\n{2,}/g); + var grafsOut = []; + + // + // Wrap

    tags. + // + var end = grafs.length; + for (var i=0; i= 0) { + grafsOut.push(str); + } + else if (str.search(/\S/) >= 0) { + str = _RunSpanGamut(str); + str = str.replace(/^([ \t]*)/g,"

    "); + str += "

    " + grafsOut.push(str); + } + + } + + // + // Unhashify HTML blocks + // + end = grafsOut.length; + for (var i=0; i= 0) { + var blockText = g_html_blocks[RegExp.$1]; + blockText = blockText.replace(/\$/g,"$$$$"); // Escape any dollar signs + grafsOut[i] = grafsOut[i].replace(/~K\d+K/,blockText); + } + } + + return grafsOut.join("\n\n"); +} + + +var _EncodeAmpsAndAngles = function(text) { +// Smart processing for ampersands and angle brackets that need to be encoded. + + // Ampersand-encoding based entirely on Nat Irons's Amputator MT plugin: + // http://bumppo.net/projects/amputator/ + text = text.replace(/&(?!#?[xX]?(?:[0-9a-fA-F]+|\w+);)/g,"&"); + + // Encode naked <'s + text = text.replace(/<(?![a-z\/?\$!])/gi,"<"); + + return text; +} + + +var _EncodeBackslashEscapes = function(text) { +// +// Parameter: String. +// Returns: The string, with after processing the following backslash +// escape sequences. +// + + // attacklab: The polite way to do this is with the new + // escapeCharacters() function: + // + // text = escapeCharacters(text,"\\",true); + // text = escapeCharacters(text,"`*_{}[]()>#+-.!",true); + // + // ...but we're sidestepping its use of the (slow) RegExp constructor + // as an optimization for Firefox. This function gets called a LOT. + + text = text.replace(/\\(\\)/g,escapeCharacters_callback); + text = text.replace(/\\([`*_{}\[\]()>#+-.!])/g,escapeCharacters_callback); + return text; +} + + +var _DoAutoLinks = function(text) { + + text = text.replace(/<((https?|ftp|dict):[^'">\s]+)>/gi,"
    $1"); + + // Email addresses: + + /* + text = text.replace(/ + < + (?:mailto:)? + ( + [-.\w]+ + \@ + [-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+ + ) + > + /gi, _DoAutoLinks_callback()); + */ + text = text.replace(/<(?:mailto:)?([-.\w]+\@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+)>/gi, + function(wholeMatch,m1) { + return _EncodeEmailAddress( _UnescapeSpecialChars(m1) ); + } + ); + + return text; +} + + +var _EncodeEmailAddress = function(addr) { +// +// Input: an email address, e.g. "foo@example.com" +// +// Output: the email address as a mailto link, with each character +// of the address encoded as either a decimal or hex entity, in +// the hopes of foiling most address harvesting spam bots. E.g.: +// +// foo +// @example.com +// +// Based on a filter by Matthew Wickline, posted to the BBEdit-Talk +// mailing list: +// + + var encode = [ + function(ch){return "&#"+ch.charCodeAt(0)+";";}, + function(ch){return "&#x"+ch.charCodeAt(0).toString(16)+";";}, + function(ch){return ch;} + ]; + + addr = "mailto:" + addr; + + addr = addr.replace(/./g, function(ch) { + if (ch == "@") { + // this *must* be encoded. I insist. + ch = encode[Math.floor(Math.random()*2)](ch); + } else if (ch !=":") { + // leave ':' alone (to spot mailto: later) + var r = Math.random(); + // roughly 10% raw, 45% hex, 45% dec + ch = ( + r > .9 ? encode[2](ch) : + r > .45 ? encode[1](ch) : + encode[0](ch) + ); + } + return ch; + }); + + addr = "" + addr + ""; + addr = addr.replace(/">.+:/g,"\">"); // strip the mailto: from the visible part + + return addr; +} + + +var _UnescapeSpecialChars = function(text) { +// +// Swap back in all the special characters we've hidden. +// + text = text.replace(/~E(\d+)E/g, + function(wholeMatch,m1) { + var charCodeToReplace = parseInt(m1); + return String.fromCharCode(charCodeToReplace); + } + ); + return text; +} + + +var _Outdent = function(text) { +// +// Remove one level of line-leading tabs or spaces +// + + // attacklab: hack around Konqueror 3.5.4 bug: + // "----------bug".replace(/^-/g,"") == "bug" + + text = text.replace(/^(\t|[ ]{1,4})/gm,"~0"); // attacklab: g_tab_width + + // attacklab: clean up hack + text = text.replace(/~0/g,"") + + return text; +} + +var _Detab = function(text) { +// attacklab: Detab's completely rewritten for speed. +// In perl we could fix it by anchoring the regexp with \G. +// In javascript we're less fortunate. + + // expand first n-1 tabs + text = text.replace(/\t(?=\t)/g," "); // attacklab: g_tab_width + + // replace the nth with two sentinels + text = text.replace(/\t/g,"~A~B"); + + // use the sentinel to anchor our regex so it doesn't explode + text = text.replace(/~B(.+?)~A/g, + function(wholeMatch,m1,m2) { + var leadingText = m1; + var numSpaces = 4 - leadingText.length % 4; // attacklab: g_tab_width + + // there *must* be a better way to do this: + for (var i=0; i name. This +// function is used by the template file scanner. +SpacebarsCompiler.isReservedName = function (name) { + return builtInBlockHelpers.hasOwnProperty(name); +}; + +var makeObjectLiteral = function (obj) { + var parts = []; + for (var k in obj) + parts.push(BlazeTools.toObjectLiteralKey(k) + ': ' + obj[k]); + return '{' + parts.join(', ') + '}'; +}; + +_.extend(CodeGen.prototype, { + codeGenTemplateTag: function (tag) { + var self = this; + if (tag.position === HTMLTools.TEMPLATE_TAG_POSITION.IN_START_TAG) { + // Special dynamic attributes: `
    ...` + // only `tag.type === 'DOUBLE'` allowed (by earlier validation) + return BlazeTools.EmitCode('function () { return ' + + self.codeGenMustache(tag.path, tag.args, 'attrMustache') + + '; }'); + } else { + if (tag.type === 'DOUBLE' || tag.type === 'TRIPLE') { + var code = self.codeGenMustache(tag.path, tag.args); + if (tag.type === 'TRIPLE') { + code = 'Spacebars.makeRaw(' + code + ')'; + } + if (tag.position !== HTMLTools.TEMPLATE_TAG_POSITION.IN_ATTRIBUTE) { + // Reactive attributes are already wrapped in a function, + // and there's no fine-grained reactivity. + // Anywhere else, we need to create a View. + code = 'Blaze.View(function () { return ' + code + '; })'; + } + return BlazeTools.EmitCode(code); + } else if (tag.type === 'INCLUSION' || tag.type === 'BLOCKOPEN') { + var path = tag.path; + + if (tag.type === 'BLOCKOPEN' && + builtInBlockHelpers.hasOwnProperty(path[0])) { + // if, unless, with, each. + // + // If someone tries to do `{{> if}}`, we don't + // get here, but an error is thrown when we try to codegen the path. + + // Note: If we caught these errors earlier, while scanning, we'd be able to + // provide nice line numbers. + if (path.length > 1) + throw new Error("Unexpected dotted path beginning with " + path[0]); + if (! tag.args.length) + throw new Error("#" + path[0] + " requires an argument"); + + // `args` must exist (tag.args.length > 0) + var dataCode = self.codeGenInclusionDataFunc(tag.args) || 'null'; + // `content` must exist + var contentBlock = (('content' in tag) ? + self.codeGenBlock(tag.content) : null); + // `elseContent` may not exist + var elseContentBlock = (('elseContent' in tag) ? + self.codeGenBlock(tag.elseContent) : null); + + var callArgs = [dataCode, contentBlock]; + if (elseContentBlock) + callArgs.push(elseContentBlock); + + return BlazeTools.EmitCode( + builtInBlockHelpers[path[0]] + '(' + callArgs.join(', ') + ')'); + + } else { + var compCode = self.codeGenPath(path, {lookupTemplate: true}); + if (path.length > 1) { + // capture reactivity + compCode = 'function () { return Spacebars.call(' + compCode + + '); }'; + } + + var dataCode = self.codeGenInclusionDataFunc(tag.args); + var content = (('content' in tag) ? + self.codeGenBlock(tag.content) : null); + var elseContent = (('elseContent' in tag) ? + self.codeGenBlock(tag.elseContent) : null); + + var includeArgs = [compCode]; + if (content) { + includeArgs.push(content); + if (elseContent) + includeArgs.push(elseContent); + } + + var includeCode = + 'Spacebars.include(' + includeArgs.join(', ') + ')'; + + // calling convention compat -- set the data context around the + // entire inclusion, so that if the name of the inclusion is + // a helper function, it gets the data context in `this`. + // This makes for a pretty confusing calling convention -- + // In `{{#foo bar}}`, `foo` is evaluated in the context of `bar` + // -- but it's what we shipped for 0.8.0. The rationale is that + // `{{#foo bar}}` is sugar for `{{#with bar}}{{#foo}}...`. + if (dataCode) { + includeCode = + 'Spacebars.TemplateWith(' + dataCode + ', function () { return ' + + includeCode + '; })'; + } + + if (path[0] === 'UI' && + (path[1] === 'contentBlock' || path[1] === 'elseBlock')) { + includeCode = 'Blaze.InOuterTemplateScope(view, function () { return ' + + includeCode + '; })'; + } + + return BlazeTools.EmitCode(includeCode); + } + } else { + // Can't get here; TemplateTag validation should catch any + // inappropriate tag types that might come out of the parser. + throw new Error("Unexpected template tag type: " + tag.type); + } + } + }, + + // `path` is an array of at least one string. + // + // If `path.length > 1`, the generated code may be reactive + // (i.e. it may invalidate the current computation). + // + // No code is generated to call the result if it's a function. + // + // Options: + // + // - lookupTemplate {Boolean} If true, generated code also looks in + // the list of templates. (After helpers, before data context). + // Used when generating code for `{{> foo}}` or `{{#foo}}`. Only + // used for non-dotted paths. + codeGenPath: function (path, opts) { + if (builtInBlockHelpers.hasOwnProperty(path[0])) + throw new Error("Can't use the built-in '" + path[0] + "' here"); + // Let `{{#if UI.contentBlock}}` check whether this template was invoked via + // inclusion or as a block helper, in addition to supporting + // `{{> UI.contentBlock}}`. + if (path.length >= 2 && + path[0] === 'UI' && builtInUIPaths.hasOwnProperty(path[1])) { + if (path.length > 2) + throw new Error("Unexpected dotted path beginning with " + + path[0] + '.' + path[1]); + return builtInUIPaths[path[1]]; + } + + var firstPathItem = BlazeTools.toJSLiteral(path[0]); + var lookupMethod = 'lookup'; + if (opts && opts.lookupTemplate && path.length === 1) + lookupMethod = 'lookupTemplate'; + var code = 'view.' + lookupMethod + '(' + firstPathItem + ')'; + + if (path.length > 1) { + code = 'Spacebars.dot(' + code + ', ' + + _.map(path.slice(1), BlazeTools.toJSLiteral).join(', ') + ')'; + } + + return code; + }, + + // Generates code for an `[argType, argValue]` argument spec, + // ignoring the third element (keyword argument name) if present. + // + // The resulting code may be reactive (in the case of a PATH of + // more than one element) and is not wrapped in a closure. + codeGenArgValue: function (arg) { + var self = this; + + var argType = arg[0]; + var argValue = arg[1]; + + var argCode; + switch (argType) { + case 'STRING': + case 'NUMBER': + case 'BOOLEAN': + case 'NULL': + argCode = BlazeTools.toJSLiteral(argValue); + break; + case 'PATH': + argCode = self.codeGenPath(argValue); + break; + default: + // can't get here + throw new Error("Unexpected arg type: " + argType); + } + + return argCode; + }, + + // Generates a call to `Spacebars.fooMustache` on evaluated arguments. + // The resulting code has no function literals and must be wrapped in + // one for fine-grained reactivity. + codeGenMustache: function (path, args, mustacheType) { + var self = this; + + var nameCode = self.codeGenPath(path); + var argCode = self.codeGenMustacheArgs(args); + var mustache = (mustacheType || 'mustache'); + + return 'Spacebars.' + mustache + '(' + nameCode + + (argCode ? ', ' + argCode.join(', ') : '') + ')'; + }, + + // returns: array of source strings, or null if no + // args at all. + codeGenMustacheArgs: function (tagArgs) { + var self = this; + + var kwArgs = null; // source -> source + var args = null; // [source] + + // tagArgs may be null + _.each(tagArgs, function (arg) { + var argCode = self.codeGenArgValue(arg); + + if (arg.length > 2) { + // keyword argument (represented as [type, value, name]) + kwArgs = (kwArgs || {}); + kwArgs[arg[2]] = argCode; + } else { + // positional argument + args = (args || []); + args.push(argCode); + } + }); + + // put kwArgs in options dictionary at end of args + if (kwArgs) { + args = (args || []); + args.push('Spacebars.kw(' + makeObjectLiteral(kwArgs) + ')'); + } + + return args; + }, + + codeGenBlock: function (content) { + return SpacebarsCompiler.codeGen(content); + }, + + codeGenInclusionDataFunc: function (args) { + var self = this; + + var dataFuncCode = null; + + if (! args.length) { + // e.g. `{{#foo}}` + return null; + } else if (args[0].length === 3) { + // keyword arguments only, e.g. `{{> point x=1 y=2}}` + var dataProps = {}; + _.each(args, function (arg) { + var argKey = arg[2]; + dataProps[argKey] = 'Spacebars.call(' + self.codeGenArgValue(arg) + ')'; + }); + dataFuncCode = makeObjectLiteral(dataProps); + } else if (args[0][0] !== 'PATH') { + // literal first argument, e.g. `{{> foo "blah"}}` + // + // tag validation has confirmed, in this case, that there is only + // one argument (`args.length === 1`) + dataFuncCode = self.codeGenArgValue(args[0]); + } else if (args.length === 1) { + // one argument, must be a PATH + dataFuncCode = 'Spacebars.call(' + self.codeGenPath(args[0][1]) + ')'; + } else { + // Multiple positional arguments; treat them as a nested + // "data mustache" + dataFuncCode = self.codeGenMustache(args[0][1], args.slice(1), + 'dataMustache'); + } + + return 'function () { return ' + dataFuncCode + '; }'; + } + +}); diff --git a/packages/spacebars-compiler/compile_tests.js b/packages/spacebars-compiler/compile_tests.js index e357c6f0fd..9acf1c915e 100644 --- a/packages/spacebars-compiler/compile_tests.js +++ b/packages/spacebars-compiler/compile_tests.js @@ -1,4 +1,4 @@ -Tinytest.add("spacebars - compiler output", function (test) { +Tinytest.add("spacebars-compiler - compiler output", function (test) { var run = function (input, expected) { if (expected.fail) { @@ -7,7 +7,7 @@ Tinytest.add("spacebars - compiler output", function (test) { var msg = ''; test.throws(function () { try { - Spacebars.compile(input); + SpacebarsCompiler.compile(input, {isTemplate: true}); } catch (e) { msg = e.message; throw e; @@ -16,7 +16,7 @@ Tinytest.add("spacebars - compiler output", function (test) { test.equal(msg.slice(0, expectedMessage.length), expectedMessage); } else { - var output = Spacebars.compile(input); + var output = SpacebarsCompiler.compile(input, {isTemplate: true}); var postProcess = function (string) { // remove initial and trailing parens string = string.replace(/^\(([\S\s]*)\)$/, '$1'); @@ -28,8 +28,6 @@ Tinytest.add("spacebars - compiler output", function (test) { // Remove single-line comments, including line nums from build system. string = string.replace(/\/\/.*$/mg, ''); string = string.replace(/\s+/g, ''); // kill whitespace - // collapse identical consecutive parens - string = string.replace(/\(+/g, '(').replace(/\)+/g, ')'); } return string; }; @@ -37,241 +35,24 @@ Tinytest.add("spacebars - compiler output", function (test) { test._stringEqual( postProcess(output.toString()), postProcess( - Spacebars._beautify('(' + expected.toString() + ')')), + SpacebarsCompiler._beautify('(' + expected.toString() + ')')), input); } }; - - - run("abc", - function () { - var self = this; - return "abc"; - }); - - run("{{foo}}", - function() { - var self = this; - return function() { - return Spacebars.mustache(self.lookup("foo")); - }; - }); - - run("{{foo bar}}", - function() { - var self = this; - return function() { - return Spacebars.mustache(self.lookup("foo"), self.lookup("bar")); - }; - }); - - run("{{foo x=bar}}", - function() { - var self = this; - return function() { - return Spacebars.mustache(self.lookup("foo"), Spacebars.kw({ - x: self.lookup("bar") - })); - }; - }); - - run("{{foo.bar baz}}", - function() { - var self = this; - return function() { - return Spacebars.mustache(Spacebars.dot(self.lookup("foo"), "bar"), self.lookup("baz")); - }; - }); - - run("{{foo bar.baz}}", - function() { - var self = this; - return function() { - return Spacebars.mustache(self.lookup("foo"), Spacebars.dot(self.lookup("bar"), "baz")); - }; - }); - - run("{{foo x=bar.baz}}", - function() { - var self = this; - return function() { - return Spacebars.mustache(self.lookup("foo"), Spacebars.kw({ - x: Spacebars.dot(self.lookup("bar"), "baz") - })); - }; - }); - - run("{{#foo}}abc{{/foo}}", - function() { - var self = this; - return Spacebars.include(self.lookupTemplate("foo"), UI.block(function() { - var self = this; - return "abc"; - })); - }); - - run("{{#if cond}}aaa{{else}}bbb{{/if}}", - function() { - var self = this; - return UI.If(function () { - return Spacebars.call(self.lookup("cond")); - }, UI.block(function() { - var self = this; - return "aaa"; - }), UI.block(function() { - var self = this; - return "bbb"; - })); - }); - - run("{{!-- --}}{{#if cond}}aaa{{!\n}}{{else}}{{!}}bbb{{!-- --}}{{/if}}{{!}}", - function() { - var self = this; - return UI.If(function () { - return Spacebars.call(self.lookup("cond")); - }, UI.block(function() { - var self = this; - return "aaa"; - }), UI.block(function() { - var self = this; - return "bbb"; - })); - }); - - run("{{> foo bar}}", - function() { - var self = this; - return Spacebars.TemplateWith(function() { - return Spacebars.call(self.lookup("bar")); - }, UI.block(function() { - var self = this; - return Spacebars.include(self.lookupTemplate("foo")); - })); - }); - - run("{{> foo x=bar}}", - function() { - var self = this; - return Spacebars.TemplateWith(function() { - return { - x: Spacebars.call(self.lookup("bar")) - }; - }, UI.block(function() { - var self = this; - return Spacebars.include(self.lookupTemplate("foo")); - })); - }); - - run("{{> foo bar.baz}}", - function() { - var self = this; - return Spacebars.TemplateWith(function() { - return Spacebars.call(Spacebars.dot(self.lookup("bar"), "baz")); - }, UI.block(function() { - var self = this; - return Spacebars.include(self.lookupTemplate("foo")); - })); - }); - - run("{{> foo x=bar.baz}}", - function() { - var self = this; - return Spacebars.TemplateWith(function() { - return { - x: Spacebars.call(Spacebars.dot(self.lookup("bar"), "baz")) - }; - }, UI.block(function() { - var self = this; - return Spacebars.include(self.lookupTemplate("foo")); - })); - }); - - run("{{> foo bar baz}}", - function() { - var self = this; - return Spacebars.TemplateWith(function() { - return Spacebars.dataMustache(self.lookup("bar"), self.lookup("baz")); - }, UI.block(function() { - var self = this; - return Spacebars.include(self.lookupTemplate("foo")); - })); - }); - - run("{{#foo bar baz}}aaa{{/foo}}", - function() { - var self = this; - return Spacebars.TemplateWith(function() { - return Spacebars.dataMustache(self.lookup("bar"), self.lookup("baz")); - }, UI.block(function() { - var self = this; - return Spacebars.include(self.lookupTemplate("foo"), UI.block(function() { - var self = this; - return "aaa"; - })); - })); - }); - - run("{{#foo p.q r.s}}aaa{{/foo}}", - function() { - var self = this; - return Spacebars.TemplateWith(function() { - return Spacebars.dataMustache(Spacebars.dot(self.lookup("p"), "q"), Spacebars.dot(self.lookup("r"), "s")); - }, UI.block(function() { - var self = this; - return Spacebars.include(self.lookupTemplate("foo"), UI.block(function() { - var self = this; - return "aaa"; - })); - })); - }); - - run("", - function() { - var self = this; - return HTML.A({ - $dynamic: [ function() { - return Spacebars.attrMustache(self.lookup("b")); - } ] - }); - }); - - run("", - function() { - var self = this; - return HTML.A({ - c: [ "d", function() { - return Spacebars.mustache(self.lookup("e")); - }, "f" ], - $dynamic: [ function() { - return Spacebars.attrMustache(self.lookup("b")); - } ] - }); - }); - - run("{{foo}}", - function () { - var self = this; - return HTML.getTag("asdf")(function () { - return Spacebars.mustache(self.lookup("foo")); - }); - }); - - run("", - function () { - var self = this; - return HTML.TEXTAREA(function () { - return Spacebars.mustache(self.lookup("foo")); - }); - }); - + coffee.runCompilerOutputTests(run); }); -Tinytest.add("spacebars - compiler errors", function (test) { +coffee = { + runCompilerOutputTests: null // implemented in compiler_output_tests.coffee +}; + + +Tinytest.add("spacebars-compiler - compiler errors", function (test) { var getError = function (input) { try { - Spacebars.compile(input); + SpacebarsCompiler.compile(input); } catch (e) { return e.message; } diff --git a/packages/spacebars-compiler/compiler.js b/packages/spacebars-compiler/compiler.js new file mode 100644 index 0000000000..9e819aa629 --- /dev/null +++ b/packages/spacebars-compiler/compiler.js @@ -0,0 +1,110 @@ + +SpacebarsCompiler.parse = function (input) { + + var tree = HTMLTools.parseFragment( + input, + { getTemplateTag: TemplateTag.parseCompleteTag }); + + return tree; +}; + +SpacebarsCompiler.compile = function (input, options) { + var tree = SpacebarsCompiler.parse(input); + return SpacebarsCompiler.codeGen(tree, options); +}; + +SpacebarsCompiler._TemplateTagReplacer = HTML.TransformingVisitor.extend(); +SpacebarsCompiler._TemplateTagReplacer.def({ + visitObject: function (x) { + if (x instanceof HTMLTools.TemplateTag) { + + // Make sure all TemplateTags in attributes have the right + // `.position` set on them. This is a bit of a hack + // (we shouldn't be mutating that here), but it allows + // cleaner codegen of "synthetic" attributes like TEXTAREA's + // "value", where the template tags were originally not + // in an attribute. + if (this.inAttributeValue) + x.position = HTMLTools.TEMPLATE_TAG_POSITION.IN_ATTRIBUTE; + + return this.codegen.codeGenTemplateTag(x); + } + + return HTML.TransformingVisitor.prototype.visitObject.call(this, x); + }, + visitAttributes: function (attrs) { + if (attrs instanceof HTMLTools.TemplateTag) + return this.codegen.codeGenTemplateTag(attrs); + + // call super (e.g. for case where `attrs` is an array) + return HTML.TransformingVisitor.prototype.visitAttributes.call(this, attrs); + }, + visitAttribute: function (name, value, tag) { + this.inAttributeValue = true; + var result = this.visit(value); + this.inAttributeValue = false; + + if (result !== value) { + // some template tags must have been replaced, because otherwise + // we try to keep things `===` when transforming. Wrap the code + // in a function as per the rules. You can't have + // `{id: Blaze.View(...)}` as an attributes dict because the View + // would be rendered more than once; you need to wrap it in a function + // so that it's a different View each time. + return BlazeTools.EmitCode(this.codegen.codeGenBlock(result)); + } + return result; + } +}); + +SpacebarsCompiler.codeGen = function (parseTree, options) { + // is this a template, rather than a block passed to + // a block helper, say + var isTemplate = (options && options.isTemplate); + var isBody = (options && options.isBody); + + var tree = parseTree; + + // The flags `isTemplate` and `isBody` are kind of a hack. + if (isTemplate || isBody) { + // optimizing fragments would require being smarter about whether we are + // in a TEXTAREA, say. + tree = SpacebarsCompiler.optimize(tree); + } + + var codegen = new SpacebarsCompiler.CodeGen; + tree = (new SpacebarsCompiler._TemplateTagReplacer( + {codegen: codegen})).visit(tree); + + var code = '(function () { '; + if (isTemplate || isBody) { + code += 'var view = this; '; + } + code += 'return '; + code += BlazeTools.toJS(tree); + code += '; })'; + + code = SpacebarsCompiler._beautify(code); + + return code; +}; + +SpacebarsCompiler._beautify = function (code) { + if (Package.minifiers && Package.minifiers.UglifyJSMinify) { + var result = UglifyJSMinify(code, + { fromString: true, + mangle: false, + compress: false, + output: { beautify: true, + indent_level: 2, + width: 80 } }); + var output = result.code; + // Uglify interprets our expression as a statement and may add a semicolon. + // Strip trailing semicolon. + output = output.replace(/;$/, ''); + return output; + } else { + // don't actually beautify; no UglifyJS + return code; + } +}; diff --git a/packages/spacebars-compiler/compiler_output_tests.coffee b/packages/spacebars-compiler/compiler_output_tests.coffee new file mode 100644 index 0000000000..3ce1183248 --- /dev/null +++ b/packages/spacebars-compiler/compiler_output_tests.coffee @@ -0,0 +1,253 @@ +coffee.runCompilerOutputTests = (run) -> + run "abc", + """ + function () { + var view = this; + return "abc"; + } + """ + + run "{{foo}}", + """ + function() { + var view = this; + return Blaze.View(function() { + return Spacebars.mustache(view.lookup("foo")); + }); + } + """ + + run "{{foo bar}}", + """ + function() { + var view = this; + return Blaze.View(function() { + return Spacebars.mustache(view.lookup("foo"), + view.lookup("bar")); + }); + } + """ + + run "{{foo x=bar}}", + """ + function() { + var view = this; + return Blaze.View(function() { + return Spacebars.mustache(view.lookup("foo"), Spacebars.kw({ + x: view.lookup("bar") + })); + }); + } + """ + + run "{{foo.bar baz}}", + """ + function() { + var view = this; + return Blaze.View(function() { + return Spacebars.mustache(Spacebars.dot( + view.lookup("foo"), "bar"), + view.lookup("baz")); + }); + } + """ + + run "{{foo bar.baz}}", + """ + function() { + var view = this; + return Blaze.View(function() { + return Spacebars.mustache(view.lookup("foo"), + Spacebars.dot(view.lookup("bar"), "baz")); + }); + } + """ + + run "{{foo x=bar.baz}}", + """ + function() { + var view = this; + return Blaze.View(function() { + return Spacebars.mustache(view.lookup("foo"), Spacebars.kw({ + x: Spacebars.dot(view.lookup("bar"), "baz") + })); + }); + } + """ + + run "{{#foo}}abc{{/foo}}", + """ + function() { + var view = this; + return Spacebars.include(view.lookupTemplate("foo"), (function() { + return "abc"; + })); + } + """ + + run "{{#if cond}}aaa{{else}}bbb{{/if}}", + """ + function() { + var view = this; + return Blaze.If(function () { + return Spacebars.call(view.lookup("cond")); + }, (function() { + return "aaa"; + }), (function() { + return "bbb"; + })); + } + """ + + run "{{!-- --}}{{#if cond}}aaa{{!\n}}{{else}}{{!}}bbb{{!-- --}}{{/if}}{{!}}", + """ +function() { + var view = this; + return Blaze.If(function () { + return Spacebars.call(view.lookup("cond")); + }, (function() { + return "aaa"; + }), (function() { + return "bbb"; + })); +} + """ + + run "{{> foo bar}}", + """ + function() { + var view = this; + return Spacebars.TemplateWith(function() { + return Spacebars.call(view.lookup("bar")); + }, function() { + return Spacebars.include(view.lookupTemplate("foo")); + }); + } + """ + + run "{{> foo x=bar}}", + """ + function() { + var view = this; + return Spacebars.TemplateWith(function() { + return { + x: Spacebars.call(view.lookup("bar")) + }; + }, function() { + return Spacebars.include(view.lookupTemplate("foo")); + }); + } + + """ + + run "{{> foo bar.baz}}", + """ + function() { + var view = this; + return Spacebars.TemplateWith(function() { + return Spacebars.call(Spacebars.dot(view.lookup("bar"), "baz")); + }, function() { + return Spacebars.include(view.lookupTemplate("foo")); + }); + } + """ + + run "{{> foo x=bar.baz}}", + """ + function() { + var view = this; + return Spacebars.TemplateWith(function() { + return { + x: Spacebars.call(Spacebars.dot(view.lookup("bar"), "baz")) + }; + }, function() { + return Spacebars.include(view.lookupTemplate("foo")); + }); + } + """ + + run "{{> foo bar baz}}", + """ + function() { + var view = this; + return Spacebars.TemplateWith(function() { + return Spacebars.dataMustache(view.lookup("bar"), view.lookup("baz")); + }, function() { + return Spacebars.include(view.lookupTemplate("foo")); + }); + } + + """ + + run "{{#foo bar baz}}aaa{{/foo}}", + """ + function() { + var view = this; + return Spacebars.TemplateWith(function() { + return Spacebars.dataMustache(view.lookup("bar"), view.lookup("baz")); + }, function() { + return Spacebars.include(view.lookupTemplate("foo"), (function() { + return "aaa"; + })); + }); + } + """ + + run "{{#foo p.q r.s}}aaa{{/foo}}", + """ + function() { + var view = this; + return Spacebars.TemplateWith(function() { + return Spacebars.dataMustache(Spacebars.dot(view.lookup("p"), "q"), Spacebars.dot(view.lookup("r"), "s")); + }, function() { + return Spacebars.include(view.lookupTemplate("foo"), (function() { + return "aaa"; + })); + }); + } + """ + + run "", + """ + function() { + var view = this; + return HTML.A(HTML.Attrs(function() { + return Spacebars.attrMustache(view.lookup("b")); + })); + } + """ + + run "", + """ + function() { + var view = this; + return HTML.A(HTML.Attrs({ + c: (function() { return [ + "d", + Spacebars.mustache(view.lookup("e")), + "f" ]; }) + }, function() { + return Spacebars.attrMustache(view.lookup("b")); + })); + } + """ + + run "{{foo}}", + """ + function() { + var view = this; + return HTML.getTag("asdf")(Blaze.View(function() { + return Spacebars.mustache(view.lookup("foo")); + })); + } + """ + + run "", + """ + function() { + var view = this; + return HTML.TEXTAREA({value: (function () { + return Spacebars.mustache(view.lookup("foo")); + }) }); + } + """ diff --git a/packages/spacebars-compiler/optimizer.js b/packages/spacebars-compiler/optimizer.js new file mode 100644 index 0000000000..df421763be --- /dev/null +++ b/packages/spacebars-compiler/optimizer.js @@ -0,0 +1,186 @@ +// Optimize parts of an HTMLjs tree into raw HTML strings when they don't +// contain template tags. + +var constant = function (value) { + return function () { return value; }; +}; + +var OPTIMIZABLE = { + NONE: 0, + PARTS: 1, + FULL: 2 +}; + +// We can only turn content into an HTML string if it contains no template +// tags and no "tricky" HTML tags. If we can optimize the entire content +// into a string, we return OPTIMIZABLE.FULL. If the we are given an +// unoptimizable node, we return OPTIMIZABLE.NONE. If we are given a tree +// that contains an unoptimizable node somewhere, we return OPTIMIZABLE.PARTS. +// +// For example, we always create SVG elements programmatically, since SVG +// doesn't have innerHTML. If we are given an SVG element, we return NONE. +// However, if we are given a big tree that contains SVG somewhere, we +// return PARTS so that the optimizer can descend into the tree and optimize +// other parts of it. +var CanOptimizeVisitor = HTML.Visitor.extend(); +CanOptimizeVisitor.def({ + visitNull: constant(OPTIMIZABLE.FULL), + visitPrimitive: constant(OPTIMIZABLE.FULL), + visitComment: constant(OPTIMIZABLE.FULL), + visitCharRef: constant(OPTIMIZABLE.FULL), + visitRaw: constant(OPTIMIZABLE.FULL), + visitObject: constant(OPTIMIZABLE.NONE), + visitFunction: constant(OPTIMIZABLE.NONE), + visitArray: function (x) { + for (var i = 0; i < x.length; i++) + if (this.visit(x[i]) !== OPTIMIZABLE.FULL) + return OPTIMIZABLE.PARTS; + return OPTIMIZABLE.FULL; + }, + visitTag: function (tag) { + var tagName = tag.tagName; + if (tagName === 'textarea') { + // optimizing into a TEXTAREA's RCDATA would require being a little + // more clever. + return OPTIMIZABLE.NONE; + } else if (! (HTML.isKnownElement(tagName) && + ! HTML.isKnownSVGElement(tagName))) { + // foreign elements like SVG can't be stringified for innerHTML. + return OPTIMIZABLE.NONE; + } else if (tagName === 'table') { + // Avoid ever producing HTML containing `...`, because the + // browser will insert a TBODY. If we just `createElement("table")` and + // `createElement("tr")`, on the other hand, no TBODY is necessary + // (assuming IE 8+). + return OPTIMIZABLE.NONE; + } + + var children = tag.children; + for (var i = 0; i < children.length; i++) + if (this.visit(children[i]) !== OPTIMIZABLE.FULL) + return OPTIMIZABLE.PARTS; + + if (this.visitAttributes(tag.attrs) !== OPTIMIZABLE.FULL) + return OPTIMIZABLE.PARTS; + + return OPTIMIZABLE.FULL; + }, + visitAttributes: function (attrs) { + if (attrs) { + var isArray = HTML.isArray(attrs); + for (var i = 0; i < (isArray ? attrs.length : 1); i++) { + var a = (isArray ? attrs[i] : attrs); + if ((typeof a !== 'object') || (a instanceof HTMLTools.TemplateTag)) + return OPTIMIZABLE.PARTS; + for (var k in a) + if (this.visit(a[k]) !== OPTIMIZABLE.FULL) + return OPTIMIZABLE.PARTS; + } + } + return OPTIMIZABLE.FULL; + } +}); + +var getOptimizability = function (content) { + return (new CanOptimizeVisitor).visit(content); +}; + +var toRaw = function (x) { + return HTML.Raw(HTML.toHTML(x)); +}; + +var TreeTransformer = HTML.TransformingVisitor.extend(); +TreeTransformer.def({ + visitAttributes: function (attrs/*, ...*/) { + // pass template tags through by default + if (attrs instanceof HTMLTools.TemplateTag) + return attrs; + + return HTML.TransformingVisitor.prototype.visitAttributes.apply( + this, arguments); + } +}); + +// Replace parts of the HTMLjs tree that have no template tags (or +// tricky HTML tags) with HTML.Raw objects containing raw HTML. +var OptimizingVisitor = TreeTransformer.extend(); +OptimizingVisitor.def({ + visitNull: toRaw, + visitPrimitive: toRaw, + visitComment: toRaw, + visitCharRef: toRaw, + visitArray: function (array) { + var optimizability = getOptimizability(array); + if (optimizability === OPTIMIZABLE.FULL) { + return toRaw(array); + } else if (optimizability === OPTIMIZABLE.PARTS) { + return TreeTransformer.prototype.visitArray.call(this, array); + } else { + return array; + } + }, + visitTag: function (tag) { + var optimizability = getOptimizability(tag); + if (optimizability === OPTIMIZABLE.FULL) { + return toRaw(tag); + } else if (optimizability === OPTIMIZABLE.PARTS) { + return TreeTransformer.prototype.visitTag.call(this, tag); + } else { + return tag; + } + }, + visitChildren: function (children) { + // don't optimize the children array into a Raw object! + return TreeTransformer.prototype.visitArray.call(this, children); + }, + visitAttributes: function (attrs) { + return attrs; + } +}); + +// Combine consecutive HTML.Raws. Remove empty ones. +var RawCompactingVisitor = TreeTransformer.extend(); +RawCompactingVisitor.def({ + visitArray: function (array) { + var result = []; + for (var i = 0; i < array.length; i++) { + var item = array[i]; + if ((item instanceof HTML.Raw) && + ((! item.value) || + (result.length && + (result[result.length - 1] instanceof HTML.Raw)))) { + // two cases: item is an empty Raw, or previous item is + // a Raw as well. In the latter case, replace the previous + // Raw with a longer one that includes the new Raw. + if (item.value) { + result[result.length - 1] = HTML.Raw( + result[result.length - 1].value + item.value); + } + } else { + result.push(item); + } + } + return result; + } +}); + +// Replace pointless Raws like `HTMl.Raw('foo')` that contain no special +// characters with simple strings. +var RawReplacingVisitor = TreeTransformer.extend(); +RawReplacingVisitor.def({ + visitRaw: function (raw) { + var html = raw.value; + if (html.indexOf('&') < 0 && html.indexOf('<') < 0) { + return html; + } else { + return raw; + } + } +}); + +SpacebarsCompiler.optimize = function (tree) { + tree = (new OptimizingVisitor).visit(tree); + tree = (new RawCompactingVisitor).visit(tree); + tree = (new RawReplacingVisitor).visit(tree); + return tree; +}; diff --git a/packages/spacebars-compiler/package.js b/packages/spacebars-compiler/package.js index f5159a9774..2477e7c3c1 100644 --- a/packages/spacebars-compiler/package.js +++ b/packages/spacebars-compiler/package.js @@ -3,26 +3,27 @@ Package.describe({ }); Package.on_use(function (api) { - api.use('spacebars-common'); - api.imply('spacebars-common'); + api.export('SpacebarsCompiler'); - // we attach stuff to the global symbol `HTML`, exported - // by `htmljs` via `html-tools`, so we both use and effectively - // imply it. + api.use('htmljs'); api.use('html-tools'); - api.imply('html-tools'); + api.use('blaze-tools'); api.use('underscore'); api.use('minifiers', ['server']); - api.add_files(['tokens.js', 'tojs.js', 'templatetag.js', - 'spacebars-compiler.js']); + api.add_files(['templatetag.js', + 'optimizer.js', + 'codegen.js', + 'compiler.js']); }); Package.on_test(function (api) { api.use('underscore'); api.use('spacebars-compiler'); api.use('tinytest'); + api.use('blaze-tools'); + api.use('coffeescript'); api.add_files('spacebars_tests.js'); api.add_files('compile_tests.js'); - api.add_files('token_tests.js'); + api.add_files('compiler_output_tests.coffee'); }); diff --git a/packages/spacebars-compiler/spacebars-compiler.js b/packages/spacebars-compiler/spacebars-compiler.js deleted file mode 100644 index a24f2a11ab..0000000000 --- a/packages/spacebars-compiler/spacebars-compiler.js +++ /dev/null @@ -1,544 +0,0 @@ - - - -Spacebars.parse = function (input) { - - var tree = HTMLTools.parseFragment( - input, - { getSpecialTag: TemplateTag.parseCompleteTag }); - - return tree; -}; - -// ============================================================ -// Optimizer for optimizing HTMLjs into raw HTML string when -// it doesn't contain template tags. - -var optimize = function (tree) { - - var pushRawHTML = function (array, html) { - var N = array.length; - if (N > 0 && (array[N-1] instanceof HTML.Raw)) { - array[N-1] = HTML.Raw(array[N-1].value + html); - } else { - array.push(HTML.Raw(html)); - } - }; - - var isPureChars = function (html) { - return (html.indexOf('&') < 0 && html.indexOf('<') < 0); - }; - - // Returns `null` if no specials are found in the array, so that the - // parent can perform the actual optimization. Otherwise, returns - // an array of parts which have been optimized as much as possible. - // `forceOptimize` forces the latter case. - var optimizeArrayParts = function (array, optimizePartsFunc, forceOptimize) { - var result = null; - if (forceOptimize) - result = []; - for (var i = 0, N = array.length; i < N; i++) { - var part = optimizePartsFunc(array[i]); - if (part !== null) { - // something special found - if (result === null) { - // This is our first special item. Stringify the other parts. - result = []; - for (var j = 0; j < i; j++) - pushRawHTML(result, HTML.toHTML(array[j])); - } - result.push(part); - } else { - // just plain HTML found - if (result !== null) { - // we've already found something special, so convert this to Raw - pushRawHTML(result, HTML.toHTML(array[i])); - } - } - } - if (result !== null) { - // clean up unnecessary HTML.Raw wrappers around pure character data - for (var j = 0; j < result.length; j++) { - if ((result[j] instanceof HTML.Raw) && - isPureChars(result[j].value)) - // replace HTML.Raw with simple string - result[j] = result[j].value; - } - } - return result; - }; - - var doesAttributeValueHaveSpecials = function (v) { - if (v instanceof HTMLTools.Special) - return true; - if (typeof v === 'function') - return true; - - if (v instanceof Array) { - for (var i = 0; i < v.length; i++) - if (doesAttributeValueHaveSpecials(v[i])) - return true; - return false; - } - - return false; - }; - - var optimizeParts = function (node) { - // If we have nothing special going on, returns `null` (so that the - // parent can optimize). Otherwise returns a replacement for `node` - // with optimized parts. - if ((node == null) || (typeof node === 'string') || - (node instanceof HTML.CharRef) || (node instanceof HTML.Comment) || - (node instanceof HTML.Raw)) { - // not special; let parent decide how whether to optimize - return null; - } else if (node instanceof HTML.Tag) { - var tagName = node.tagName; - if (tagName === 'textarea' || - (! (HTML.isKnownElement(tagName) && - ! HTML.isKnownSVGElement(tagName)))) { - // optimizing into a TEXTAREA's RCDATA would require being a little - // more clever. foreign elements like SVG can't be stringified for - // innerHTML. - return node; - } - - var mustOptimize = false; - - // Avoid ever producing HTML containing `
    ...`, because the - // browser will insert a TBODY. If we just `createElement("table")` and - // `createElement("tr")`, on the other hand, no TBODY is necessary - // (assuming IE 8+). - if (tagName === 'table') - mustOptimize = true; - - if (node.attrs && ! mustOptimize) { - var attrs = node.attrs; - for (var k in attrs) { - if (doesAttributeValueHaveSpecials(attrs[k])) { - mustOptimize = true; - break; - } - } - } - - var newChildren = optimizeArrayParts(node.children, optimizeParts, mustOptimize); - - if (newChildren === null) - return null; - - var newTag = HTML.getTag(node.tagName).apply(null, newChildren); - newTag.attrs = node.attrs; - - return newTag; - - } else if (node instanceof Array) { - return optimizeArrayParts(node, optimizeParts); - } else { - return node; - } - }; - - var optTree = optimizeParts(tree); - if (optTree !== null) - // tree was optimized in parts - return optTree; - - optTree = HTML.Raw(HTML.toHTML(tree)); - - if (isPureChars(optTree.value)) - return optTree.value; - - return optTree; -}; - -// ============================================================ -// Code-generation of template tags - -var builtInBlockHelpers = { - 'if': 'UI.If', - 'unless': 'UI.Unless', - 'with': 'Spacebars.With', - 'each': 'UI.Each' -}; - -// Some `UI.*` paths are special in that they generate code that -// doesn't folow the normal lookup rules for dotted symbols. The -// following names must be prefixed with `UI.` when you use them in a -// template. -var builtInUIPaths = { - // `template` is a local variable defined in the generated render - // function for the template in which `UI.contentBlock` (or - // `UI.elseBlock`) is invoked. `template` is a reference to the - // template itself. - 'contentBlock': 'template.__content', - 'elseBlock': 'template.__elseContent', - - // `Template` is the global template namespace. If you define a - // template named `foo` in Spacebars, it gets defined as - // `Template.foo` in JavaScript. - 'dynamic': 'Template.__dynamic' -}; - -// A "reserved name" can't be used as a - - - - @@ -279,7 +271,7 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/spacebars-tests/template_tests.js b/packages/spacebars-tests/template_tests.js index b67a15f18a..ef707e1391 100644 --- a/packages/spacebars-tests/template_tests.js +++ b/packages/spacebars-tests/template_tests.js @@ -19,7 +19,7 @@ var clickIt = function (elem) { clickElement(elem); }; -Tinytest.add("spacebars - templates - simple helper", function (test) { +Tinytest.add("spacebars-tests - template_tests - simple helper", function (test) { var tmpl = Template.spacebars_template_test_simple_helper; var R = ReactiveVar(1); tmpl.foo = function (x) { @@ -42,11 +42,9 @@ Tinytest.add("spacebars - templates - simple helper", function (test) { }); delete tmpl.foo; - // We'd like this to throw, but it doesn't because of how self.lookup - // works. D'oh. Fix this as part of "new this". - //test.throws(function () { + test.throws(function () { renderToDiv(tmpl); - //}); + }); tmpl.foo = function () {}; // doesn't throw @@ -54,7 +52,7 @@ Tinytest.add("spacebars - templates - simple helper", function (test) { test.equal(canonicalizeHtml(div.innerHTML), ''); }); -Tinytest.add("spacebars - templates - dynamic template", function (test) { +Tinytest.add("spacebars-tests - template_tests - dynamic template", function (test) { var tmpl = Template.spacebars_template_test_dynamic_template; var aaa = Template.spacebars_template_test_aaa; var bbb = Template.spacebars_template_test_bbb; @@ -71,7 +69,7 @@ Tinytest.add("spacebars - templates - dynamic template", function (test) { test.equal(canonicalizeHtml(div.innerHTML), "bbb"); }); -Tinytest.add("spacebars - templates - interpolate attribute", function (test) { +Tinytest.add("spacebars-tests - template_tests - interpolate attribute", function (test) { var tmpl = Template.spacebars_template_test_interpolate_attribute; tmpl.foo = function (x) { return x+1; @@ -84,7 +82,7 @@ Tinytest.add("spacebars - templates - interpolate attribute", function (test) { test.equal($(div).find('div')[0].className, "aaa124zzz"); }); -Tinytest.add("spacebars - templates - dynamic attrs", function (test) { +Tinytest.add("spacebars-tests - template_tests - dynamic attrs", function (test) { var tmpl = Template.spacebars_template_test_dynamic_attrs; var R2 = ReactiveVar({x: "X"}); @@ -108,7 +106,7 @@ Tinytest.add("spacebars - templates - dynamic attrs", function (test) { test.equal(span.getAttribute('z'), 'Z'); }); -Tinytest.add("spacebars - templates - triple", function (test) { +Tinytest.add("spacebars-tests - template_tests - triple", function (test) { var tmpl = Template.spacebars_template_test_triple; var R = ReactiveVar('blah'); @@ -145,7 +143,7 @@ Tinytest.add("spacebars - templates - triple", function (test) { test.equal(canonicalizeHtml(div.innerHTML), 'xy'); }); -Tinytest.add("spacebars - templates - inclusion args", function (test) { +Tinytest.add("spacebars-tests - template_tests - inclusion args", function (test) { var tmpl = Template.spacebars_template_test_inclusion_args; var R = ReactiveVar(Template.spacebars_template_test_aaa); @@ -184,7 +182,7 @@ Tinytest.add("spacebars - templates - inclusion args", function (test) { test.isTrue(span1 === span2); }); -Tinytest.add("spacebars - templates - inclusion args 2", function (test) { +Tinytest.add("spacebars-tests - template_tests - inclusion args 2", function (test) { // `{{> foo bar q=baz}}` var tmpl = Template.spacebars_template_test_inclusion_args2; @@ -205,21 +203,30 @@ Tinytest.add("spacebars - templates - inclusion args 2", function (test) { test.isTrue(span1 === span2); }); -Tinytest.add("spacebars - templates - inclusion dotted args", function (test) { +// maybe use created callback on the template instead of this? +var extendTemplateWithInit = function (template, initFunc) { + return Template.__create__( + template.__viewName+'-extended', + template.__render, + initFunc); +}; + +Tinytest.add("spacebars-tests - template_tests - inclusion dotted args", function (test) { // `{{> foo bar.baz}}` var tmpl = Template.spacebars_template_test_inclusion_dotted_args; var initCount = 0; - tmpl.foo = Template.spacebars_template_test_bracketed_this.extend({ - init: function () { initCount++; } - }); + tmpl.foo = extendTemplateWithInit( + Template.spacebars_template_test_bracketed_this, + function () { initCount++; }); + var R = ReactiveVar('david'); tmpl.bar = function () { // make sure `this` is bound correctly return { baz: this.symbol + R.get() }; }; - var div = renderToDiv(tmpl.extend({data: {symbol:'%'}})); + var div = renderToDiv(tmpl, {symbol:'%'}); test.equal(initCount, 1); test.equal(canonicalizeHtml(div.innerHTML), '[%david]'); @@ -231,26 +238,26 @@ Tinytest.add("spacebars - templates - inclusion dotted args", function (test) { test.equal(initCount, 1); }); -Tinytest.add("spacebars - templates - inclusion slashed args", function (test) { +Tinytest.add("spacebars-tests - template_tests - inclusion slashed args", function (test) { // `{{> foo bar/baz}}` var tmpl = Template.spacebars_template_test_inclusion_dotted_args; var initCount = 0; - tmpl.foo = Template.spacebars_template_test_bracketed_this.extend({ - init: function () { initCount++; } - }); + tmpl.foo = extendTemplateWithInit( + Template.spacebars_template_test_bracketed_this, + function () { initCount++; }); var R = ReactiveVar('david'); tmpl.bar = function () { // make sure `this` is bound correctly return { baz: this.symbol + R.get() }; }; - var div = renderToDiv(tmpl.extend({data: {symbol:'%'}})); + var div = renderToDiv(tmpl, {symbol:'%'}); test.equal(initCount, 1); test.equal(canonicalizeHtml(div.innerHTML), '[%david]'); }); -Tinytest.add("spacebars - templates - block helper", function (test) { +Tinytest.add("spacebars-tests - template_tests - block helper", function (test) { // test the case where `foo` is a calculated template that changes // reactively. // `{{#foo}}bar{{else}}baz{{/foo}}` @@ -267,7 +274,7 @@ Tinytest.add("spacebars - templates - block helper", function (test) { test.equal(canonicalizeHtml(div.innerHTML), "baz"); }); -Tinytest.add("spacebars - templates - block helper function with one string arg", function (test) { +Tinytest.add("spacebars-tests - template_tests - block helper function with one string arg", function (test) { // `{{#foo "bar"}}content{{/foo}}` var tmpl = Template.spacebars_template_test_block_helper_function_one_string_arg; tmpl.foo = function () { @@ -280,7 +287,7 @@ Tinytest.add("spacebars - templates - block helper function with one string arg" test.equal(canonicalizeHtml(div.innerHTML), "content"); }); -Tinytest.add("spacebars - templates - block helper function with one helper arg", function (test) { +Tinytest.add("spacebars-tests - template_tests - block helper function with one helper arg", function (test) { var tmpl = Template.spacebars_template_test_block_helper_function_one_helper_arg; var R = ReactiveVar("bar"); tmpl.bar = function () { return R.get(); }; @@ -298,7 +305,7 @@ Tinytest.add("spacebars - templates - block helper function with one helper arg" test.equal(canonicalizeHtml(div.innerHTML), ""); }); -Tinytest.add("spacebars - templates - block helper component with one helper arg", function (test) { +Tinytest.add("spacebars-tests - template_tests - block helper component with one helper arg", function (test) { var tmpl = Template.spacebars_template_test_block_helper_component_one_helper_arg; var R = ReactiveVar(true); tmpl.bar = function () { return R.get(); }; @@ -310,7 +317,7 @@ Tinytest.add("spacebars - templates - block helper component with one helper arg test.equal(canonicalizeHtml(div.innerHTML), ""); }); -Tinytest.add("spacebars - templates - block helper component with three helper args", function (test) { +Tinytest.add("spacebars-tests - template_tests - block helper component with three helper args", function (test) { var tmpl = Template.spacebars_template_test_block_helper_component_three_helper_args; var R = ReactiveVar("bar"); tmpl.bar_or_baz = function () { @@ -327,16 +334,16 @@ Tinytest.add("spacebars - templates - block helper component with three helper a test.equal(canonicalizeHtml(div.innerHTML), ""); }); -Tinytest.add("spacebars - templates - block helper with dotted arg", function (test) { +Tinytest.add("spacebars-tests - template_tests - block helper with dotted arg", function (test) { var tmpl = Template.spacebars_template_test_block_helper_dotted_arg; var R1 = ReactiveVar(1); var R2 = ReactiveVar(10); var R3 = ReactiveVar(100); var initCount = 0; - tmpl.foo = Template.spacebars_template_test_bracketed_this.extend({ - init: function () { initCount++; } - }); + tmpl.foo = extendTemplateWithInit( + Template.spacebars_template_test_bracketed_this, + function () { initCount++; }); tmpl.bar = function () { return { r1: R1.get(), @@ -382,7 +389,7 @@ Tinytest.add("spacebars - templates - block helper with dotted arg", function (t test.equal(initCount, 1); }); -Tinytest.add("spacebars - templates - nested content", function (test) { +Tinytest.add("spacebars-tests - template_tests - nested content", function (test) { // Test that `{{> UI.contentBlock}}` in an `{{#if}}` works. // ``` @@ -433,7 +440,7 @@ Tinytest.add("spacebars - templates - nested content", function (test) { test.equal(canonicalizeHtml(div.innerHTML), 'hello'); }); -Tinytest.add("spacebars - template - if", function (test) { +Tinytest.add("spacebars-tests - template_tests - if", function (test) { var tmpl = Template.spacebars_template_test_if; var R = ReactiveVar(true); tmpl.foo = function () { @@ -450,7 +457,7 @@ Tinytest.add("spacebars - template - if", function (test) { rendersTo("2"); }); -Tinytest.add("spacebars - template - if in with", function (test) { +Tinytest.add("spacebars-tests - template_tests - if in with", function (test) { var tmpl = Template.spacebars_template_test_if_in_with; tmpl.foo = {bar: "bar"}; @@ -458,7 +465,7 @@ Tinytest.add("spacebars - template - if in with", function (test) { divRendersTo(test, div, "bar bar"); }); -Tinytest.add("spacebars - templates - each on cursor", function (test) { +Tinytest.add("spacebars-tests - template_tests - each on cursor", function (test) { var tmpl = Template.spacebars_template_test_each; var coll = new Meteor.Collection(null); tmpl.items = function () { @@ -481,7 +488,7 @@ Tinytest.add("spacebars - templates - each on cursor", function (test) { rendersTo("else-clause"); }); -Tinytest.add("spacebars - templates - each on array", function (test) { +Tinytest.add("spacebars-tests - template_tests - each on array", function (test) { var tmpl = Template.spacebars_template_test_each; var R = new ReactiveVar([]); tmpl.items = function () { @@ -509,7 +516,7 @@ Tinytest.add("spacebars - templates - each on array", function (test) { rendersTo("else-clause"); }); -Tinytest.add("spacebars - templates - ..", function (test) { +Tinytest.add("spacebars-tests - template_tests - ..", function (test) { var tmpl = Template.spacebars_template_test_dots; Template.spacebars_template_test_dots_subtemplate.getTitle = function (from) { return from.title; @@ -528,7 +535,7 @@ Tinytest.add("spacebars - templates - ..", function (test) { "TITLE", "1bar", "2bar", "3item", "4bar", "GETTITLE", "5bar", "6item", "7bar"].join(" ")); }); -Tinytest.add("spacebars - templates - select tags", function (test) { +Tinytest.add("spacebars-tests - template_tests - select tags", function (test) { var tmpl = Template.spacebars_template_test_select_tag; // {label: (string)} @@ -641,7 +648,7 @@ Tinytest.add("spacebars - templates - select tags", function (test) { test.equal($(selectEl).find('option')[1].selected, true); }); -Tinytest.add('spacebars - templates - {{#with}} falsy; issue #770', function (test) { +Tinytest.add('spacebars-tests - template_tests - {{#with}} falsy; issue #770', function (test) { Template.test_template_issue770.value1 = function () { return "abc"; }; Template.test_template_issue770.value2 = function () { return false; }; var div = renderToDiv(Template.test_template_issue770); @@ -649,7 +656,7 @@ Tinytest.add('spacebars - templates - {{#with}} falsy; issue #770', function (te "abc xxx abc"); }); -Tinytest.add("spacebars - templates - tricky attrs", function (test) { +Tinytest.add("spacebars-tests - template_tests - tricky attrs", function (test) { var tmpl = Template.spacebars_template_test_tricky_attrs; tmpl.theType = function () { return 'text'; }; var R = ReactiveVar('foo'); @@ -666,7 +673,7 @@ Tinytest.add("spacebars - templates - tricky attrs", function (test) { }); -Tinytest.add('spacebars - templates - no data context', function (test) { +Tinytest.add('spacebars-tests - template_tests - no data context', function (test) { var tmpl = Template.spacebars_template_test_no_data; // failure is if an exception is thrown here @@ -674,27 +681,7 @@ Tinytest.add('spacebars - templates - no data context', function (test) { test.equal(canonicalizeHtml(div.innerHTML), 'asdf'); }); -// test that #isolate is a no-op, for back compat -Tinytest.add('spacebars - templates - isolate', function (test) { - var tmpl = Template.spacebars_template_test_isolate; - - Meteor._suppress_log(1); // we print a deprecation notice - var div = renderToDiv(tmpl); - test.equal(canonicalizeHtml(div.innerHTML), 'hello'); - -}); - -// test that #constant is a no-op, for back compat -Tinytest.add('spacebars - templates - constant', function (test) { - var tmpl = Template.spacebars_template_test_constant; - - Meteor._suppress_log(1); // we print a deprecation notice - var div = renderToDiv(tmpl); - test.equal(canonicalizeHtml(div.innerHTML), 'hello'); - -}); - -Tinytest.add('spacebars - templates - textarea', function (test) { +Tinytest.add('spacebars-tests - template_tests - textarea', function (test) { var tmpl = Template.spacebars_template_test_textarea; var R = ReactiveVar('hello'); @@ -713,7 +700,7 @@ Tinytest.add('spacebars - templates - textarea', function (test) { }); -Tinytest.add('spacebars - templates - textarea 2', function (test) { +Tinytest.add('spacebars-tests - template_tests - textarea 2', function (test) { var tmpl = Template.spacebars_template_test_textarea2; var R = ReactiveVar(true); @@ -736,7 +723,7 @@ Tinytest.add('spacebars - templates - textarea 2', function (test) { }); -Tinytest.add('spacebars - templates - textarea each', function (test) { +Tinytest.add('spacebars-tests - template_tests - textarea each', function (test) { var tmpl = Template.spacebars_template_test_textarea_each; var R = ReactiveVar(['APPLE', 'BANANA']); @@ -766,7 +753,7 @@ Tinytest.add('spacebars - templates - textarea each', function (test) { // `Meteor.defer` inside a method stub (see // packages/meteor/timers.js). This test verifies that rendered // callbacks don't fire synchronously as part of a method stub. -testAsyncMulti('spacebars - template - defer in rendered callbacks', [function (test, expect) { +testAsyncMulti('spacebars-tests - template_tests - defer in rendered callbacks', [function (test, expect) { var tmpl = Template.spacebars_template_test_defer_in_rendered; var coll = new Meteor.Collection(null); @@ -797,7 +784,7 @@ testAsyncMulti('spacebars - template - defer in rendered callbacks', [function ( Meteor.call("spacebarsTestInsertEmptyObject"); }]); -testAsyncMulti('spacebars - template - rendered template is DOM in rendered callbacks', [ +testAsyncMulti('spacebars-tests - template_tests - rendered template is DOM in rendered callbacks', [ function (test, expect) { var tmpl = Template.spacebars_template_test_aaa; tmpl.rendered = expect(function () { @@ -817,7 +804,7 @@ testAsyncMulti('spacebars - template - rendered template is DOM in rendered call // ``` // // ... we run `someData` once even if `foo` re-renders. -Tinytest.add('spacebars - templates - with someData', function (test) { +Tinytest.add('spacebars-tests - template_tests - with someData', function (test) { var tmpl = Template.spacebars_template_test_with_someData; var foo = ReactiveVar('AAA'); @@ -850,7 +837,7 @@ Tinytest.add('spacebars - templates - with someData', function (test) { test.equal(canonicalizeHtml(div.innerHTML), 'CCC YO'); }); -Tinytest.add('spacebars - template - #each stops when rendered element is removed', function (test) { +Tinytest.add('spacebars-tests - template_tests - #each stops when rendered element is removed', function (test) { var tmpl = Template.spacebars_template_test_each_stops; var coll = new Meteor.Collection(null); coll.insert({}); @@ -868,7 +855,7 @@ Tinytest.add('spacebars - template - #each stops when rendered element is remove divRendersTo(test, div, 'x'); }); -Tinytest.add('spacebars - templates - block helpers in attribute', function (test) { +Tinytest.add('spacebars-tests - template_tests - block helpers in attribute', function (test) { var tmpl = Template.spacebars_template_test_block_helpers_in_attribute; var coll = new Meteor.Collection(null); @@ -889,12 +876,12 @@ Tinytest.add('spacebars - templates - block helpers in attribute', function (tes var shouldBe = function (className) { Deps.flush(); - test.equal(div.innerHTML, "Hello"); + test.equal(div.innerHTML, "Smurf"); test.equal(div.className, className); var result = canonicalizeHtml(containerDiv.innerHTML); - if (result === '
    Hello
    ') - result = '
    Hello
    '; // e.g. IE 9 and 10 - test.equal(result, '
    Hello
    '); + if (result === '
    Smurf
    ') + result = '
    Smurf
    '; // e.g. IE 9 and 10 + test.equal(result, '
    Smurf
    '); }; shouldBe('donut frankfurter noodle'); @@ -910,7 +897,7 @@ Tinytest.add('spacebars - templates - block helpers in attribute', function (tes shouldBe('bubblegum'); }); -Tinytest.add('spacebars - templates - block helpers in attribute 2', function (test) { +Tinytest.add('spacebars-tests - template_tests - block helpers in attribute 2', function (test) { var tmpl = Template.spacebars_template_test_block_helpers_in_attribute_2; var R = ReactiveVar(true); @@ -930,7 +917,7 @@ Tinytest.add('spacebars - templates - block helpers in attribute 2', function (t // Test that if the argument to #each is a constant, it doesn't establish a // dependency on the data context, so when the context changes, items of // the #each are not "changed" and helpers do not rerun. -Tinytest.add('spacebars - templates - constant #each argument', function (test) { +Tinytest.add('spacebars-tests - template_tests - constant #each argument', function (test) { var tmpl = Template.spacebars_template_test_constant_each_argument; var justReturnRuns = 0; // how many times `justReturn` is called @@ -959,7 +946,7 @@ Tinytest.add('spacebars - templates - constant #each argument', function (test) 'foo bar 2'); }); -Tinytest.addAsync('spacebars - templates - #markdown - basic', function (test, onComplete) { +Tinytest.addAsync('spacebars-tests - template_tests - #markdown - basic', function (test, onComplete) { var tmpl = Template.spacebars_template_test_markdown_basic; tmpl.obj = {snippet: "hi"}; tmpl.hi = function () { @@ -975,7 +962,7 @@ Tinytest.addAsync('spacebars - templates - #markdown - basic', function (test, o }); }); -testAsyncMulti('spacebars - templates - #markdown - if', [ +testAsyncMulti('spacebars-tests - template_tests - #markdown - if', [ function (test, expect) { var self = this; Meteor.call("getAsset", "markdown_if1.html", expect(function (err, html) { @@ -1002,7 +989,7 @@ testAsyncMulti('spacebars - templates - #markdown - if', [ } ]); -testAsyncMulti('spacebars - templates - #markdown - each', [ +testAsyncMulti('spacebars-tests - template_tests - #markdown - each', [ function (test, expect) { var self = this; Meteor.call("getAsset", "markdown_each1.html", expect(function (err, html) { @@ -1030,7 +1017,7 @@ testAsyncMulti('spacebars - templates - #markdown - each', [ } ]); -Tinytest.add('spacebars - templates - #markdown - inclusion', function (test) { +Tinytest.add('spacebars-tests - template_tests - #markdown - inclusion', function (test) { var tmpl = Template.spacebars_template_test_markdown_inclusion; var subtmpl = Template.spacebars_template_test_markdown_inclusion_subtmpl; subtmpl.foo = "bar"; @@ -1038,7 +1025,7 @@ Tinytest.add('spacebars - templates - #markdown - inclusion', function (test) { test.equal(canonicalizeHtml(div.innerHTML), "

    Foo is bar.

    "); }); -Tinytest.add('spacebars - templates - #markdown - block helpers', function (test) { +Tinytest.add('spacebars-tests - template_tests - #markdown - block helpers', function (test) { var tmpl = Template.spacebars_template_test_markdown_block_helpers; var div = renderToDiv(tmpl); test.equal(canonicalizeHtml(div.innerHTML), "

    Hi there!

    "); @@ -1047,7 +1034,7 @@ Tinytest.add('spacebars - templates - #markdown - block helpers', function (test // Test that when a simple helper re-runs due to a dependency changing // but the return value is the same, the DOM text node is not // re-rendered. -Tinytest.add('spacebars - templates - simple helpers are isolated', function (test) { +Tinytest.add('spacebars-tests - template_tests - simple helpers are isolated', function (test) { var runs = [{ helper: function () { return "foo"; }, nodeValue: "foo" @@ -1083,7 +1070,7 @@ Tinytest.add('spacebars - templates - simple helpers are isolated', function (te // Test that when a helper in an element attribute re-runs due to a // dependency changing but the return value is the same, the attribute // value is not set. -Tinytest.add('spacebars - templates - attribute helpers are isolated', function (test) { +Tinytest.add('spacebars-tests - template_tests - attribute helpers are isolated', function (test) { var tmpl = Template.spacebars_template_test_attr_helpers_are_isolated; var dep = new Deps.Dependency; tmpl.foo = function () { @@ -1107,7 +1094,7 @@ Tinytest.add('spacebars - templates - attribute helpers are isolated', function // `

    `. When it re-runs due to a dependency changing the // value for a given attribute might stay the same. Test that the // attribute is not set on the DOM element. -Tinytest.add('spacebars - templates - attribute object helpers are isolated', function (test) { +Tinytest.add('spacebars-tests - template_tests - attribute object helpers are isolated', function (test) { var tmpl = Template.spacebars_template_test_attr_object_helpers_are_isolated; var dep = new Deps.Dependency; tmpl.attrs = function () { @@ -1133,25 +1120,27 @@ Tinytest.add('spacebars - templates - attribute object helpers are isolated', fu // // Also, verify that an error is thrown if the return value from such // a helper is not a component. -Tinytest.add('spacebars - templates - inclusion helpers are isolated', function (test) { +Tinytest.add('spacebars-tests - template_tests - inclusion helpers are isolated', function (test) { var tmpl = Template.spacebars_template_test_inclusion_helpers_are_isolated; var dep = new Deps.Dependency; - var subtmpl = Template. - spacebars_template_test_inclusion_helpers_are_isolated_subtemplate - .extend({}); // fresh instance - var R = new ReactiveVar(subtmpl); + var subtmpl = Template.spacebars_template_test_inclusion_helpers_are_isolated_subtemplate; + // make a copy so we can set "rendered" without mutating the original + var subtmplCopy = Template.__create__( + subtmpl.__viewName, + subtmpl.__render); + var R = new ReactiveVar(subtmplCopy); tmpl.foo = function () { dep.depend(); return R.get(); }; var div = renderToDiv(tmpl); - subtmpl.rendered = function () { + subtmplCopy.rendered = function () { test.fail("shouldn't re-render when same value returned from helper"); }; dep.changed(); - Deps.flush({_throwFirstError: true}); // `subtmpl.rendered` not called + Deps.flush({_throwFirstError: true}); // `subtmplCopy.rendered` not called R.set(null); Deps.flush({_throwFirstError: true}); // no error thrown @@ -1160,10 +1149,10 @@ Tinytest.add('spacebars - templates - inclusion helpers are isolated', function test.throws(function () { Deps.flush({_throwFirstError: true}); - }, /Expected null or template/); + }, /Expected template or null/); }); -Tinytest.add('spacebars - templates - nully attributes', function (test) { +Tinytest.add('spacebars-tests - template_tests - nully attributes', function (test) { var tmpls = { 0: Template.spacebars_template_test_nully_attributes0, 1: Template.spacebars_template_test_nully_attributes1, @@ -1175,9 +1164,7 @@ Tinytest.add('spacebars - templates - nully attributes', function (test) { }; var run = function (whichTemplate, data, expectTrue) { - var templateWithData = tmpls[whichTemplate].extend({data: function () { - return data; }}); - var div = renderToDiv(templateWithData); + var div = renderToDiv(tmpls[whichTemplate], data); var input = div.querySelector('input'); var descr = JSON.stringify([whichTemplate, data, expectTrue]); if (expectTrue) { @@ -1188,7 +1175,10 @@ Tinytest.add('spacebars - templates - nully attributes', function (test) { test.equal(JSON.stringify(input.getAttribute('stuff')), 'null', descr); } - var html = HTML.toHTML(templateWithData); + var html = Blaze.toHTML(Blaze.With(data, function () { + return tmpls[whichTemplate]; + })); + test.equal(/ checked="[^"]*"/.test(html), !! expectTrue); test.equal(/ stuff="[^"]*"/.test(html), !! expectTrue); }; @@ -1226,7 +1216,7 @@ Tinytest.add('spacebars - templates - nully attributes', function (test) { run(3, {foo: false}, false); }); -Tinytest.add("spacebars - templates - double", function (test) { +Tinytest.add("spacebars-tests - template_tests - double", function (test) { var tmpl = Template.spacebars_template_test_double; var run = function (foo, expectedResult) { @@ -1244,11 +1234,11 @@ Tinytest.add("spacebars - templates - double", function (test) { run(undefined, ''); }); -Tinytest.add("spacebars - templates - inclusion lookup order", function (test) { +Tinytest.add("spacebars-tests - template_tests - inclusion lookup order", function (test) { // test that {{> foo}} looks for a helper named 'foo', then a // template named 'foo', then a 'foo' field in the data context. var tmpl = Template.spacebars_template_test_inclusion_lookup; - tmpl.data = function () { + var tmplData = function () { return { // shouldn't have an effect since we define a helper with the // same name. @@ -1261,12 +1251,12 @@ Tinytest.add("spacebars - templates - inclusion lookup order", function (test) { tmpl.spacebars_template_test_inclusion_lookup_subtmpl = Template.spacebars_template_test_inclusion_lookup_subtmpl2; - test.equal(canonicalizeHtml(renderToDiv(tmpl).innerHTML), + test.equal(canonicalizeHtml(renderToDiv(tmpl, tmplData).innerHTML), ["This is generated by a helper with the same name.", "This is a template passed in the data context."].join(' ')); }); -Tinytest.add("spacebars - templates - content context", function (test) { +Tinytest.add("spacebars-tests - template_tests - content context", function (test) { var tmpl = Template.spacebars_template_test_content_context; var R = ReactiveVar(true); tmpl.foo = { @@ -1288,7 +1278,7 @@ Tinytest.add("spacebars - templates - content context", function (test) { _.each(['textarea', 'text', 'password', 'submit', 'button', 'reset', 'select', 'hidden'], function (type) { - Tinytest.add("spacebars - controls - " + type, function(test) { + Tinytest.add("spacebars-tests - template_tests - controls - " + type, function(test) { var R = ReactiveVar({x:"test"}); var R2 = ReactiveVar(""); var tmpl; @@ -1371,7 +1361,7 @@ _.each(['textarea', 'text', 'password', 'submit', 'button', }); }); -Tinytest.add("spacebars - controls - radio", function(test) { +Tinytest.add("spacebars-tests - template_tests - radio", function(test) { var R = ReactiveVar(""); var R2 = ReactiveVar(""); var change_buf = []; @@ -1442,7 +1432,7 @@ Tinytest.add("spacebars - controls - radio", function(test) { document.body.removeChild(div); }); -Tinytest.add("spacebars - controls - checkbox", function(test) { +Tinytest.add("spacebars-tests - template_tests - checkbox", function(test) { var tmpl = Template.spacebars_test_control_checkbox; tmpl.labels = ["Foo", "Bar", "Baz"]; var Rs = {}; @@ -1504,13 +1494,13 @@ Tinytest.add("spacebars - controls - checkbox", function(test) { document.body.removeChild(div); }); -Tinytest.add('spacebars - template - unfound template', function (test) { +Tinytest.add('spacebars-tests - template_tests - unfound template', function (test) { test.throws(function () { renderToDiv(Template.spacebars_test_nonexistent_template); - }, /Can't find template/); + }, /No such template/); }); -Tinytest.add('spacebars - template - helper passed to #if called exactly once when invalidated', function (test) { +Tinytest.add('spacebars-tests - template_tests - helper passed to #if called exactly once when invalidated', function (test) { var tmpl = Template.spacebars_test_if_helper; var count = 0; @@ -1532,7 +1522,7 @@ Tinytest.add('spacebars - template - helper passed to #if called exactly once wh test.equal(count, 2); }); -Tinytest.add('spacebars - template - custom block helper functions called exactly once when invalidated', function (test) { +Tinytest.add('spacebars-tests - template_tests - custom block helper functions called exactly once when invalidated', function (test) { var tmpl = Template.spacebars_test_block_helper_function; var count = 0; @@ -1540,7 +1530,7 @@ Tinytest.add('spacebars - template - custom block helper functions called exactl tmpl.foo = function () { d.depend(); count++; - return UI.block(function () { return []; }); + return Template.spacebars_template_test_aaa; }; foo = false; @@ -1606,32 +1596,32 @@ var runOneTwoTest = function (test, subTemplateName, optionsData) { }); }; -Tinytest.add('spacebars - template - with stops without re-running helper', function (test) { +Tinytest.add('spacebars-tests - template_tests - with stops without re-running helper', function (test) { runOneTwoTest(test, 'spacebars_test_helpers_stop_with'); }); -Tinytest.add('spacebars - template - each stops without re-running helper', function (test) { +Tinytest.add('spacebars-tests - template_tests - each stops without re-running helper', function (test) { runOneTwoTest(test, 'spacebars_test_helpers_stop_each'); }); -Tinytest.add('spacebars - template - each inside with stops without re-running helper', function (test) { +Tinytest.add('spacebars-tests - template_tests - each inside with stops without re-running helper', function (test) { runOneTwoTest(test, 'spacebars_test_helpers_stop_with_each'); }); -Tinytest.add('spacebars - template - if stops without re-running helper', function (test) { +Tinytest.add('spacebars-tests - template_tests - if stops without re-running helper', function (test) { runOneTwoTest(test, 'spacebars_test_helpers_stop_if', ['a', 'b', 'a']); }); -Tinytest.add('spacebars - template - unless stops without re-running helper', function (test) { +Tinytest.add('spacebars-tests - template_tests - unless stops without re-running helper', function (test) { runOneTwoTest(test, 'spacebars_test_helpers_stop_unless', ['a', 'b', 'a']); }); -Tinytest.add('spacebars - template - inclusion stops without re-running function', function (test) { +Tinytest.add('spacebars-tests - template_tests - inclusion stops without re-running function', function (test) { var t = Template.spacebars_test_helpers_stop_inclusion3; runOneTwoTest(test, 'spacebars_test_helpers_stop_inclusion', [t, t, t]); }); -Tinytest.add('spacebars - template - template with callbacks inside with stops without recalculating data', function (test) { +Tinytest.add('spacebars-tests - template_tests - template with callbacks inside with stops without recalculating data', function (test) { var tmpl = Template.spacebars_test_helpers_stop_with_callbacks3; tmpl.created = function () {}; tmpl.rendered = function () {}; @@ -1639,7 +1629,7 @@ Tinytest.add('spacebars - template - template with callbacks inside with stops w runOneTwoTest(test, 'spacebars_test_helpers_stop_with_callbacks'); }); -Tinytest.add('spacebars - template - no data context is seen as an empty object', function (test) { +Tinytest.add('spacebars-tests - template_tests - no data context is seen as an empty object', function (test) { var tmpl = Template.spacebars_test_no_data_context; var dataInHelper = 'UNSET'; @@ -1681,7 +1671,7 @@ Tinytest.add('spacebars - template - no data context is seen as an empty object' test.equal(dataInEvent, {}); }); -Tinytest.add('spacebars - template - falsy with', function (test) { +Tinytest.add('spacebars-tests - template_tests - falsy with', function (test) { var tmpl = Template.spacebars_test_falsy_with; var R = ReactiveVar(null); tmpl.obj = function () { return R.get(); }; @@ -1699,7 +1689,7 @@ Tinytest.add('spacebars - template - falsy with', function (test) { divRendersTo(test, div, "alpha"); }); -Tinytest.add("spacebars - template - helpers don't leak", function (test) { +Tinytest.add("spacebars-tests - template_tests - helpers don't leak", function (test) { var tmpl = Template.spacebars_test_helpers_dont_leak; tmpl.foo = "wrong"; tmpl.bar = function () { return "WRONG"; }; @@ -1713,8 +1703,7 @@ Tinytest.add("spacebars - template - helpers don't leak", function (test) { divRendersTo(test, div, "correct BONUS"); }); -Tinytest.add( - "spacebars - template - event handler returns false", +Tinytest.add("spacebars-tests - template_tests - event handler returns false", function (test) { var tmpl = Template.spacebars_test_event_returns_false; var elemId = "spacebars_test_event_returns_false_link"; @@ -1725,6 +1714,9 @@ Tinytest.add( var div = renderToDiv(tmpl); document.body.appendChild(div); clickIt(document.getElementById(elemId)); + // NOTE: This failure can stick across test runs! Try + // removing '#bad-url' from the location bar and run + // the tests again. :) test.isFalse(/#bad-url/.test(window.location.hash)); document.body.removeChild(div); } @@ -1735,7 +1727,7 @@ Tinytest.add( // `$(elem).find(...)` works this way, but the browser's // querySelector doesn't. Tinytest.add( - "spacebars - template - event map selector scope", + "spacebars-tests - template_tests - event map selector scope", function (test) { var tmpl = Template.spacebars_test_event_selectors1; var tmpl2 = Template.spacebars_test_event_selectors2; @@ -1761,7 +1753,7 @@ if (document.addEventListener) { // nice to get rid of the network dependency, though.) // We skip this test in IE 8. Tinytest.add( - "spacebars - template - event map selector scope (capturing)", + "spacebars-tests - template_tests - event map selector scope (capturing)", function (test) { var tmpl = Template.spacebars_test_event_selectors_capturing1; var tmpl2 = Template.spacebars_test_event_selectors_capturing2; @@ -1784,7 +1776,7 @@ if (document.addEventListener) { ); } -Tinytest.add("spacebars - template - tables", function (test) { +Tinytest.add("spacebars-tests - template_tests - tables", function (test) { var tmpl1 = Template.spacebars_test_tables1; var div = renderToDiv(tmpl1); @@ -1800,8 +1792,7 @@ Tinytest.add("spacebars - template - tables", function (test) { divRendersTo(test, div, '

    Foo
    '); }); -Tinytest.add( - "spacebars - template - jQuery.trigger extraParameters are passed to the event callback", +Tinytest.add("spacebars-tests - template_tests - jQuery.trigger extraParameters are passed to the event callback", function (test) { var tmpl = Template.spacebars_test_jquery_events; var captured = false; @@ -1828,7 +1819,7 @@ Tinytest.add( } ); -Tinytest.add("spacebars - template - UI.toHTML", function (test) { +Tinytest.add("spacebars-tests - template_tests - toHTML", function (test) { // run once, verifying that autoruns are stopped var once = function (tmplToRender, tmplForHelper, helper, val) { var count = 0; @@ -1840,7 +1831,7 @@ Tinytest.add("spacebars - template - UI.toHTML", function (test) { R.set(val); tmplForHelper[helper] = getR; - test.equal(canonicalizeHtml(UI.toHTML(tmplToRender)), "bar"); + test.equal(canonicalizeHtml(Blaze.toHTML(tmplToRender)), "bar"); test.equal(count, 1); R.set(""); Deps.flush(); @@ -1862,8 +1853,7 @@ Tinytest.add("spacebars - template - UI.toHTML", function (test) { Template.spacebars_test_tohtml_each, "foos", ["bar"]); }); -Tinytest.add( - "spacebars - template - block comments should not be displayed", +Tinytest.add("spacebars-tests - template_tests - block comments should not be displayed", function (test) { var tmpl = Template.spacebars_test_block_comment; var div = renderToDiv(tmpl); @@ -1872,8 +1862,7 @@ Tinytest.add( ); // Originally reported at https://github.com/meteor/meteor/issues/2046 -Tinytest.add( - "spacebars - template - {{#with}} with mutated data context", +Tinytest.add("spacebars-tests - template_tests - {{#with}} with mutated data context", function (test) { var tmpl = Template.spacebars_test_with_mutated_data_context; var foo = {value: 0}; @@ -1892,8 +1881,7 @@ Tinytest.add( test.equal(canonicalizeHtml(div.innerHTML), '1'); }); -Tinytest.add( - "spacebars - template - javascript scheme urls", +Tinytest.add("spacebars-tests - template_tests - javascript scheme urls", function (test) { var tmpl = Template.spacebars_test_url_attribute; var sessionKey = "foo-" + Random.id(); @@ -1963,8 +1951,7 @@ Tinytest.add( } ); -Tinytest.add( - "spacebars - template - event handlers get cleaned up with template is removed", +Tinytest.add("spacebars-tests - template_tests - event handlers get cleaned up when template is removed", function (test) { var tmpl = Template.spacebars_test_event_handler_cleanup; var subtmpl = Template.spacebars_test_event_handler_cleanup_sub; @@ -1980,20 +1967,40 @@ Tinytest.add( var div = renderToDiv(tmpl); - test.equal(div.$_uievents["click"].handlers.length, 1); - test.equal(div.$_uievents["mouseover"].handlers.length, 1); + test.equal(div.$blaze_events["click"].handlers.length, 1); + test.equal(div.$blaze_events["mouseover"].handlers.length, 1); rv.set(false); Deps.flush(); - test.equal(div.$_uievents["click"].handlers.length, 0); - test.equal(div.$_uievents["mouseover"].handlers.length, 0); + test.equal(div.$blaze_events["click"].handlers.length, 0); + test.equal(div.$blaze_events["mouseover"].handlers.length, 0); } ); +// This test makes sure that Blaze correctly finds the controller +// heirarchy surrounding an element that itself doesn't have a +// controller. +Tinytest.add( + "spacebars-tests - template_tests - data context in event handlers on elements inside {{#if}}", + function (test) { + var tmpl = Template.spacebars_test_data_context_for_event_handler_in_if; + var data = null; + tmpl.events({ + 'click span': function () { + data = this; + } + }); + var div = renderToDiv(tmpl); + document.body.appendChild(div); + clickIt(div.querySelector('span')); + test.equal(data, {foo: "bar"}); + document.body.removeChild(div); + }); + // https://github.com/meteor/meteor/issues/2156 Tinytest.add( - "spacebars - template - each with inserts inside autorun", + "spacebars-tests - template_tests - each with inserts inside autorun", function (test) { var tmpl = Template.spacebars_test_each_with_autorun_insert; var coll = new Meteor.Collection(null); @@ -2027,7 +2034,7 @@ Tinytest.add( ); Tinytest.add( - "spacebars - ui hooks", + "spacebars-tests - template_tests - ui hooks", function (test) { var tmpl = Template.spacebars_test_ui_hooks; var rv = new ReactiveVar([]); @@ -2050,8 +2057,8 @@ Tinytest.add( hooks.push("insert"); // check that the element hasn't actually been added yet - test.isTrue(n.parentNode.nodeType === 11 /*DOCUMENT_FRAGMENT_NODE*/); - test.isFalse(n.parentNode.parentNode); + test.isTrue((! n.parentNode) || + n.parentNode.nodeType === 11 /*DOCUMENT_FRAGMENT_NODE*/); }, removeElement: function (n) { hooks.push("remove"); @@ -2094,7 +2101,7 @@ Tinytest.add( ); Tinytest.add( - "spacebars - ui hooks - nested domranges", + "spacebars-tests - template_tests - ui hooks - nested domranges", function (test) { var tmpl = Template.spacebars_test_ui_hooks_nested; var rv = new ReactiveVar(true); @@ -2128,7 +2135,7 @@ Tinytest.add( ); Tinytest.add( - "spacebars - access template instance from helper", + "spacebars-tests - template_tests - UI._templateInstance from helper", function (test) { // Set a property on the template instance; check that it's still // there from a helper. @@ -2149,19 +2156,8 @@ Tinytest.add( } ); -// XXX This is for traversing empty text nodes and should be removed -// on blaze-refactor. -var getSiblingText = function (node, siblingNum) { - var sibling = node; - for (var i = 0; i < siblingNum; i++) { - if (sibling) - sibling = sibling.nextSibling; - } - return $(sibling).text(); -}; - Tinytest.add( - "spacebars - access template instance from helper, " + + "spacebars-tests - template_tests - UI._templateInstance from helper, " + "template instance is kept up-to-date", function (test) { var tmpl = Template.spacebars_test_template_instance_helper; @@ -2169,21 +2165,16 @@ Tinytest.add( var instanceFromHelper; tmpl.foo = function () { - instanceFromHelper = UI._templateInstance(); - return rv.get(); + return UI._templateInstance().data; }; - var div = renderToDiv(tmpl); + var div = renderToDiv(tmpl, function () { return rv.get(); }); rv.set("first"); - Deps.flush(); - // `nextSibling` because the first node is an empty text node. - test.equal(getSiblingText(instanceFromHelper.firstNode, 4), - "first"); + divRendersTo(test, div, "first"); rv.set("second"); Deps.flush(); - test.equal(getSiblingText(instanceFromHelper.firstNode, 4), - "second"); + divRendersTo(test, div, "second"); // UI._templateInstance() should throw when called from not within a // helper. @@ -2194,7 +2185,7 @@ Tinytest.add( ); Tinytest.add( - "spacebars - {{#with}} autorun is cleaned up", + "spacebars-tests - template_tests - {{#with}} autorun is cleaned up", function (test) { var tmpl = Template.spacebars_test_with_cleanup; var rv = new ReactiveVar(""); @@ -2219,39 +2210,40 @@ Tinytest.add( ); Tinytest.add( - "spacebars - access parent data contexts from helper", + "spacebars-tests - template_tests - UI._parentData from helpers", function (test) { var childTmpl = Template.spacebars_test_template_parent_data_helper_child; var parentTmpl = Template.spacebars_test_template_parent_data_helper; - var rv = new ReactiveVar(0); + + var height = new ReactiveVar(0); + var bar = new ReactiveVar("bar"); childTmpl.a = ["a"]; - childTmpl.b = new ReactiveVar("b"); + childTmpl.b = function () { return bar.get(); }; childTmpl.c = ["c"]; childTmpl.foo = function () { - var data = UI._parentData(rv.get()); - return data.get === undefined ? data : data.get(); + return UI._parentData(height.get()); }; var div = renderToDiv(parentTmpl); test.equal(canonicalizeHtml(div.innerHTML), "d"); - rv.set(1); + height.set(1); Deps.flush(); - test.equal(canonicalizeHtml(div.innerHTML), "b"); + test.equal(canonicalizeHtml(div.innerHTML), "bar"); // Test UI._parentData() reactivity - childTmpl.b.set("bNew"); + bar.set("baz"); Deps.flush(); - test.equal(canonicalizeHtml(div.innerHTML), "bNew"); + test.equal(canonicalizeHtml(div.innerHTML), "baz"); - rv.set(2); + height.set(2); Deps.flush(); test.equal(canonicalizeHtml(div.innerHTML), "a"); - rv.set(3); + height.set(3); Deps.flush(); test.equal(canonicalizeHtml(div.innerHTML), "parent"); } @@ -2272,3 +2264,266 @@ Tinytest.add( test.equal(anchNamespace, "http://www.w3.org/2000/svg"); } ); + +Tinytest.add( + "spacebars-tests - template_tests - created/rendered/destroyed by each", + function (test) { + var outerTmpl = + Template.spacebars_test_template_created_rendered_destroyed_each; + var innerTmpl = + Template.spacebars_test_template_created_rendered_destroyed_each_sub; + + var buf = ''; + + innerTmpl.created = function () { buf += 'C' + String(this.data).toLowerCase(); }; + innerTmpl.rendered = function () { buf += 'R' + String(this.data).toLowerCase(); }; + innerTmpl.destroyed = function () { buf += 'D' + String(this.data).toLowerCase(); }; + + var R = ReactiveVar([{_id: 'A'}]); + + outerTmpl.items = function () { + return R.get(); + }; + + var div = renderToDiv(outerTmpl); + divRendersTo(test, div, '
    A
    '); + test.equal(buf, 'CaRa'); + + R.set([{_id: 'B'}]); + divRendersTo(test, div, '
    B
    '); + test.equal(buf, 'CaRaDaCbRb'); + + R.set([{_id: 'C'}]); + divRendersTo(test, div, '
    C
    '); + test.equal(buf, 'CaRaDaCbRbDbCcRc'); + + $(div).remove(); + test.equal(buf, 'CaRaDaCbRbDbCcRcDc'); + }); + +Tinytest.add( + "spacebars-tests - template_tests - UI.render/UI.insert/UI.remove", + function (test) { + var div = document.createElement("DIV"); + document.body.appendChild(div); + + var created = false, rendered = false, destroyed = false; + var R = ReactiveVar('aaa'); + + var tmpl = Template.spacebars_test_ui_render; + tmpl.greeting = function () { return this.greeting || 'Hello'; }; + tmpl.r = function () { return R.get(); }; + tmpl.created = function () { created = true; }; + tmpl.rendered = function () { rendered = true; }; + tmpl.destroyed = function () { destroyed = true; }; + + test.equal([created, rendered, destroyed], [false, false, false]); + + var renderedTmpl = UI.render(tmpl); + test.equal([created, rendered, destroyed], [true, false, false]); + + UI.insert(renderedTmpl, div); + // Flush now. We fire the rendered callback in an afterFlush block, + // to ensure that the DOM is completely updated. + Deps.flush(); + test.equal([created, rendered, destroyed], [true, true, false]); + + var x = UI.render(tmpl); // can run a second time without throwing + // note: we'll have clean up `x` below + + var renderedTmpl2; + UI.insert(renderedTmpl2 = UI.renderWithData(tmpl, {greeting: 'Bye'}), + div); + test.equal(canonicalizeHtml(div.innerHTML), + "Hello aaaBye aaa"); + R.set('bbb'); + Deps.flush(); + test.equal(canonicalizeHtml(div.innerHTML), + "Hello bbbBye bbb"); + test.equal([created, rendered, destroyed], [true, true, false]); + test.equal(R.numListeners(), 3); + UI.remove(renderedTmpl); + UI.remove(renderedTmpl2); + UI.remove(x); + test.equal([created, rendered, destroyed], [true, true, true]); + test.equal(R.numListeners(), 0); + test.equal(canonicalizeHtml(div.innerHTML), ""); + }); + +Tinytest.add( + "spacebars-tests - template_tests - UI.insert fails on jQuery objects", + function (test) { + var tmpl = Template.spacebars_test_ui_render; + test.throws(function () { + UI.insert(UI.render(tmpl), $('body')); + }, /'parentElement' must be a DOM node/); + test.throws(function () { + UI.insert(UI.render(tmpl), document.body, $('body')); + }, /'nextNode' must be a DOM node/); + }); + +Tinytest.add( + "spacebars-tests - template_tests - UI.getElementData", + function (test) { + var div = document.createElement("DIV"); + var tmpl = Template.spacebars_test_ui_getElementData; + UI.insert(UI.renderWithData(tmpl, {foo: "bar"}), div); + + var span = div.querySelector('SPAN'); + test.isTrue(span); + test.equal(UI.getElementData(span), {foo: "bar"}); + }); + +Tinytest.add( + "spacebars-tests - template_tests - autorun cleanup", + function (test) { + var tmpl = Template.spacebars_test_parent_removal; + + var Acalls = ''; + var A = ReactiveVar('hi'); + tmpl.A = function (chr) { + Acalls += chr; + return A.get(); + }; + var Bcalls = 0; + var B = ReactiveVar(['one', 'two']); + tmpl.B = function () { + Bcalls++; + return B.get(); + }; + + // Assert how many times A and B were accessed (since last time) + // and how many autoruns are listening to them. + var assertCallsAndListeners = + function (a_calls, b_calls, a_listeners, b_listeners) { + test.equal('A calls: ' + Acalls.length, + 'A calls: ' + a_calls, + Acalls); + test.equal('B calls: ' + Bcalls, + 'B calls: ' + b_calls); + test.equal('A listeners: ' + A.numListeners(), + 'A listeners: ' + a_listeners); + test.equal('B listeners: ' + B.numListeners(), + 'B listeners: ' + b_listeners); + Acalls = ''; + Bcalls = 0; + }; + + var div = renderToDiv(tmpl); + assertCallsAndListeners(10, 1, 10, 1); + A.set(''); + Deps.flush(); + // Confirm that #4, #5, #6, and #9 are not re-run. + // #a is newly run, for a total of 10 - 4 + 1 = 7, + assertCallsAndListeners(7, 0, 7, 1); + A.set('hi'); + Deps.flush(); + assertCallsAndListeners(10, 0, 10, 1); + + // Now see that removing the DOM with jQuery, below + // the level of the entire template, stops everything. + $(div.querySelector('.toremove')).remove(); + assertCallsAndListeners(0, 0, 0, 0); + }); + +Tinytest.add( + "spacebars-tests - template_tests - focus/blur with clean-up", + function (test) { + var tmpl = Template.spacebars_test_focus_blur_outer; + var cond = ReactiveVar(true); + tmpl.cond = function () { + return cond.get(); + }; + var buf = []; + Template.spacebars_test_focus_blur_inner.events({ + 'focus input': function () { + buf.push('FOCUS'); + }, + 'blur input': function () { + buf.push('BLUR'); + } + }); + + var div = renderToDiv(tmpl); + document.body.appendChild(div); + + // check basic focus and blur to make sure + // everything is sane + test.equal(div.querySelectorAll('input').length, 1); + var input; + focusElement(input = div.querySelector('input')); + // We don't get focus events when the Chrome Dev Tools are focused, + // unfortunately, as of Chrome 35. I think this is a regression in + // Chrome 34. So, the goal is to work whether or not focus is + // "borken," where "working" means always failing if DOMBackend isn't + // correctly unbinding the old event handlers when we switch the IF, + // and always passing if it is. To cause the problem in DOMBackend, + // delete the '**' argument to jQuery#off in + // DOMBackend.Events.undelegateEvents. The only compromise we are + // making here is that if some unrelated bug in Blaze makes + // focus/blur not work, the failure might be masked while the Dev + // Tools are open. + var borken = false; + if (buf.length === 0 && document.activeElement === input) { + test.ok({note:"You might need to defocus the Chrome Dev Tools to get a more accurate run of this test!"}); + borken = true; + $(input).trigger('focus'); + } + test.equal(buf.join(), 'FOCUS'); + blurElement(div.querySelector('input')); + if (buf.length === 1) + $(input).trigger('blur'); + test.equal(buf.join(), 'FOCUS,BLUR'); + + // now switch the IF and check again. The failure mode + // we observed was that DOMBackend would not correctly + // unbind the old event listener at the jQuery level, + // so the old event listener would fire and cause an + // exception inside Blaze ("Must be attached" in + // DOMRange#containsElement), which would show up in + // the console and cause our handler not to fire. + cond.set(false); + buf.length = 0; + Deps.flush(); + test.equal(div.querySelectorAll('input').length, 1); + focusElement(input = div.querySelector('input')); + if (borken) + $(input).trigger('focus'); + test.equal(buf.join(), 'FOCUS'); + blurElement(div.querySelector('input')); + if (! borken) + test.equal(buf.join(), 'FOCUS,BLUR'); + + document.body.removeChild(div); + }); + +// We used to remove event handlers on DOMRange detached, but when +// tearing down a view, we don't "detach" all the DOMRanges recursively. +// Mainly, we destroy the View. Destroying a View should remove its +// event listeners. (In practice, however, it's hard to think of +// consequences to not removing event handlers on removed DOM nodes, +// which will probably be GCed anyway.) +Tinytest.add( + "spacebars-tests - template_tests - event cleanup on destroyed", + function (test) { + var tmpl = Template.spacebars_test_event_cleanup_on_destroyed_outer; + var cond = ReactiveVar(true); + tmpl.cond = function () { + return cond.get(); + }; + + Template.spacebars_test_event_cleanup_on_destroyed_inner.events({ + 'click span': function () {}}); + + var div = renderToDiv(tmpl); + document.body.appendChild(div); + + var eventDiv = div.querySelector('div'); + test.equal(eventDiv.$blaze_events.click.handlers.length, 1); + + cond.set(false); + Deps.flush(); + test.equal(eventDiv.$blaze_events.click.handlers.length, 0); + + document.body.removeChild(div); + }); diff --git a/packages/templating/templating_tests.html b/packages/spacebars-tests/templating_tests.html similarity index 100% rename from packages/templating/templating_tests.html rename to packages/spacebars-tests/templating_tests.html diff --git a/packages/templating/templating_tests.js b/packages/spacebars-tests/templating_tests.js similarity index 88% rename from packages/templating/templating_tests.js rename to packages/spacebars-tests/templating_tests.js index bd19264199..ae01a03a47 100644 --- a/packages/templating/templating_tests.js +++ b/packages/spacebars-tests/templating_tests.js @@ -1,9 +1,3 @@ -// render and put in the document -var renderToDiv = function (comp) { - var div = document.createElement("DIV"); - UI.materialize(comp, div); - return div; -}; // for events to bubble an element needs to be in the DOM. // @return {Function} call this for cleanup @@ -16,7 +10,7 @@ var addToBody = function (el) { }; -Tinytest.add("templating - assembly", function (test) { +Tinytest.add("spacebars-tests - templating_tests - assembly", function (test) { // Test for a bug that made it to production -- after a replacement, // we need to also check the newly replaced node for replacements @@ -46,7 +40,7 @@ Tinytest.add("templating - assembly", function (test) { -Tinytest.add("templating - table assembly", function(test) { +Tinytest.add("spacebars-tests - templating_tests - table assembly", function(test) { var childWithTag = function(node, tag) { return _.find(node.childNodes, function(n) { return n.nodeName === tag; @@ -62,7 +56,7 @@ Tinytest.add("templating - table assembly", function(test) { c.insert({bar:'a'}); c.insert({bar:'b'}); c.insert({bar:'c'}); - var onscreen = renderToDiv(Template.test_table_each.extend({data: {foo: c.find()}})); + var onscreen = renderToDiv(Template.test_table_each, {foo: c.find()}); table = childWithTag(onscreen, "TABLE"); test.equal(table.rows.length, 3, table.parentNode.innerHTML); @@ -75,7 +69,7 @@ Tinytest.add("templating - table assembly", function(test) { Deps.flush(); }); -Tinytest.add("templating - event handler this", function(test) { +Tinytest.add("spacebars-tests - templating_tests - event handler this", function(test) { Template.test_event_data_with.ONE = {str: "one"}; Template.test_event_data_with.TWO = {str: "two"}; @@ -90,8 +84,8 @@ Tinytest.add("templating - event handler this", function(test) { }); var event_buf = []; - var containerDiv = renderToDiv(Template.test_event_data_with.extend({data: - Template.test_event_data_with.ONE})); + var containerDiv = renderToDiv(Template.test_event_data_with, + Template.test_event_data_with.ONE); var cleanupDiv = addToBody(containerDiv); var divs = containerDiv.getElementsByTagName("div"); @@ -125,7 +119,7 @@ if (document.addEventListener) { // 2. Event should work on every element in the selector and not just the first element // This test isn't written against mouseenter because it is synthesized by jQuery, // the bug also happened with the play event - Tinytest.add("templating - capturing events", function (test) { + Tinytest.add("spacebars-tests - templating_tests - capturing events", function (test) { var video1Played = 0, video2Played = 0; @@ -168,7 +162,7 @@ if (document.addEventListener) { }); } -Tinytest.add("templating - safestring", function(test) { +Tinytest.add("spacebars-tests - templating_tests - safestring", function(test) { Template.test_safestring_a.foo = function() { return "
    "; @@ -180,7 +174,7 @@ Tinytest.add("templating - safestring", function(test) { var obj = {fooprop: "
    ", barprop: new Spacebars.SafeString("
    ")}; var html = canonicalizeHtml( - renderToDiv(Template.test_safestring_a.extend({data: obj})).innerHTML); + renderToDiv(Template.test_safestring_a, obj).innerHTML); test.equal(html, "<br>


    "+ @@ -188,7 +182,7 @@ Tinytest.add("templating - safestring", function(test) { }); -Tinytest.add("templating - helpers and dots", function(test) { +Tinytest.add("spacebars-tests - templating_tests - helpers and dots", function(test) { UI.registerHelper("platypus", function() { return "eggs"; }); @@ -258,7 +252,7 @@ Tinytest.add("templating - helpers and dots", function(test) { var html; html = canonicalizeHtml( - renderToDiv(Template.test_helpers_a.extend({data: dataObj})).innerHTML); + renderToDiv(Template.test_helpers_a, dataObj).innerHTML); test.equal(html.match(/\S+/g), [ 'platypus=bill', // helpers on Template object take first priority 'watermelon=seeds', // global helpers take second priority @@ -268,7 +262,7 @@ Tinytest.add("templating - helpers and dots", function(test) { ]); html = canonicalizeHtml( - renderToDiv(Template.test_helpers_b.extend({data: dataObj})).innerHTML); + renderToDiv(Template.test_helpers_b, dataObj).innerHTML); test.equal(html.match(/\S+/g), [ // unknown properties silently fail 'unknown=', @@ -277,7 +271,7 @@ Tinytest.add("templating - helpers and dots", function(test) { ]); html = canonicalizeHtml( - renderToDiv(Template.test_helpers_c.extend({data: dataObj})).innerHTML); + renderToDiv(Template.test_helpers_c, dataObj).innerHTML); test.equal(html.match(/\S+/g), [ // property gets are supposed to silently fail 'platypus.X=', @@ -291,7 +285,7 @@ Tinytest.add("templating - helpers and dots", function(test) { ]); html = canonicalizeHtml( - renderToDiv(Template.test_helpers_d.extend({data: dataObj})).innerHTML); + renderToDiv(Template.test_helpers_d, dataObj).innerHTML); test.equal(html.match(/\S+/g), [ // helpers should get current data context in `this` 'daisygetter=petal', @@ -305,7 +299,7 @@ Tinytest.add("templating - helpers and dots", function(test) { ]); html = canonicalizeHtml( - renderToDiv(Template.test_helpers_e.extend({data: dataObj})).innerHTML); + renderToDiv(Template.test_helpers_e, dataObj).innerHTML); test.equal(html.match(/\S+/g), [ 'fancy.foo=bar', 'fancy.apple.banana=smoothie', @@ -316,7 +310,7 @@ Tinytest.add("templating - helpers and dots", function(test) { ]); html = canonicalizeHtml( - renderToDiv(Template.test_helpers_f.extend({data: dataObj})).innerHTML); + renderToDiv(Template.test_helpers_f, dataObj).innerHTML); test.equal(html.match(/\S+/g), [ 'fancyhelper.foo=bar', 'fancyhelper.apple.banana=smoothie', @@ -329,7 +323,7 @@ Tinytest.add("templating - helpers and dots", function(test) { // test significance of 'this', which prevents helper from // shadowing property html = canonicalizeHtml( - renderToDiv(Template.test_helpers_g.extend({data: dataObj})).innerHTML); + renderToDiv(Template.test_helpers_g, dataObj).innerHTML); test.equal(html.match(/\S+/g), [ 'platypus=eggs', 'this.platypus=weird' @@ -340,7 +334,7 @@ Tinytest.add("templating - helpers and dots", function(test) { Template.test_helpers_h.helperListFour = listFour; html = canonicalizeHtml( - renderToDiv(Template.test_helpers_h.extend({data: dataObj})).innerHTML); + renderToDiv(Template.test_helpers_h, dataObj).innerHTML); var trials = html.match(/\(.*?\)/g); test.equal(trials[0], @@ -358,14 +352,14 @@ Tinytest.add("templating - helpers and dots", function(test) { }); -Tinytest.add("templating - rendered template", function(test) { +Tinytest.add("spacebars-tests - templating_tests - rendered template", function(test) { var R = ReactiveVar('foo'); Template.test_render_a.foo = function() { R.get(); return this.x + 1; }; - var div = renderToDiv(Template.test_render_a.extend({data: {x: 123}})); + var div = renderToDiv(Template.test_render_a, {x: 123}); test.equal($(div).text().match(/\S+/)[0], "124"); var br1 = div.getElementsByTagName('br')[0]; @@ -393,7 +387,7 @@ Tinytest.add("templating - rendered template", function(test) { return (+this) + 1; }; - div = renderToDiv(Template.test_render_b.extend({data: {x: 123}})); + div = renderToDiv(Template.test_render_b, {x: 123}); test.equal($(div).text().match(/\S+/)[0], "201"); var br1 = div.getElementsByTagName('br')[0]; @@ -414,7 +408,7 @@ Tinytest.add("templating - rendered template", function(test) { }); -Tinytest.add("templating - template arg", function (test) { +Tinytest.add("spacebars-tests - templating_tests - template arg", function (test) { Template.test_template_arg_a.events({ click: function (event, template) { template.firstNode.innerHTML = 'Hello'; @@ -451,7 +445,7 @@ Tinytest.add("templating - template arg", function (test) { test.throws(function () { return self.findAll("*"); }); }; - var div = renderToDiv(Template.test_template_arg_a.extend({data: {food: "pie"}})); + var div = renderToDiv(Template.test_template_arg_a, {food: "pie"}); var cleanupDiv = addToBody(div); Deps.flush(); // cause `rendered` to be called test.equal($(div).text(), "Greetings 1-bold Line"); @@ -462,7 +456,7 @@ Tinytest.add("templating - template arg", function (test) { Deps.flush(); }); -Tinytest.add("templating - helpers", function (test) { +Tinytest.add("spacebars-tests - templating_tests - helpers", function (test) { var tmpl = Template.test_template_helpers_a; tmpl.foo = 'z'; @@ -501,7 +495,7 @@ Tinytest.add("templating - helpers", function (test) { Deps.flush(); }); -Tinytest.add("templating - events", function (test) { +Tinytest.add("spacebars-tests - templating_tests - events", function (test) { var tmpl = Template.test_template_events_a; var buf = []; @@ -559,7 +553,7 @@ Tinytest.add("templating - events", function (test) { }); -Tinytest.add('templating - helper typecast Issue #617', function (test) { +Tinytest.add('spacebars-tests - templating_tests - helper typecast Issue #617', function (test) { UI.registerHelper('testTypeCasting', function (/*arguments*/) { // Return a string representing the arguments passed to this @@ -586,14 +580,14 @@ Tinytest.add('templating - helper typecast Issue #617', function (test) { "[object]"); }); -Tinytest.add('templating - each falsy Issue #801', function (test) { +Tinytest.add('spacebars-tests - templating_tests - each falsy Issue #801', function (test) { //Minor test for issue #801 (#each over array containing nulls) Template.test_template_issue801.values = function() { return [0,1,2,null,undefined,false]; }; var div = renderToDiv(Template.test_template_issue801); test.equal(canonicalizeHtml(div.innerHTML), "012"); }); -Tinytest.add('templating - duplicate template error', function (test) { +Tinytest.add('spacebars-tests - templating_tests - duplicate template error', function (test) { Template.__define__("test_duplicate_template", function () {}); test.throws(function () { Template.__define__("test_duplicate_template", function () {}); diff --git a/packages/spacebars/dynamic.html b/packages/spacebars/dynamic.html index 00dedc7b8f..3e93b50538 100644 --- a/packages/spacebars/dynamic.html +++ b/packages/spacebars/dynamic.html @@ -20,7 +20,7 @@ the template to render) and a `data` property, which can be falsey. --> {{#with ../data}} {{! original 'dataContext' argument to __dynamic}} {{> ..}} {{! return value from chooseTemplate(template) }} {{else}} {{! if the 'dataContext' argument was falsey }} - {{> .. ../data}} {{! return value from chooseTemplate(template) }} + {{> .. ../data}} {{! return value from chooseTemplate(template) }} {{/with}} {{/with}} diff --git a/packages/spacebars/dynamic_tests.js b/packages/spacebars/dynamic_tests.js index a8b62f5275..503c22011b 100644 --- a/packages/spacebars/dynamic_tests.js +++ b/packages/spacebars/dynamic_tests.js @@ -1,5 +1,5 @@ Tinytest.add( - "ui-dynamic-template - render template dynamically", function (test, expect) { + "spacebars - ui-dynamic-template - render template dynamically", function (test, expect) { var tmpl = Template.ui_dynamic_test; var nameVar = new ReactiveVar; @@ -30,7 +30,7 @@ Tinytest.add( // Same test as above, but the {{> UI.dynamic}} inclusion has no // `dataContext` argument. Tinytest.add( - "ui-dynamic-template - render template dynamically, no data context", + "spacebars - ui-dynamic-template - render template dynamically, no data context", function (test, expect) { var tmpl = Template.ui_dynamic_test_no_data; @@ -49,7 +49,7 @@ Tinytest.add( Tinytest.add( - "ui-dynamic-template - render template " + + "spacebars - ui-dynamic-template - render template " + "dynamically, data context gets inherited", function (test, expect) { var tmpl = Template.ui_dynamic_test_inherited_data; @@ -80,7 +80,7 @@ Tinytest.add( ); Tinytest.add( - "ui-dynamic-template - render template " + + "spacebars - ui-dynamic-template - render template " + "dynamically, data context does not get inherited if " + "falsey context is passed in", function (test, expect) { @@ -107,7 +107,7 @@ Tinytest.add( ); Tinytest.add( - "ui-dynamic-template - render template " + + "spacebars - ui-dynamic-template - render template " + "dynamically, bad arguments", function (test, expect) { var tmplPrefix = "ui_dynamic_test_bad_args"; @@ -120,6 +120,7 @@ Tinytest.add( for (var i = 0; i < 3; i++) { var tmpl = Template[tmplPrefix + i]; test.throws(function () { + Blaze._throwNextException = true; var div = renderToDiv(tmpl); }); } @@ -127,7 +128,7 @@ Tinytest.add( ); Tinytest.add( - "ui-dynamic-template - render template " + + "spacebars - ui-dynamic-template - render template " + "dynamically, falsey context", function (test, expect) { var tmpl = Template.ui_dynamic_test_falsey_context; diff --git a/packages/spacebars/package.js b/packages/spacebars/package.js index 4c20f2ee2b..1b38fae2b0 100644 --- a/packages/spacebars/package.js +++ b/packages/spacebars/package.js @@ -12,11 +12,11 @@ Package.describe({ // Additional tests are in `spacebars-tests`. Package.on_use(function (api) { - api.use('spacebars-common'); - api.imply('spacebars-common'); + api.export('Spacebars'); api.use('htmljs'); api.use('ui'); + api.use('observe-sequence'); api.use('templating'); api.add_files(['spacebars-runtime.js']); api.add_files(['dynamic.html', 'dynamic.js'], 'client'); diff --git a/packages/spacebars/spacebars-runtime.js b/packages/spacebars/spacebars-runtime.js index c79b1fc66b..7d02d7f9b9 100644 --- a/packages/spacebars/spacebars-runtime.js +++ b/packages/spacebars/spacebars-runtime.js @@ -1,42 +1,37 @@ -// * `templateOrFunction` - template (component) or function returning a template -// or null -Spacebars.include = function (templateOrFunction, contentBlock, elseContentBlock) { - if (contentBlock && ! UI.isComponent(contentBlock)) - throw new Error('Second argument to Spacebars.include must be a template or UI.block if present'); - if (elseContentBlock && ! UI.isComponent(elseContentBlock)) - throw new Error('Third argument to Spacebars.include must be a template or UI.block if present'); +Spacebars = {}; - var props = null; - if (contentBlock) { - props = (props || {}); - props.__content = contentBlock; - } - if (elseContentBlock) { - props = (props || {}); - props.__elseContent = elseContentBlock; +var tripleEquals = function (a, b) { return a === b; }; + +Spacebars.include = function (templateOrFunction, contentFunc, elseFunc) { + if (! templateOrFunction) + return null; + + if (typeof templateOrFunction !== 'function') { + var template = templateOrFunction; + if (! Blaze.isTemplate(template)) + throw new Error("Expected template or null, found: " + template); + return Blaze.runTemplate(templateOrFunction, contentFunc, elseFunc); } - if (UI.isComponent(templateOrFunction)) - return templateOrFunction.extend(props); - - var func = templateOrFunction; - - var f = function () { - var emboxedFunc = UI.namedEmboxValue('Spacebars.include', func); - f.stop = function () { - emboxedFunc.stop(); - }; - var tmpl = emboxedFunc(); - - if (tmpl === null) + var templateVar = Blaze.ReactiveVar(null, tripleEquals); + var view = Blaze.View('Spacebars.include', function () { + var template = templateVar.get(); + if (template === null) return null; - if (! UI.isComponent(tmpl)) - throw new Error("Expected null or template in return value from inclusion function, found: " + tmpl); - return tmpl.extend(props); - }; + if (! Template.__isTemplate__(template)) + throw new Error("Expected template or null, found: " + template); - return f; + return Blaze.runTemplate(template, contentFunc, elseFunc); + }); + view.__templateVar = templateVar; + view.onCreated(function () { + this.autorun(function () { + templateVar.set(templateOrFunction()); + }); + }); + + return view; }; // Executes `{{foo bar baz}}` when called on `(foo, bar, baz)`. @@ -209,38 +204,77 @@ Spacebars.dot = function (value, id1/*, id2, ...*/) { }; }; -// Implement Spacebars's #with, which renders its else case (or nothing) -// if the argument is falsy. -Spacebars.With = function (argFunc, contentBlock, elseContentBlock) { - return UI.Component.extend({ - init: function () { - this.v = UI.emboxValue(argFunc, UI.safeEquals); - }, - render: function () { - return UI.If(this.v, UI.With(this.v, contentBlock), elseContentBlock); - }, - materialized: (function () { - var f = function (range) { - var self = this; - if (Deps.active) { - Deps.onInvalidate(function () { - self.v.stop(); - }); - } - if (range) { - range.removed = function () { - self.v.stop(); - }; - } - }; - f.isWith = true; - return f; - })() - }); -}; - Spacebars.TemplateWith = function (argFunc, contentBlock) { - var w = UI.With(argFunc, contentBlock); + var w = Blaze.With(argFunc, contentBlock); w.__isTemplateWith = true; return w; }; + +// Spacebars.With implements the conditional logic of rendering +// the `{{else}}` block if the argument is falsy. It combines +// a Blaze.If with a Blaze.With (the latter only in the truthy +// case, since the else block is evaluated without entering +// a new data context). +Spacebars.With = function (argFunc, contentFunc, elseFunc) { + var argVar = new Blaze.ReactiveVar; + var view = Blaze.View('Spacebars_with', function () { + return Blaze.If(function () { return argVar.get(); }, + function () { return Blaze.With(function () { + return argVar.get(); }, contentFunc); }, + elseFunc); + }); + view.onCreated(function () { + this.autorun(function () { + argVar.set(argFunc()); + + // This is a hack so that autoruns inside the body + // of the #with get stopped sooner. It reaches inside + // our ReactiveVar to access its dep. + + Deps.onInvalidate(function () { + argVar.dep.changed(); + }); + + // Take the case of `{{#with A}}{{B}}{{/with}}`. The goal + // is to not re-render `B` if `A` changes to become falsy + // and `B` is simultaneously invalidated. + // + // A series of autoruns are involved: + // + // 1. This autorun (argument to Spacebars.With) + // 2. Argument to Blaze.If + // 3. Blaze.If view re-render + // 4. Argument to Blaze.With + // 5. The template tag `{{B}}` + // + // When (3) is invalidated, it immediately stops (4) and (5) + // because of a Deps.onInvalidate built into materializeView. + // (When a View's render method is invalidated, it immediately + // tears down all the subviews, via a Deps.onInvalidate much + // like this one. + // + // Suppose `A` changes to become falsy, and `B` changes at the + // same time (i.e. without an intervening flush). + // Without the code above, this happens: + // + // - (1) and (5) are invalidated. + // - (1) runs, invalidating (2) and (4). + // - (5) runs. + // - (2) runs, invalidating (3), stopping (4) and (5). + // + // With the code above: + // + // - (1) and (5) are invalidated, invalidating (2) and (4). + // - (1) runs. + // - (2) runs, invalidating (3), stopping (4) and (5). + // + // If the re-run of (5) is originally enqueued before (1), all + // bets are off, but typically that doesn't seem to be the + // case. Anyway, doing this is always better than not doing it, + // because it might save a bunch of DOM from being updated + // needlessly. + }); + }); + + return view; +}; diff --git a/packages/stylus/stylus_tests.js b/packages/stylus/stylus_tests.js index 916f3a3ad7..e447e121bd 100644 --- a/packages/stylus/stylus_tests.js +++ b/packages/stylus/stylus_tests.js @@ -2,7 +2,7 @@ Tinytest.add("stylus - presence", function(test) { var div = document.createElement('div'); - UI.materialize(Template.stylus_test_presence, div); + Blaze.render(Template.stylus_test_presence).attach(div); div.style.display = 'block'; document.body.appendChild(div); @@ -15,7 +15,7 @@ Tinytest.add("stylus - presence", function(test) { Tinytest.add("stylus - @import", function(test) { var div = document.createElement('div'); - UI.materialize(Template.stylus_test_import, div); + Blaze.render(Template.stylus_test_import).attach(div); div.style.display = 'block'; document.body.appendChild(div); diff --git a/packages/templating/global_template_object.js b/packages/templating/global_template_object.js deleted file mode 100644 index 3e22e9e383..0000000000 --- a/packages/templating/global_template_object.js +++ /dev/null @@ -1,14 +0,0 @@ -// Create an empty template object. Packages and apps add templates on -// to this object. -Template = {}; - -Template.__define__ = function (templateName, renderFunc) { - if (Template.hasOwnProperty(templateName)) - throw new Error("There are multiple templates named '" + templateName + "'. Each template needs a unique name."); - - Template[templateName] = UI.Component.extend({ - kind: "Template_" + templateName, - render: renderFunc, - __helperHost: true - }); -}; diff --git a/packages/templating/package.js b/packages/templating/package.js index 0250efac55..2106295e2c 100644 --- a/packages/templating/package.js +++ b/packages/templating/package.js @@ -22,7 +22,7 @@ Package.on_use(function (api) { // XXX would like to do the following only when the first html file // is encountered - api.add_files('global_template_object.js', 'client'); + api.add_files('templating.js', 'client'); api.export('Template', 'client'); // html_scanner.js emits client code that calls Meteor.startup and @@ -41,10 +41,6 @@ Package.on_test(function (api) { 'minimongo'], 'client'); api.use('spacebars-compiler'); - api.add_files([ - 'templating_tests.js', - 'templating_tests.html' - ], 'client'); api.add_files([ 'plugin/html_scanner.js', 'scanner_tests.js' diff --git a/packages/templating/plugin/html_scanner.js b/packages/templating/plugin/html_scanner.js index 56331f28a9..9be7de3f35 100644 --- a/packages/templating/plugin/html_scanner.js +++ b/packages/templating/plugin/html_scanner.js @@ -152,10 +152,10 @@ html_scanner = { if (! name) throwParseError("Template has no 'name' attribute"); - if (Spacebars.isReservedName(name)) + if (SpacebarsCompiler.isReservedName(name)) throwParseError("Template can't be named \"" + name + "\""); - var renderFuncCode = Spacebars.compile( + var renderFuncCode = SpacebarsCompiler.compile( contents, { isTemplate: true, sourceName: 'Template "' + name + '"' @@ -168,14 +168,14 @@ html_scanner = { if (hasAttribs) throwParseError("Attributes on not supported"); - var renderFuncCode = Spacebars.compile( + var renderFuncCode = SpacebarsCompiler.compile( contents, { isBody: true, sourceName: "" }); // We may be one of many `` tags. - results.js += "\nUI.body.contentParts.push(UI.Component.extend({render: " + renderFuncCode + "}));\nMeteor.startup(function () { if (! UI.body.INSTANTIATED) { UI.body.INSTANTIATED = true; UI.DomRange.insert(UI.render(UI.body).dom, document.body); } });\n"; + results.js += "\nTemplate.__body__.__contentParts.push(Blaze.View('body_content_'+Template.__body__.__contentParts.length, " + renderFuncCode + "));\nMeteor.startup(Template.__body__.__instantiate);\n"; } } catch (e) { if (e.scanner) { diff --git a/packages/templating/scanner_tests.js b/packages/templating/scanner_tests.js index c03e6c07e7..40ad203c7a 100644 --- a/packages/templating/scanner_tests.js +++ b/packages/templating/scanner_tests.js @@ -26,12 +26,12 @@ Tinytest.add("templating - html scanner", function (test) { // where content is something simple like the string "Hello" // (passed in as a source string including the quotes). var simpleBody = function (content) { - return "\nUI.body.contentParts.push(UI.Component.extend({render: (function() {\n var self = this;\n return " + content + ";\n})}));\nMeteor.startup(function () { if (! UI.body.INSTANTIATED) { UI.body.INSTANTIATED = true; UI.DomRange.insert(UI.render(UI.body).dom, document.body); } });\n"; + return "\nTemplate.__body__.__contentParts.push(Blaze.View('body_content_'+Template.__body__.__contentParts.length, (function() {\n var view = this;\n return " + content + ";\n})));\nMeteor.startup(Template.__body__.__instantiate);\n"; }; // arguments are quoted strings like '"hello"' var simpleTemplate = function (templateName, content) { - return '\nTemplate.__define__(' + templateName + ', (function() {\n var self = this;\n var template = this;\n return ' + content + ';\n}));\n'; + return '\nTemplate.__define__(' + templateName + ', (function() {\n var view = this;\n return ' + content + ';\n}));\n'; }; var checkResults = function(results, expectJs, expectHead) { diff --git a/packages/templating/templating.js b/packages/templating/templating.js new file mode 100644 index 0000000000..b871b9c180 --- /dev/null +++ b/packages/templating/templating.js @@ -0,0 +1,261 @@ +// Create an empty template object. Packages and apps add templates on +// to this object. +Template = {}; + +// `Template` is not a function so this is not a real function prototype, +// but it is used as the prototype of all `Template.foo` objects. +// Naming a template "prototype" will cause an error. +Template.prototype = (function () { + // IE 8 exposes function names in the enclosing scope, so + // use this IIFE to catch it. + return (function Template() {}).prototype; +})(); + +Template.prototype.helpers = function (dict) { + for (var k in dict) + this[k] = dict[k]; +}; + +Template.__updateTemplateInstance = function (view) { + // Populate `view.templateInstance.{firstNode,lastNode,data}` + // on demand. + var tmpl = view._templateInstance; + if (! tmpl) { + tmpl = view._templateInstance = { + $: function (selector) { + if (! view.domrange) + throw new Error("Can't use $ on component with no DOM"); + return view.domrange.$(selector); + }, + findAll: function (selector) { + return Array.prototype.slice.call(this.$(selector)); + }, + find: function (selector) { + var result = this.$(selector); + return result[0] || null; + }, + data: null, + firstNode: null, + lastNode: null, + __view__: view + }; + } + + tmpl.data = Blaze.getViewData(view); + + if (view.domrange && !view.isDestroyed) { + tmpl.firstNode = view.domrange.firstNode(); + tmpl.lastNode = view.domrange.lastNode(); + } else { + // on 'created' or 'destroyed' callbacks we don't have a DomRange + tmpl.firstNode = null; + tmpl.lastNode = null; + } + + return tmpl; +}; + +UI._templateInstance = function () { + var templateView = Blaze.getCurrentTemplateView(); + if (! templateView) + throw new Error("No current template"); + + return Template.__updateTemplateInstance(templateView); +}; + +Template.prototype.events = function (eventMap) { + var template = this; + template.__eventMaps = (template.__eventMaps || []); + var eventMap2 = {}; + for (var k in eventMap) { + eventMap2[k] = (function (k, v) { + return function (event/*, ...*/) { + var view = this; // passed by EventAugmenter + var data = Blaze.getElementData(event.currentTarget); + if (data == null) + data = {}; + var args = Array.prototype.slice.call(arguments); + var tmplInstance = Template.__updateTemplateInstance(view); + args.splice(1, 0, tmplInstance); + return v.apply(data, args); + }; + })(k, eventMap[k]); + } + + template.__eventMaps.push(eventMap2); +}; + +Template.prototype.__makeView = function (contentFunc, elseFunc) { + var template = this; + var view = Blaze.View(this.__viewName, this.__render); + view.template = template; + + view.templateContentBlock = ( + contentFunc ? Template.__create__('(contentBlock)', contentFunc) : null); + view.templateElseBlock = ( + elseFunc ? Template.__create__('(elseBlock)', elseFunc) : null); + + if (template.__eventMaps || + typeof template.events === 'object') { + view.onMaterialized(function () { + if (! template.__eventMaps && + typeof template.events === "object") { + // Provide limited back-compat support for `.events = {...}` + // syntax. Pass `template.events` to the original `.events(...)` + // function. This code must run only once per template, in + // order to not bind the handlers more than once, which is + // ensured by the fact that we only do this when `__eventMaps` + // is falsy, and we cause it to be set now. + Template.prototype.events.call(template, template.events); + } + + _.each(template.__eventMaps, function (m) { + Blaze._addEventMap(view, m, view); + }); + }); + } + + if (template.__initView) + template.__initView(view); + + if (template.created) { + view.onCreated(function () { + var inst = Template.__updateTemplateInstance(view); + template.created.call(inst); + }); + } + + if (template.rendered) { + view.onRendered(function () { + var inst = Template.__updateTemplateInstance(view); + template.rendered.call(inst); + }); + } + + if (template.destroyed) { + view.onDestroyed(function () { + var inst = Template.__updateTemplateInstance(view); + template.destroyed.call(inst); + }); + } + + return view; +}; + +var _hasOwnProperty = Object.prototype.hasOwnProperty; + +Template.__lookup__ = function (templateName) { + if (! _hasOwnProperty.call(Template, templateName)) + return null; + var tmpl = Template[templateName]; + if (Template.__isTemplate__(tmpl)) + return tmpl; + return null; +}; + +Template.__create__ = function (viewName, templateFunc, initView) { + var tmpl = new Template.prototype.constructor; + tmpl.__viewName = viewName; + tmpl.__render = templateFunc; + if (initView) + tmpl.__initView = initView; + + return tmpl; +}; + +Template.__define__ = function (templateName, templateFunc) { + if (_hasOwnProperty.call(Template, templateName)) { + if (Template[templateName].__makeView) + throw new Error("There are multiple templates named '" + templateName + "'. Each template needs a unique name."); + throw new Error("This template name is reserved: " + templateName); + } + + var tmpl = Template.__create__('Template.' + templateName, templateFunc); + tmpl.__templateName = templateName; + + Template[templateName] = tmpl; + return tmpl; +}; + +Template.__isTemplate__ = function (x) { + return x && x.__makeView; +}; + +// Define a template `Template.__body__` that renders its +// `__contentParts`. +Template.__define__('__body__', function () { + var parts = Template.__body__.__contentParts; + // enable lookup by setting `view.template` + for (var i = 0; i < parts.length; i++) + parts[i].template = Template.__body__; + return parts; +}); +Template.__body__.__contentParts = []; // array of Blaze.Views + +// Define `Template.__body__.__instantiate()` as a function that +// renders `Template.__body__` into `document.body`, at most once +// (calling it a second time does nothing). This function does +// not use `this`, so you can safely call: +// `Meteor.startup(Template.__body__.__instantiate)`. +Template.__body__.__isInstantiated = false; +var instantiateBody = function () { + if (Template.__body__.__isInstantiated) + return; + Template.__body__.__isInstantiated = true; + var range = Blaze.render(Template.__body__); + Template.__body__.__view = range.view; + range.attach(document.body); +}; +Template.__body__.__instantiate = instantiateBody; + + +// Renders a template (eg `Template.foo`), returning a DOMRange. The +// range will keep updating reactively. +UI.render = function (tmpl) { + if (! Template.__isTemplate__(tmpl)) + throw new Error("Template required here"); + + return Blaze.render(tmpl); +}; + +// Same as `UI.render` with a data context passed in. +UI.renderWithData = function (tmpl, data) { + if (! Template.__isTemplate__(tmpl)) + throw new Error("Template required here"); + if (typeof data === 'function') + throw new Error("Data argument can't be a function"); // XXX or can it? + + return Blaze.render(Blaze.With(data, function () { + return tmpl; + })); +}; + +// The publicly documented API for inserting a DOMRange returned from +// `UI.render` or `UI.renderWithData` into the DOM. If you then remove +// `parentElement` using jQuery, all reactive updates on the rendered +// template will stop. +UI.insert = function (range, parentElement, nextNode) { + // parentElement must be a DOM node. in particular, can't be the + // result of a call to `$`. Can't check if `parentElement instanceof + // Node` since 'Node' is undefined in IE8. + if (! parentElement || typeof parentElement.nodeType !== 'number') + throw new Error("'parentElement' must be a DOM node"); + if (nextNode && typeof nextNode.nodeType !== 'number') // 'nextNode' is optional + throw new Error("'nextNode' must be a DOM node"); + if (! range instanceof Blaze.DOMRange) + throw new Error("Expected template rendered with UI.render"); + + range.attach(parentElement, nextNode); +}; + +// XXX test and document +UI.remove = function (range) { + if (! range instanceof Blaze.DOMRange) + throw new Error("Expected template rendered with UI.render"); + + if (range.attached) + range.detach(); + range.destroy(); +}; + +UI.body = Template.__body__; diff --git a/packages/test-helpers/canonicalize_html.js b/packages/test-helpers/canonicalize_html.js index 6a5ed5f4a9..5ac2c0e277 100644 --- a/packages/test-helpers/canonicalize_html.js +++ b/packages/test-helpers/canonicalize_html.js @@ -21,7 +21,7 @@ canonicalizeHtml = function(html) { attrs = attrs.replace(/sizcache[0-9]+="[^"]*"/g, ' '); // Similarly for expando properties used by jQuery to track data. attrs = attrs.replace(/jQuery[0-9]+="[0-9]+"/g, ' '); - // Similarly for expando properties used to DomBackend to keep + // Similarly for expando properties used to DOMBackend to keep // track of callbacks to fire when an element is removed attrs = attrs.replace(/\$meteor_ui_removal_callbacks="[^"]*"/g, ' '); diff --git a/packages/test-helpers/render_div.js b/packages/test-helpers/render_div.js index 451afea535..2976d5c18c 100644 --- a/packages/test-helpers/render_div.js +++ b/packages/test-helpers/render_div.js @@ -1,5 +1,13 @@ -renderToDiv = function (comp) { +renderToDiv = function (template, optData) { var div = document.createElement("DIV"); - UI.materialize(comp, div); + if (optData == null) { + Blaze.render(template).attach(div); + } else { + Blaze.render(function () { + return Blaze.With(optData, function () { + return template; + }); + }).attach(div); + } return div; }; diff --git a/packages/test-helpers/seeded_random_test.js b/packages/test-helpers/seeded_random_test.js index 9d3ef18509..841f62c35a 100644 --- a/packages/test-helpers/seeded_random_test.js +++ b/packages/test-helpers/seeded_random_test.js @@ -1,6 +1,6 @@ // XXX SECTION: Meta tests -Tinytest.add("seeded random", function (test) { +Tinytest.add("test-helpers - seeded_random", function (test) { // Test that two seeded PRNGs with the same seed produce the same values. var seed = "I'm a seed"; var sr1 = new SeededRandom(seed); diff --git a/packages/test-helpers/try_all_permutations_test.js b/packages/test-helpers/try_all_permutations_test.js index f3e2f0cb04..f9bebffea9 100644 --- a/packages/test-helpers/try_all_permutations_test.js +++ b/packages/test-helpers/try_all_permutations_test.js @@ -1,6 +1,6 @@ // XXX SECTION: Meta tests -Tinytest.add("try_all_permutations", function (test) { +Tinytest.add("test-helpers - try_all_permutations", function (test) { // Have a good test of try_all_permutations, because it would suck // if try_all_permutations didn't actually run anything and so none // of our other tests actually did any testing. diff --git a/packages/ui/README-old.md b/packages/ui/README-old.md deleted file mode 100644 index 8e44afe656..0000000000 --- a/packages/ui/README-old.md +++ /dev/null @@ -1,87 +0,0 @@ -# Meteor UI - -XXX This README just talks about DomRange, and the information is out of date or may change before release. - -## DomRange - -- - - -**What users need to know:** DomRange is the type of object found at `component.dom` (sometimes `this.dom`). It provides useful methods like `dom.$(selector)` and `dom.elements()`. A DomRange represents the DOM extent of a rendered component, sort of like virtual wrapper element. -- - - - -A DomRange can be conceptualized as an invisible element in the DOM tree which sits under some parent element and contains some of the element's children as members. There's one DomRange for every template, component, or block helper in a Meteor application. The members of a DomRange may be either nodes or other DomRanges, forming a miniature tree. Since all the nodes in a DomRange tree are siblings in the DOM, this miniature tree occurs entirely at one DOM tree level. - -For example, take the following template code: - -``` - -``` - -Surrounding the `post-wrapper` div elements is a DomRange tree at least three levels deep, containing DomRanges for the `template`, `each`, and `if`s. Additionally, there is a DomRange inside each div that encloses the `post` template, but because this DomRange occurs at a different level of the DOM, it doesn't interact with the others. - -Compared to a real DOM element, a DomRange holds few pointers to other nodes and makes few assumptions about its members. Member elements may be removed or re-ordered without notifying the DomRange (leading to, at worst, fewer ordering guarantees from future operations). Essentially, a DomRange holds an unordered set of member pointers which are weak, bidirectional, and optionally labeled with names. There are no pointers across levels of the DOM. - -Meteor UI uses DomRange for all manipulation of DOM structures, and user hooks allow these operations to be customized at a fine level for the sake of animated transitions. Constructs like `#each` call DomRange's high-level member operations like add, move, and remove, which in turn call low-level DOM operations that can be customized to suit the context (for example, a container element all of whose children should be animated in and out). - -DomRanges also provide a range of other core Meteor UI functionality such as: - -* Detection of removed nodes -* Event binding -* Containment and selector testing - -A helper library such as jQuery provides DOM compatibility shimming and API expansion (adding features to selectors and event objects, for example). A common interface connects DomRange to "DOM backends" like jQuery or Zepto. Each backend requires an adaptor library which adapts names and semantics to fit DomRange's requirements. - -DomRange attaches a `.$ui` property to DOM elements that makes it easy to get from an element to its immediately enclosing range. Going in the other direction is as easy as `range.elements()`. - -Given that components, not ranges, are likely to be of the most interest to the application, DomRange is designed to hang off a *host object* in a property called `.dom`. Thus, following pointers from a DomRange to other ranges, or following `.$ui` from an element, goes to the host object if there is one, not the DomRange itself. In this way, DomRange interoperates with components while knowing only that they have a `.dom` property. - -### Host Objects - -When a DomRange is created, an object called the "host" or "component" can be supplied, and this object is used in arguments and return values of DomRange methods instead of the DomRange itself. This lets you treat a DomRange as having components as members. For example, `myComponent.dom.get("foo")` will be a component, not a DomRange, and `myElement.$ui` will also be a component even though DomRange itself uses this pointer to find the DomRange that owns an element. - -The only thing DomRange knows about components is that they are host objects for DomRanges. - -### Methods - -In the following method signatures, a "component" is a DomRange host object. A "member" is a component or DOM node. - -`new DomRange([component])` - -Creates an empty DomRange in an offscreen document fragment. If `component` is provided, it is used as the host of the new DomRange, and `component.dom` is set to the new DomRange. Otherwise, the new DomRange serves as its own host and receives a `dom` property pointing to itself. - -....... - -### Representation - -DomRange is not a tight abstraction over the DOM, it's more of a tool or machine, so it's helpful to understand how it operates before using it. Meteor application developers do *not* need to know this level of detail unless they are doing a fair amount of custom DOM manipulation. - -DomRange uses two empty text nodes as `start` and `end` markers. (In IE 8, they must be comment nodes.) Empty text nodes are allowed in a wide variety of DOM positions and do not affect how the DOM is displayed by the browser. In addition, text nodes are largely ignored by libraries like jQuery and inspectors like Chrome Dev Tools. We expect that even if a DomRange's member elements (and member elements of its descendants) are arbitrarily moved or removed without the DomRange's knowledge, the start/end markers will still be present, though their locations may carry little meaning. (It's also possible the markers will be removed completely, for example if innerHTML is set on the parent element.) - -Because a DomRange points to all its members, the start/end markers are not needed to traverse the contents of a DomRange or define which nodes it contains. However, accurate markers are needed for DomRange methods that add or move members relative to other members, and the markers will always be accurate in the absence of foreign DOM manipulation (like elements being added, moved, or removed by jQuery). An operation called **refresh** repositions a DomRange's start/end markers based on the positions of its members. A DomRange is refreshed automatically at certain times when its nodes are required to be consecutive or accurate markers are important. In some cases where nodes are reordered outside of DomRange, a manual call to refresh may be necessary as well. - -#### Details of Refresh - -Refreshing a DomRange generally causes it to "follow" its elements. For example, if DomRange A contains B, which contains C, which contains a div, the initial, clean DOM will have three start markers, then the div, then three end markers. If the div is moved to a different position (under the same parent node), refreshing A will cause all three start markers and all three end markers to snap into place around the relocated div. - -An automatic refresh happens: - -* When a range is removed -* When a range is moved (by its owner range) -* When a range is inserted into the document for the first time -* When a range's start marker is needed to determine the position of an added or moved sibling member - -An automatic refresh does *not* happen when adding or moving a member to the end of a range, even though the end marker is used. We don't want to refresh an entire list just to add a member to the end. This means that if you have a DomRange with labeled members (e.g. an "each") and you perform foreign DOM manipulation that may make the end marker inaccurate (e.g. by moving elements to the end of the parent element), you should manually refresh the DomRange after doing the manipulation. - -The refresh algorithm is as follows: - -* Recursively refresh all member ranges -* Find the first and last node that is either a member node (but not a text node with only whitespace) or a marker of a member range -* Move the start and end markers to just before the first such node and just after the last such node -* Nodes found between members that don't belong to any DomRange may be "adopted" and made members. This allows foreign-inserted nodes to be moved or removed along with their surroundings. diff --git a/packages/ui/base.js b/packages/ui/base.js deleted file mode 100644 index fe18942dd4..0000000000 --- a/packages/ui/base.js +++ /dev/null @@ -1,412 +0,0 @@ -UI = {}; - -// A very basic operation like Underscore's `_.extend` that -// copies `src`'s own, enumerable properties onto `tgt` and -// returns `tgt`. -_extend = function (tgt, src) { - for (var k in src) - if (src.hasOwnProperty(k)) - tgt[k] = src[k]; - return tgt; -}; - -// Defines a single non-enumerable, read-only property -// on `tgt`. -// It won't be non-enumerable in IE 8, so its -// non-enumerability can't be relied on for logic -// purposes, it just makes things prettier in -// the dev console. -var _defineNonEnum = function (tgt, name, value) { - try { - Object.defineProperty(tgt, name, {value: value}); - } catch (e) { - // IE < 9 - tgt[name] = value; - } - return tgt; -}; - -// Named function (like `function Component() {}` below) make -// inspection in debuggers more descriptive. In IE, this sets the -// value of the `Component` var in the function scope in which it's -// executed. We already have a top-level `Component` var so we create -// a new function scope to not write it over in IE. -(function () { - - // Components and Component kinds are the same thing, just - // objects; there are no constructor functions, no `new`, - // and no `instanceof`. A Component object is like a class, - // until it is inited, at which point it becomes more like - // an instance. - // - // `y = x.extend({ ...new props })` creates a new Component - // `y` with `x` as its prototype, plus additional properties - // on `y` itself. `extend` is used both to subclass and to - // create instances (and the hope is we can gloss over the - // difference in the docs). - UI.Component = (function (constr) { - - // Make sure the "class name" that Chrome infers for - // UI.Component is "Component", and that - // `new UI.Component._constr` (which is what `extend` - // does) also produces objects whose inferred class - // name is "Component". Chrome's name inference rules - // are a little mysterious, but a function name in - // the source code (as in `function Component() {}`) - // seems to be reliable and high precedence. - var C = new constr; - _defineNonEnum(C, '_constr', constr); - _defineNonEnum(C, '_super', null); - return C; - })(function Component() {}); -})(); - -_extend(UI, { - nextGuid: 2, // Component is 1! - - isComponent: function (obj) { - return obj && UI.isKindOf(obj, UI.Component); - }, - // `UI.isKindOf(a, b)` where `a` and `b` are Components - // (or kinds) asks if `a` is or descends from - // (transitively extends) `b`. - isKindOf: function (a, b) { - while (a) { - if (a === b) - return true; - a = a._super; - } - return false; - }, - // use these to produce error messages for developers - // (though throwing a more specific error message is - // even better) - _requireNotDestroyed: function (c) { - if (c.isDestroyed) - throw new Error("Component has been destroyed; can't perform this operation"); - }, - _requireInited: function (c) { - if (! c.isInited) - throw new Error("Component must be inited to perform this operation"); - }, - _requireDom: function (c) { - if (! c.dom) - throw new Error("Component must be built into DOM to perform this operation"); - } -}); - -Component = UI.Component; - -_extend(UI.Component, { - kind: "Component", - guid: "1", - dom: null, - // Has this Component ever been inited? - isInited: false, - // Has this Component been destroyed? Only inited Components - // can be destroyed. - isDestroyed: false, - // Component that created this component (typically also - // the DOM containment parent). - // No child pointers (except in `dom`). - parent: null, - - // create a new subkind or instance whose proto pointer - // points to this, with additional props set. - extend: function (props) { - // this function should never cause `props` to be - // mutated in case people want to reuse `props` objects - // in a mixin-like way. - - if (this.isInited) - // Disallow extending inited Components so that - // inited Components don't inherit instance-specific - // properties from other inited Components, just - // default values. - throw new Error("Can't extend an inited Component"); - - var constr; - var constrMade = false; - if (props && props.kind) { - // If `kind` is different from super, set a constructor. - // We used to set the function name here so that components - // printed better in the console, but we took it out because - // of CSP (and in hopes that Chrome finally adds proper - // displayName support). - constr = function () {}; - constrMade = true; - } else { - constr = this._constr; - } - - // We don't know where we're getting `constr` from -- - // it might be from some supertype -- just that it has - // the right function name. So set the `prototype` - // property each time we use it as a constructor. - constr.prototype = this; - - var c = new constr; - if (constrMade) - c._constr = constr; - - if (props) - _extend(c, props); - - // for efficient Component instantiations, we assign - // as few things as possible here. - _defineNonEnum(c, '_super', this); - c.guid = String(UI.nextGuid++); - - return c; - } -}); - -//callChainedCallback = function (comp, propName, orig) { - // Call `comp.foo`, `comp._super.foo`, - // `comp._super._super.foo`, and so on, but in reverse - // order, and only if `foo` is an "own property" in each - // case. Furthermore, the passed value of `this` should - // remain `comp` for all calls (which is achieved by - // filling in `orig` when recursing). -// if (comp._super) -// callChainedCallback(comp._super, propName, orig || comp); -// -// if (comp.hasOwnProperty(propName)) -// comp[propName].call(orig || comp); -//}; - - -// Returns 0 if the nodes are the same or either one contains the other; -// otherwise, -1 if a comes before b, or else 1 if b comes before a in -// document order. -// Requires: `a` and `b` are element nodes in the same document tree. -var compareElementIndex = function (a, b) { - // See http://ejohn.org/blog/comparing-document-position/ - if (a === b) - return 0; - if (a.compareDocumentPosition) { - var n = a.compareDocumentPosition(b); - return ((n & 0x18) ? 0 : ((n & 0x4) ? -1 : 1)); - } else { - // Only old IE is known to not have compareDocumentPosition (though Safari - // originally lacked it). Thankfully, IE gives us a way of comparing elements - // via the "sourceIndex" property. - if (a.contains(b) || b.contains(a)) - return 0; - return (a.sourceIndex < b.sourceIndex ? -1 : 1); - } -}; - -findComponentWithProp = function (id, comp) { - while (comp) { - if (typeof comp[id] !== 'undefined') - return comp; - comp = comp.parent; - } - return null; -}; - -// Look up the component's chain of parents until we find one with -// `__helperHost` set (a component that can have helpers defined on it, -// i.e. a template). -var findHelperHostComponent = function (comp) { - while (comp) { - if (comp.__helperHost) { - return comp; - } - comp = comp.parent; - } - return null; -}; - -findComponentWithHelper = function (id, comp) { - while (comp) { - if (comp.__helperHost) { - if (typeof comp[id] !== 'undefined') - return comp; - else - return null; - } - comp = comp.parent; - } - return null; -}; - -getComponentData = function (comp) { - comp = findComponentWithProp('data', comp); - return (comp ? - (typeof comp.data === 'function' ? - comp.data() : comp.data) : - null); -}; - -updateTemplateInstance = function (comp) { - // Populate `comp.templateInstance.{firstNode,lastNode,data}` - // on demand. - var tmpl = comp.templateInstance; - tmpl.data = getComponentData(comp); - - if (comp.dom && !comp.isDestroyed) { - tmpl.firstNode = comp.dom.startNode().nextSibling; - tmpl.lastNode = comp.dom.endNode().previousSibling; - // Catch the case where the DomRange is empty and we'd - // otherwise pass the out-of-order nodes (end, start) - // as (firstNode, lastNode). - if (tmpl.lastNode && tmpl.lastNode.nextSibling === tmpl.firstNode) - tmpl.lastNode = tmpl.firstNode; - } else { - // on 'created' or 'destroyed' callbacks we don't have a DomRange - tmpl.firstNode = null; - tmpl.lastNode = null; - } -}; - -_extend(UI.Component, { - // We implement the old APIs here, including how data is passed - // to helpers in `this`. - helpers: function (dict) { - _extend(this, dict); - }, - events: function (dict) { - var events; - if (this.hasOwnProperty('_events')) - events = this._events; - else - events = (this._events = []); - - _.each(dict, function (handler, spec) { - var clauses = spec.split(/,\s+/); - // iterate over clauses of spec, e.g. ['click .foo', 'click .bar'] - _.each(clauses, function (clause) { - var parts = clause.split(/\s+/); - if (parts.length === 0) - return; - - var newEvents = parts.shift(); - var selector = parts.join(' '); - events.push({events: newEvents, - selector: selector, - handler: handler}); - }); - }); - } -}); - -// XXX we don't really want this to be a user-visible callback, -// it's just a particular signal we need from DomRange. -UI.Component.notifyParented = function () { - var self = this; - for (var comp = self; comp; comp = comp._super) { - var events = (comp.hasOwnProperty('_events') && comp._events) || null; - if ((! events) && comp.hasOwnProperty('events') && - typeof comp.events === 'object') { - // Provide limited back-compat support for `.events = {...}` - // syntax. Pass `comp.events` to the original `.events(...)` - // function. This code must run only once per component, in - // order to not bind the handlers more than once, which is - // ensured by the fact that we only do this when `comp._events` - // is falsy, and we cause it to be set now. - UI.Component.events.call(comp, comp.events); - events = comp._events; - } - _.each(events, function (esh) { // {events, selector, handler} - // wrap the handler here, per instance of the template that - // declares the event map, so we can pass the instance to - // the event handler. - var wrappedHandler = function (event) { - var comp = UI.DomRange.getContainingComponent(event.currentTarget); - var data = comp && getComponentData(comp); - var args = _.toArray(arguments); - updateTemplateInstance(self); - return Deps.nonreactive(function () { - // put self.templateInstance as the second argument - args.splice(1, 0, self.templateInstance); - // Don't want to be in a deps context, even if we were somehow - // triggered synchronously in an existing deps context - // (the `blur` event can do this). - // XXX we should probably do what Spark did and block all - // event handling during our DOM manip. Many apps had weird - // unanticipated bugs until we did that. - return esh.handler.apply(data === null ? {} : data, args); - }); - }; - - self.dom.on(esh.events, esh.selector, wrappedHandler); - }); - } - - if (self.rendered) { - // Defer rendered callback until flush time. - Deps.afterFlush(function () { - if (! self.isDestroyed) { - updateTemplateInstance(self); - self.rendered.call(self.templateInstance); - } - }); - } -}; - -// past compat -UI.Component.preserve = function () { - Meteor._debug("The 'preserve' method on templates is now unnecessary and deprecated."); -}; - -// Gets the data context of the enclosing component that rendered a -// given element -UI.getElementData = function (el) { - var comp = UI.DomRange.getContainingComponent(el); - return comp && getComponentData(comp); -}; - -var jsUrlsAllowed = false; -UI._allowJavascriptUrls = function () { - jsUrlsAllowed = true; -}; -UI._javascriptUrlsAllowed = function () { - return jsUrlsAllowed; -}; - -UI._templateInstance = function () { - var currentComp = currentComponent.get(); - if (! currentComp) { - throw new Error("You can only call UI._templateInstance() from within" + - " a helper function."); - } - - // Find the enclosing component that is a template. (`currentComp` - // could be, for example, an #if or #with, and we want the component - // that is the surrounding template.) - var template = findHelperHostComponent(currentComp); - if (! template) { - throw new Error("Current component is not inside a template?"); - } - - // Lazily update the template instance for this helper, and do it only - // once. - if (! currentTemplateInstance) { - updateTemplateInstance(template); - currentTemplateInstance = template.templateInstance; - } - return currentTemplateInstance; -}; - -// Returns the data context of the parent which is 'numLevels' above the -// component. Same behavior as {{../..}} in a template, with 'numLevels' -// occurrences of '..'. -UI._parentData = function (numLevels) { - var component = currentComponent.get(); - while (component && numLevels >= 0) { - // Decrement numLevels every time we find a new data context. Break - // once we have reached numLevels < 0. - if (component.data !== undefined && --numLevels < 0) { - break; - } - component = component.parent; - } - - if (! component) { - return null; - } - - return getComponentData(component); -}; diff --git a/packages/ui/base_tests.js b/packages/ui/base_tests.js deleted file mode 100644 index 9965fc5944..0000000000 --- a/packages/ui/base_tests.js +++ /dev/null @@ -1,214 +0,0 @@ - -/* -THESE TESTS ARE OUT OF DATE. - -TODO: WRITE TESTS AGAINST THE LATEST base.js - -Tinytest.add("ui - Component basics", function (test) { - var Foo = UI.Component.extend(); - var Bar = Foo.extend({x: 1, y: 2}); - var Baz = Bar.extend({y: 3, z: 4}); - - test.equal(typeof Foo.x, 'undefined'); - test.equal(typeof Foo.y, 'undefined'); - test.equal(typeof Foo.z, 'undefined'); - test.equal(Bar.x, 1); - test.equal(Bar.y, 2); - test.equal(typeof Bar.z, 'undefined'); - test.equal(Baz.x, 1); - test.equal(Baz.y, 3); - test.equal(Baz.z, 4); - - // _super - - test.equal(Foo._super, UI.Component); - test.equal(Bar._super, Foo); - test.equal(Baz._super, Bar); - - // isa - - test.isTrue(UI.isComponent(UI.Component)); - test.isTrue(UI.isComponent(Foo)); - test.isTrue(UI.isComponent(Bar)); - test.isTrue(UI.isComponent(Baz)); - test.isTrue(UI.Component.isa(UI.Component)); - test.isFalse(UI.Component.isa(Foo)); - test.isFalse(UI.Component.isa(Bar)); - test.isFalse(UI.Component.isa(Baz)); - test.isTrue(Foo.isa(UI.Component)); - test.isTrue(Foo.isa(Foo)); - test.isFalse(Foo.isa(Bar)); - test.isFalse(Foo.isa(Baz)); - test.isTrue(Bar.isa(UI.Component)); - test.isTrue(Bar.isa(Foo)); - test.isTrue(Bar.isa(Bar)); - test.isFalse(Bar.isa(Baz)); - test.isTrue(Baz.isa(UI.Component)); - test.isTrue(Baz.isa(Foo)); - test.isTrue(Baz.isa(Bar)); - test.isTrue(Baz.isa(Baz)); - - test.isFalse(UI.isComponent({})); - test.isFalse(UI.isComponent(null)); - test.isFalse(UI.isComponent()); - test.isFalse(UI.isComponent(function () {})); - test.isFalse(Foo.isa({})); - test.isFalse(Foo.isa(null)); - test.isFalse(Foo.isa()); - test.isFalse(Foo.isa(function () {})); - - // guid - - var a = UI.Component.guid, - b = Foo.guid, - c = Bar.guid, - d = Baz.guid; - - test.isTrue(a > 0); - test.isTrue(b > 0); - test.isTrue(c > 0); - test.isTrue(d > 0); - test.isTrue(a !== b); - test.isTrue(a !== c); - test.isTrue(a !== d); - test.isTrue(b !== c); - test.isTrue(b !== d); - test.isTrue(c !== d); -}); - -Tinytest.add("ui - Component init/destroy", function (test) { - var buf = []; - - var x = UI.Component.extend({ - init: function () { - test.isTrue(this.isInited); - test.isFalse(this.isAssembled); - test.isFalse(this.isDestroyed); - buf.push('init'); - }, - destroyed: function () { - test.isTrue(this.isInited); - test.isFalse(this.isAssembled); - test.isTrue(this.isDestroyed); - buf.push('destroyed'); - } - }); - test.isFalse(this.isInited); - test.isFalse(this.isAssembled); - test.isFalse(this.isDestroyed); - test.equal(buf, []); - x.makeRoot(); - test.equal(buf, ['init']); - x.destroy(); - test.equal(buf, ['init', 'destroyed']); - - buf.length = 0; - x = UI.Component.extend({ - init: function () { buf.push('init'); }, - destroyed: function () { buf.push('destroyed'); } - }); - test.throws(function () { - x.destroy(); - }); - x.makeRoot(); - test.throws(function () { - x.makeRoot(); - }); - test.throws(function () { - var y = x.extend(); - }); - test.equal(buf, ['init']); - x.destroy(); - x.destroy(); - test.equal(buf, ['init', 'destroyed']); - test.throws(function () { - x.makeRoot(); - }); - test.throws(function () { - var y = x.extend(); - }); - - buf.length = 0; - x = UI.Component.extend({ - init: function () { buf.push('init'); }, - destroyed: function () { buf.push('destroyed'); } - }); - var y = x.extend({ - init: function () { buf.push('init2'); }, - destroyed: function () { buf.push('destroyed2'); } - }); - test.equal(buf, []); - y.makeRoot(); - test.equal(buf, ['init', 'init2']); - y.destroy(); - test.equal(buf, ['init', 'init2', 'destroyed', 'destroyed2']); - - buf.length = 0; - var z = x.extend(); - z.makeRoot(); - z.destroy(); - test.equal(buf, ['init', 'destroyed']); -}); - -Tinytest.add("ui - Component add/remove", function (test) { - var x = UI.Component.extend(); - var y = UI.Component.extend(); - - test.throws(function () { - x.add(y); - }); - - test.isFalse(x.isInited); - x.makeRoot(); - test.isTrue(x.isInited); - test.isFalse(y.isInited); - test.isFalse(x.hasChild(y)); - test.equal(_.keys(x.children), []); - test.equal(_.keys(y.children), []); - - x.add(y); - test.isTrue(y.isInited); - test.equal(y.parent, x); - test.isTrue(x.hasChild(y)); - test.isFalse(y.hasChild(x)); - test.equal(_.keys(x.children), [String(y.guid)]); - test.equal(_.keys(y.children), []); - test.equal(x.children[y.guid], y); - - var z = UI.Component.extend(); - x.add(z); - test.isTrue(z.isInited); - test.equal(z.parent, x); - test.isTrue(x.hasChild(z)); - test.isFalse(z.hasChild(x)); - test.equal(_.keys(x.children).sort(), - [String(y.guid), String(z.guid)].sort()); - test.equal(_.keys(z.children), []); - test.equal(x.children[z.guid], z); - - x.remove(y); - z.remove(); - test.isFalse(x.hasChild(y)); - test.isFalse(x.hasChild(z)); - test.equal(_.keys(x.children), []); - // children are destroyed - test.isTrue(y.isDestroyed); - test.isTrue(z.isDestroyed); - // parent pointers remain - test.equal(y.parent, x); - test.equal(z.parent, x); - test.throws(function () { - y.remove(); - }); - test.throws(function () { - z.remove(); - }); - test.throws(function () { - x.remove(y); - }); - test.throws(function () { - x.remove(z); - }); -}); - -*/ \ No newline at end of file diff --git a/packages/ui/builtins.js b/packages/ui/builtins.js deleted file mode 100644 index 2ba0da6f48..0000000000 --- a/packages/ui/builtins.js +++ /dev/null @@ -1,126 +0,0 @@ - -UI.If = function (argFunc, contentBlock, elseContentBlock) { - checkBlockHelperArguments('If', argFunc, contentBlock, elseContentBlock); - - var f = function () { - var emboxedCondition = emboxCondition(argFunc); - f.stop = function () { - emboxedCondition.stop(); - }; - if (emboxedCondition()) - return contentBlock; - else - return elseContentBlock || null; - }; - - return f; -}; - - -UI.Unless = function (argFunc, contentBlock, elseContentBlock) { - checkBlockHelperArguments('Unless', argFunc, contentBlock, elseContentBlock); - - var f = function () { - var emboxedCondition = emboxCondition(argFunc); - f.stop = function () { - emboxedCondition.stop(); - }; - if (! emboxedCondition()) - return contentBlock; - else - return elseContentBlock || null; - }; - - return f; -}; - -// Returns true if `a` and `b` are `===`, unless they are of a mutable type. -// (Because then, they may be equal references to an object that was mutated, -// and we'll never know. We save only a reference to the old object; we don't -// do any deep-copying or diffing.) -UI.safeEquals = function (a, b) { - if (a !== b) - return false; - else - return ((!a) || (typeof a === 'number') || (typeof a === 'boolean') || - (typeof a === 'string')); -}; - -// Unlike Spacebars.With, there's no else case and no conditional logic. -// -// We don't do any reactive emboxing of `argFunc` here; it should be done -// by the caller if efficiency and/or number of calls to the data source -// is important. -UI.With = function (argFunc, contentBlock) { - checkBlockHelperArguments('With', argFunc, contentBlock); - - var block = contentBlock; - if ('data' in block) { - // XXX TODO: get religion about where `data` property goes - block = UI.block(function () { - return contentBlock; - }); - } - - block.data = function () { - throw new Error("Can't get data for component kind"); - }; - - block.init = function () { - this.data = UI.emboxValue(argFunc, UI.safeEquals); - }; - - block.materialized = function () { - var self = this; - if (Deps.active) { - Deps.onInvalidate(function () { - self.data.stop(); - }); - } - }; - block.materialized.isWith = true; - - return block; -}; - -UI.Each = function (argFunc, contentBlock, elseContentBlock) { - checkBlockHelperArguments('Each', argFunc, contentBlock, elseContentBlock); - - return UI.EachImpl.extend({ - __sequence: argFunc, - __content: contentBlock, - __elseContent: elseContentBlock - }); -}; - -var checkBlockHelperArguments = function (which, argFunc, contentBlock, elseContentBlock) { - if (typeof argFunc !== 'function') - throw new Error('First argument to ' + which + ' must be a function'); - if (! UI.isComponent(contentBlock)) - throw new Error('Second argument to ' + which + ' must be a template or UI.block'); - if (elseContentBlock && ! UI.isComponent(elseContentBlock)) - throw new Error('Third argument to ' + which + ' must be a template or UI.block if present'); -}; - -// Returns a function that computes `!! conditionFunc()` except: -// -// - Empty array is considered falsy -// - The result is UI.emboxValue'd (doesn't trigger invalidation -// as long as the condition stays truthy or stays falsy) -var emboxCondition = function (conditionFunc) { - return UI.namedEmboxValue('if/unless', function () { - // `condition` is emboxed; it is always a function, - // and it only triggers invalidation if its return - // value actually changes. We still need to isolate - // the calculation of whether it is truthy or falsy - // in order to not re-render if it changes from one - // truthy or falsy value to another. - var cond = conditionFunc(); - - // empty arrays are treated as falsey values - if (cond instanceof Array && cond.length === 0) - return false; - else - return !! cond; - }); -}; diff --git a/packages/ui/dombackend.js b/packages/ui/dombackend.js deleted file mode 100644 index 2d291b7a26..0000000000 --- a/packages/ui/dombackend.js +++ /dev/null @@ -1,145 +0,0 @@ -if (Meteor.isClient) { - - // XXX in the future, make the jQuery adapter a separate - // package and make the choice of back-end library - // configurable. Adapters all expose the same DomBackend interface. - - if (! Package.jquery) - throw new Error("Meteor UI jQuery adapter: jQuery not found."); - - var $jq = Package.jquery.jQuery; - - var DomBackend = {}; - UI.DomBackend = DomBackend; - - ///// Removal detection and interoperability. - - // For an explanation of this technique, see: - // http://bugs.jquery.com/ticket/12213#comment:23 . - // - // In short, an element is considered "removed" when jQuery - // cleans up its *private* userdata on the element, - // which we can detect using a custom event with a teardown - // hook. - - var JQUERY_REMOVAL_WATCHER_EVENT_NAME = 'meteor_ui_removal_watcher'; - var REMOVAL_CALLBACKS_PROPERTY_NAME = '$meteor_ui_removal_callbacks'; - var NOOP = function () {}; - - // Causes `elem` (a DOM element) to be detached from its parent, if any. - // Whether or not `elem` was detached, causes any callbacks registered - // with `onElementTeardown` on `elem` and its descendants to fire. - // Not for use on non-element nodes. - // - // This method is modeled after the behavior of jQuery's `$(elem).remove()`, - // which causes teardown on the subtree being removed. - DomBackend.removeElement = function (elem) { - $jq(elem).remove(); - }; - - DomBackend.tearDownElement = function (elem) { - var elems = _.toArray(elem.getElementsByTagName('*')); - elems.push(elem); - $jq.cleanData(elems); - }; - - // Registers a callback function to be called when the given element or - // one of its ancestors is removed from the DOM via the backend library. - // The callback function is called at most once, and it receives the element - // in question as an argument. - DomBackend.onElementTeardown = function (elem, func) { - if (! elem[REMOVAL_CALLBACKS_PROPERTY_NAME]) { - elem[REMOVAL_CALLBACKS_PROPERTY_NAME] = []; - - // Set up the event, only the first time. - $jq(elem).on(JQUERY_REMOVAL_WATCHER_EVENT_NAME, NOOP); - } - - elem[REMOVAL_CALLBACKS_PROPERTY_NAME].push(func); - }; - - $jq.event.special[JQUERY_REMOVAL_WATCHER_EVENT_NAME] = { - teardown: function() { - var elem = this; - var callbacks = elem[REMOVAL_CALLBACKS_PROPERTY_NAME]; - if (callbacks) { - for (var i = 0; i < callbacks.length; i++) - callbacks[i](elem); - elem[REMOVAL_CALLBACKS_PROPERTY_NAME] = null; - } - } - }; - - DomBackend.parseHTML = function (html) { - // Return an array of nodes. - // - // jQuery does fancy stuff like creating an appropriate - // container element and setting innerHTML on it, as well - // as working around various IE quirks. - return $jq.parseHTML(html) || []; - }; - - // Must use jQuery semantics for `context`, not - // querySelectorAll's. In other words, all the parts - // of `selector` must be found under `context`. - DomBackend.findBySelector = function (selector, context) { - return $jq(selector, context); - }; - - DomBackend.newFragment = function (nodeArray) { - var frag = document.createDocumentFragment(); - for (var i = 0; i < nodeArray.length; i++) - frag.appendChild(nodeArray[i]); - return frag; - }; - - // `selector` is non-null. `type` is one type (but - // may be in backend-specific form, e.g. have namespaces). - // Order fired must be order bound. - DomBackend.delegateEvents = function (elem, type, selector, handler) { - $jq(elem).on(type, selector, handler); - }; - - DomBackend.undelegateEvents = function (elem, type, handler) { - $jq(elem).off(type, handler); - }; - - DomBackend.bindEventCapturer = function (elem, type, selector, handler) { - var $elem = $jq(elem); - - var wrapper = function (event) { - event = $jq.event.fix(event); - event.currentTarget = event.target; - - // Note: It might improve jQuery interop if we called into jQuery - // here somehow. Since we don't use jQuery to dispatch the event, - // we don't fire any of jQuery's event hooks or anything. However, - // since jQuery can't bind capturing handlers, it's not clear - // where we would hook in. Internal jQuery functions like `dispatch` - // are too high-level. - var $target = $jq(event.currentTarget); - if ($target.is($elem.find(selector))) - handler.call(elem, event); - }; - - handler._meteorui_wrapper = wrapper; - - type = this.parseEventType(type); - // add *capturing* event listener - elem.addEventListener(type, wrapper, true); - }; - - DomBackend.unbindEventCapturer = function (elem, type, handler) { - type = this.parseEventType(type); - elem.removeEventListener(type, handler._meteorui_wrapper, true); - }; - - DomBackend.parseEventType = function (type) { - // strip off namespaces - var dotLoc = type.indexOf('.'); - if (dotLoc >= 0) - return type.slice(0, dotLoc); - return type; - }; - -} diff --git a/packages/ui/dombackend_tests.js b/packages/ui/dombackend_tests.js deleted file mode 100644 index 5799e3c030..0000000000 --- a/packages/ui/dombackend_tests.js +++ /dev/null @@ -1,123 +0,0 @@ - -var runDivSpanBTest = function (func) { - // Common code - - var div = document.createElement("DIV"); - var span = document.createElement("SPAN"); - var b = document.createElement("B"); - div.appendChild(span); - span.appendChild(b); - - var buf = []; - - var func1 = function (elem) { buf.push(elem.nodeName + "1"); }; - var func2 = function (elem) { buf.push(elem.nodeName + "2"); }; - var func3 = function (elem) { buf.push(elem.nodeName + "3"); }; - var func4 = function (elem) { buf.push(elem.nodeName + "4"); }; - - func(div, span, b, buf, func1, func2, func3, func4); -}; - -var DomBackend = UI.DomBackend; - -// Essentially test that the `node` argument has been removed from the -// DOM. The caveat is that in IE8, calling `removeChild` leaves the -// removed child with a document fragment parent, which itself has no -// parent. -var isDetachedSingleNode = function (test, node) { - if (!node.parentNode) { - test.ok(); - } else { - test.equal(node.parentNode.nodeName, '#document-fragment'); - test.equal(node.parentNode.childNodes.length, 1); - test.equal(node.parentNode.parentNode, null); - } -}; - -Tinytest.add("ui - DomBackend - element removal", function (test) { - // Test that calling removeElement on a detached element calls onElementTeardown - // on it and its descendents. For jQuery, `removeElement` runs `$(elem).remove()`, - // so it tests detecting a jQuery removal, as well as the stronger condition - // that clean-up still happens on the DOM tree in the detached case. - runDivSpanBTest(function (div, span, b, buf, func1, func2, func3, func4) { - DomBackend.onElementTeardown(div, func1); - DomBackend.onElementTeardown(span, func2); - DomBackend.onElementTeardown(b, func3); - // test second callback on same element - DomBackend.onElementTeardown(div, func4); - - DomBackend.removeElement(div); // "remove" the (parentless) DIV - - buf.sort(); - test.equal(buf, ["B3", "DIV1", "DIV4", "SPAN2"]); - - buf.length = 0; - DomBackend.removeElement(div); - test.equal(buf.length, 0); - }); - - // Test that `removeElement` actually removes the element - // (and fires appropriate callbacks). - runDivSpanBTest(function (div, span, b, buf, func1, func2, func3, func4) { - DomBackend.onElementTeardown(div, func1); - DomBackend.onElementTeardown(span, func2); - DomBackend.onElementTeardown(b, func3); - DomBackend.onElementTeardown(div, func4); - - DomBackend.removeElement(span); // remove the SPAN - - test.equal(div.childNodes.length, 0); - isDetachedSingleNode(test, span); - - buf.sort(); - test.equal(buf, ["B3", "SPAN2"]); - - buf.length = 0; - DomBackend.removeElement(div); // remove the DIV - test.equal(buf, ["DIV1", "DIV4"]); - }); - -}); - -Tinytest.add("ui - DomBackend - element removal (jQuery)", function (test) { - - // Test with `$(elem).remove()`. - runDivSpanBTest(function (div, span, b, buf, func1, func2, func3, func4) { - DomBackend.onElementTeardown(div, func1); - DomBackend.onElementTeardown(span, func2); - DomBackend.onElementTeardown(b, func3); - DomBackend.onElementTeardown(div, func4); - - $(span).remove(); // remove the SPAN - - test.equal(div.childNodes.length, 0); - isDetachedSingleNode(test, span); - - buf.sort(); - test.equal(buf, ["B3", "SPAN2"]); - - buf.length = 0; - $(div).remove(); // "remove" the DIV - test.equal(buf, ["DIV1", "DIV4"]); - }); - - // Test that `$(elem).detach()` is NOT considered a removal. - runDivSpanBTest(function (div, span, b, buf, func1, func2, func3, func4) { - DomBackend.onElementTeardown(div, func1); - DomBackend.onElementTeardown(span, func2); - DomBackend.onElementTeardown(b, func3); - DomBackend.onElementTeardown(div, func4); - - $(span).detach(); // detach the SPAN - - test.equal(div.childNodes.length, 0); - isDetachedSingleNode(test, span); - - test.equal(buf, []); - - buf.length = 0; - $(div).detach(); // "detach" the DIV - test.equal(buf, []); - }); - -}); diff --git a/packages/ui/domrange.js b/packages/ui/domrange.js deleted file mode 100644 index a875b759de..0000000000 --- a/packages/ui/domrange.js +++ /dev/null @@ -1,1085 +0,0 @@ -// TODO -// - Lazy removal detection -// - UI hooks (expose, test) -// - Quick remove/add (mark "leaving" members; needs UI hooks) -// - Event removal on removal - -var DomBackend = UI.DomBackend; - -var removeNode = function (n) { - if (n.nodeType === 1 && - n.parentNode._uihooks && n.parentNode._uihooks.removeElement) { - n.parentNode._uihooks.removeElement(n); - } else { - n.parentNode.removeChild(n); - } -}; - -var insertNode = function (n, parent, next) { - // `|| null` because IE throws an error if 'next' is undefined - next = next || null; - if (n.nodeType === 1 && - parent._uihooks && parent._uihooks.insertElement) { - parent._uihooks.insertElement(n, next); - } else { - parent.insertBefore(n, next); - } -}; - -var moveNode = function (n, parent, next) { - // `|| null` because IE throws an error if 'next' is undefined - next = next || null; - if (n.nodeType === 1 && - parent._uihooks && parent._uihooks.moveElement) { - parent._uihooks.moveElement(n, next); - } else { - parent.insertBefore(n, next); - } -}; - -// A very basic operation like Underscore's `_.extend` that -// copies `src`'s own, enumerable properties onto `tgt` and -// returns `tgt`. -var _extend = function (tgt, src) { - for (var k in src) - if (src.hasOwnProperty(k)) - tgt[k] = src[k]; - return tgt; -}; - -var _contains = function (list, item) { - if (! list) - return false; - for (var i = 0, N = list.length; i < N; i++) - if (list[i] === item) - return true; - return false; -}; - -var isArray = function (x) { - return !!((typeof x.length === 'number') && - (x.sort || x.splice)); -}; - -// Text nodes consisting of only whitespace -// are "insignificant" nodes. -var isSignificantNode = function (n) { - return ! (n.nodeType === 3 && - (! n.nodeValue || - /^\s+$/.test(n.nodeValue))); -}; - -var checkId = function (id) { - if (typeof id !== 'string') - throw new Error("id must be a string"); - if (! id) - throw new Error("id may not be empty"); -}; - -var textExpandosSupported = (function () { - var tn = document.createTextNode(''); - try { - tn.blahblah = true; - return true; - } catch (e) { - // IE 8 - return false; - } -})(); - -var createMarkerNode = ( - textExpandosSupported ? - function () { return document.createTextNode(""); } : - function () { return document.createComment("IE"); }); - -var rangeParented = function (range) { - if (! range.isParented) { - range.isParented = true; - - if (! range.owner) { - // top-level (unowned) ranges in an element, - // keep a pointer to the range on the parent - // element. This is really just for IE 9+ - // TextNode GC issues, but we can't do reliable - // feature detection (i.e. bug detection). - var parentNode = range.parentNode(); - var rangeDict = ( - parentNode.$_uiranges || - (parentNode.$_uiranges = {})); - rangeDict[range._rangeId] = range; - range._rangeDict = rangeDict; - - // get jQuery to tell us when this node is removed - DomBackend.onElementTeardown(parentNode, function () { - rangeRemoved(range, true /* alreadyTornDown */); - }); - } - - if (range.component && range.component.notifyParented) - range.component.notifyParented(); - - // recurse on member ranges - var members = range.members; - for (var k in members) { - var mem = members[k]; - if (mem instanceof DomRange) - rangeParented(mem); - } - } -}; - -var rangeRemoved = function (range, alreadyTornDown) { - if (! range.isRemoved) { - range.isRemoved = true; - - if (range._rangeDict) - delete range._rangeDict[range._rangeId]; - - // clean up events - if (range.stopHandles) { - for (var i = 0; i < range.stopHandles.length; i++) - range.stopHandles[i].stop(); - range.stopHandles = null; - } - - // notify component of removal - if (range.removed) - range.removed(); - - membersRemoved(range, alreadyTornDown); - } -}; - -var nodeRemoved = function (node, alreadyTornDown) { - if (node.nodeType === 1) { // ELEMENT - var comps = DomRange.getComponents(node); - for (var i = 0, N = comps.length; i < N; i++) - rangeRemoved(comps[i], true /* alreadyTornDown */); - - // `alreadyTornDown` is an optimization so that we don't - // tear down the same elements multiple times when tearing - // down a tree of DomRanges and elements, leading to asymptotic - // inefficiency. - // - // When jQuery removes an element or DomBackend.tearDownElement - // is called, the DOM is "cleaned" recursively, calling all - // onElementTearDown handlers on the entire DOM subtree. - // Since the entire subtree is already walked, we don't want to - // also walk the subtrees of each DomRange for teardown purposes. - if (! alreadyTornDown) - DomBackend.tearDownElement(node); - } -}; - -var membersRemoved = function (range, alreadyTornDown) { - var members = range.members; - for (var k in members) { - var mem = members[k]; - if (mem instanceof DomRange) - rangeRemoved(mem, alreadyTornDown); - else - nodeRemoved(mem, alreadyTornDown); - } -}; - -var nextGuid = 1; - -var DomRange = function () { - var start = createMarkerNode(); - var end = createMarkerNode(); - var fragment = DomBackend.newFragment([start, end]); - fragment.$_uiIsOffscreen = true; - - this.start = start; - this.end = end; - start.$ui = this; - end.$ui = this; - - this.members = {}; - this.nextMemberId = 1; - this.owner = null; - this._rangeId = nextGuid++; - this._rangeDict = null; - - this.isParented = false; - this.isRemoved = false; - - this.stopHandles = null; -}; - -_extend(DomRange.prototype, { - getNodes: function () { - if (! this.parentNode()) - return []; - - this.refresh(); - - var afterNode = this.end.nextSibling; - var nodes = []; - for (var n = this.start; - n && n !== afterNode; - n = n.nextSibling) - nodes.push(n); - return nodes; - }, - removeAll: function () { - if (! this.parentNode()) - return; - - this.refresh(); - - // leave start and end - var afterNode = this.end; - var nodes = []; - for (var n = this.start.nextSibling; - n && n !== afterNode; - n = n.nextSibling) { - // don't remove yet since then we'd lose nextSibling - nodes.push(n); - } - for (var i = 0, N = nodes.length; i < N; i++) - removeNode(nodes[i]); - - membersRemoved(this); - - this.members = {}; - }, - // (_nextNode is internal) - add: function (id, newMemberOrArray, beforeId, _nextNode) { - if (id != null && typeof id !== 'string') { - if (typeof id !== 'object') - // a non-object first argument is probably meant - // as an id, NOT a new member, so complain about it - // as such. - throw new Error("id must be a string"); - beforeId = newMemberOrArray; - newMemberOrArray = id; - id = null; - } - - if (! newMemberOrArray || typeof newMemberOrArray !== 'object') - throw new Error("Expected component, node, or array"); - - if (isArray(newMemberOrArray)) { - if (newMemberOrArray.length === 1) { - newMemberOrArray = newMemberOrArray[0]; - } else { - if (id != null) - throw new Error("Can only add one node or one component if id is given"); - var array = newMemberOrArray; - // calculate `nextNode` once in case it involves a refresh - _nextNode = this.getInsertionPoint(beforeId); - for (var i = 0; i < array.length; i++) - this.add(null, array[i], beforeId, _nextNode); - return; - } - } - - var parentNode = this.parentNode(); - // Consider ourselves removed (and don't mind) if - // start marker has no parent. - if (! parentNode) - return; - // because this may call `refresh`, it must be done - // early, before we add the new member. - var nextNode = (_nextNode || - this.getInsertionPoint(beforeId)); - - var newMember = newMemberOrArray; - if (id == null) { - id = this.nextMemberId++; - } else { - checkId(id); - id = ' ' + id; - } - - var members = this.members; - if (members.hasOwnProperty(id)) { - var oldMember = members[id]; - if (oldMember instanceof DomRange) { - // range, does it still exist? - var oldRange = oldMember; - if (oldRange.start.parentNode !== parentNode) { - delete members[id]; - oldRange.owner = null; - rangeRemoved(oldRange); - } else { - throw new Error("Member already exists: " + id.slice(1)); - } - } else { - // node, does it still exist? - var oldNode = oldMember; - if (oldNode.parentNode !== parentNode) { - nodeRemoved(oldNode); - delete members[id]; - } else { - throw new Error("Member already exists: " + id.slice(1)); - } - } - } - - if (newMember instanceof DomRange) { - // Range - var range = newMember; - range.owner = this; - var nodes = range.getNodes(); - - members[id] = newMember; - for (var i = 0; i < nodes.length; i++) - insertNode(nodes[i], parentNode, nextNode); - - if (this.isParented) - rangeParented(range); - } else { - // Node - if (typeof newMember.nodeType !== 'number') - throw new Error("Expected Component or Node"); - var node = newMember; - // can't attach `$ui` to a TextNode in IE 8, so - // don't bother on any browser. - if (node.nodeType !== 3) - node.$ui = this; - - members[id] = newMember; - insertNode(node, parentNode, nextNode); - } - }, - remove: function (id) { - if (id == null) { - // remove self - this.removeAll(); - removeNode(this.start); - removeNode(this.end); - this.owner = null; - rangeRemoved(this); - return; - } - - checkId(id); - id = ' ' + id; - var members = this.members; - var member = (members.hasOwnProperty(id) && - members[id]); - delete members[id]; - - // Don't mind double-remove. - if (! member) - return; - - var parentNode = this.parentNode(); - // Consider ourselves removed (and don't mind) if - // start marker has no parent. - if (! parentNode) - return; - - if (member instanceof DomRange) { - // Range - var range = member; - range.owner = null; - // Don't mind if range (specifically its start - // marker) has been removed already. - if (range.start.parentNode === parentNode) - member.remove(); - } else { - // Node - var node = member; - // Don't mind if node has been removed already. - if (node.parentNode === parentNode) - removeNode(node); - } - }, - moveBefore: function (id, beforeId) { - var nextNode = this.getInsertionPoint(beforeId); - checkId(id); - id = ' ' + id; - var members = this.members; - var member = - (members.hasOwnProperty(id) && - members[id]); - - // Don't mind if member doesn't exist. - if (! member) - return; - - var parentNode = this.parentNode(); - // Consider ourselves removed (and don't mind) if - // start marker has no parent. - if (! parentNode) - return; - - if (member instanceof DomRange) { - // Range - var range = member; - // Don't mind if range (specifically its start marker) - // has been removed already. - if (range.start.parentNode === parentNode) { - range.refresh(); - var nodes = range.getNodes(); - for (var i = 0; i < nodes.length; i++) - moveNode(nodes[i], parentNode, nextNode); - } - } else { - // Node - var node = member; - moveNode(node, parentNode, nextNode); - } - }, - get: function (id) { - checkId(id); - id = ' ' + id; - var members = this.members; - if (members.hasOwnProperty(id)) - return members[id]; - return null; - }, - parentNode: function () { - return this.start.parentNode; - }, - startNode: function () { - return this.start; - }, - endNode: function () { - return this.end; - }, - eachMember: function (nodeFunc, rangeFunc) { - var members = this.members; - var parentNode = this.parentNode(); - for (var k in members) { - // mem is a component (hosting a Range) or a Node - var mem = members[k]; - if (mem instanceof DomRange) { - // Range - var range = mem; - if (range.start.parentNode === parentNode) { - rangeFunc && rangeFunc(range); // still there - } else { - range.owner = null; - delete members[k]; // gone - rangeRemoved(range); - } - } else { - // Node - var node = mem; - if (node.parentNode === parentNode) { - nodeFunc && nodeFunc(node); // still there - } else { - delete members[k]; // gone - nodeRemoved(node); - } - } - } - }, - - ///////////// INTERNALS below this point, pretty much - - // The purpose of "refreshing" a DomRange is to - // take into account any element removals or moves - // that may have occurred, and to "fix" the start - // and end markers before the entire range is moved - // or removed so that they bracket the appropriate - // content. - // - // For example, if a DomRange contains a single element - // node, and this node is moved using jQuery, refreshing - // the DomRange will look to the element as ground truth - // and move the start/end markers around the element. - // A refreshed DomRange's nodes may surround nodes from - // sibling DomRanges (including their marker nodes) - // until the sibling DomRange is refreshed. - // - // Specifically, `refresh` moves the `start` - // and `end` nodes to immediate before the first, - // and after the last, "significant" node the - // DomRange contains, where a significant node - // is any node except a whitespace-only text-node. - // All member ranges are refreshed first. Adjacent - // insignificant member nodes are included between - // `start` and `end` as well, but it's possible that - // other insignificant nodes remain as siblings - // elsewhere. Nodes with no DomRange owner that are - // found between this DomRange's nodes are adopted. - // - // Performing add/move/remove operations on an "each" - // shouldn't require refreshing the entire each, just - // the member in question. (However, adding to the - // end may require refreshing the whole "each"; - // see `getInsertionPoint`. Adding multiple members - // at once using `add(array)` is faster. - refresh: function () { - - var parentNode = this.parentNode(); - if (! parentNode) - return; - - // Using `eachMember`, do several things: - // - Refresh all member ranges - // - Count our members - // - If there's only one, get that one - // - Make a list of member TextNodes, which we - // can't detect with a `$ui` property because - // IE 8 doesn't allow user-defined properties - // on TextNodes. - var someNode = null; - var someRange = null; - var numMembers = 0; - var textNodes = null; - this.eachMember(function (node) { - someNode = node; - numMembers++; - if (node.nodeType === 3) { - textNodes = (textNodes || []); - textNodes.push(node); - } - }, function (range) { - range.refresh(); - someRange = range; - numMembers++; - }); - - var firstNode = null; - var lastNode = null; - - if (numMembers === 0) { - // don't scan for members - } else if (numMembers === 1) { - if (someNode) { - firstNode = someNode; - lastNode = someNode; - } else if (someRange) { - firstNode = someRange.start; - lastNode = someRange.end; - } - } else { - // This loop is O(childNodes.length), even if our members - // are already consecutive. This means refreshing just one - // item in a list is technically order of the total number - // of siblings, including in other list items. - // - // The root cause is we intentionally don't track the - // DOM order of our members, so finding the first - // and last in sibling order either involves a scan - // or a bunch of calls to compareDocumentPosition. - // - // Fortunately, the common cases of zero and one members - // are optimized. Also, the scan is super-fast because - // no work is done for unknown nodes. It could be possible - // to optimize this code further if it becomes a problem. - for (var node = parentNode.firstChild; - node; node = node.nextSibling) { - - var nodeOwner; - if (node.$ui && - (nodeOwner = node.$ui) && - ((nodeOwner === this && - node !== this.start && - node !== this.end && - isSignificantNode(node)) || - (nodeOwner !== this && - nodeOwner.owner === this && - nodeOwner.start === node))) { - // found a member range or node - // (excluding "insignificant" empty text nodes, - // which won't be moved by, say, jQuery) - if (firstNode) { - // if we've already found a member in our - // scan, see if there are some easy ownerless - // nodes to "adopt" by scanning backwards. - for (var n = firstNode.previousSibling; - n && ! n.$ui; - n = n.previousSibling) { - this.members[this.nextMemberId++] = n; - // can't attach `$ui` to a TextNode in IE 8, so - // don't bother on any browser. - if (n.nodeType !== 3) - n.$ui = this; - } - } - if (node.$ui === this) { - // Node - firstNode = (firstNode || node); - lastNode = node; - } else { - // Range - // skip it and include its nodes in - // firstNode/lastNode. - firstNode = (firstNode || node); - node = node.$ui.end; - lastNode = node; - } - } - } - } - if (firstNode) { - // some member or significant node was found. - // expand to include our insigificant member - // nodes as well. - for (var n; - (n = firstNode.previousSibling) && - (n.$ui && n.$ui === this || - _contains(textNodes, n));) - firstNode = n; - for (var n; - (n = lastNode.nextSibling) && - (n.$ui && n.$ui === this || - _contains(textNodes, n));) - lastNode = n; - // adjust our start/end pointers - if (firstNode !== this.start) - insertNode(this.start, - parentNode, firstNode); - if (lastNode !== this.end) - insertNode(this.end, parentNode, - lastNode.nextSibling); - } - }, - getInsertionPoint: function (beforeId) { - var members = this.members; - var parentNode = this.parentNode(); - - if (! beforeId) { - // Refreshing here is necessary if we want to - // allow elements to move around arbitrarily. - // If jQuery is used to reorder elements, it could - // easily make our `end` pointer meaningless, - // even though all our members continue to make - // good reference points as long as they are refreshed. - // - // However, a refresh is expensive! Let's - // make the developer manually refresh if - // elements are being re-ordered externally. - return this.end; - } - - checkId(beforeId); - beforeId = ' ' + beforeId; - var mem = members[beforeId]; - - if (mem instanceof DomRange) { - // Range - var range = mem; - if (range.start.parentNode === parentNode) { - // still there - range.refresh(); - return range.start; - } else { - range.owner = null; - rangeRemoved(range); - } - } else { - // Node - var node = mem; - if (node.parentNode === parentNode) - return node; // still there - else - nodeRemoved(node); - } - - // not there anymore - delete members[beforeId]; - // no good position - return this.end; - } -}); - -DomRange.prototype.elements = function (intoArray) { - intoArray = (intoArray || []); - this.eachMember(function (node) { - if (node.nodeType === 1) - intoArray.push(node); - }, function (range) { - range.elements(intoArray); - }); - return intoArray; -}; - -// XXX alias the below as `UI.refresh` and `UI.insert` - -// In a real-life case where you need a refresh, -// you probably don't have easy -// access to the appropriate DomRange or component, -// just the enclosing element: -// -// ``` -// {{#Sortable}} -//
    -// {{#each}} -// ... -// ``` -// -// In this case, Sortable wants to call `refresh` -// on the div, not the each, so it would use this function. -DomRange.refresh = function (element) { - var comps = DomRange.getComponents(element); - - for (var i = 0, N = comps.length; i < N; i++) - comps[i].refresh(); -}; - -DomRange.getComponents = function (element) { - var topLevelComps = []; - for (var n = element.firstChild; - n; n = n.nextSibling) { - if (n.$ui && n === n.$ui.start && - ! n.$ui.owner) - topLevelComps.push(n.$ui); - } - return topLevelComps; -}; - -// `parentNode` must be an ELEMENT, not a fragment -DomRange.insert = function (range, parentNode, nextNode) { - var nodes = range.getNodes(); - for (var i = 0; i < nodes.length; i++) - insertNode(nodes[i], parentNode, nextNode); - rangeParented(range); -}; - -DomRange.getContainingComponent = function (element) { - while (element && ! element.$ui) - element = element.parentNode; - - var range = (element && element.$ui); - - while (range) { - if (range.component) - return range.component; - range = range.owner; - } - return null; -}; - -///// FIND BY SELECTOR - -DomRange.prototype.contains = function (compOrNode) { - if (! compOrNode) - throw new Error("Expected Component or Node"); - - var parentNode = this.parentNode(); - if (! parentNode) - return false; - - var range; - if (compOrNode instanceof DomRange) { - // Component - range = compOrNode; - var pn = range.parentNode(); - if (! pn) - return false; - // If parentNode is different, it must be a node - // we contain. - if (pn !== parentNode) - return this.contains(pn); - if (range === this) - return false; // don't contain self - // Ok, `range` is a same-parent range to see if we - // contain. - } else { - // Node - var node = compOrNode; - if (! elementContains(parentNode, node)) - return false; - - while (node.parentNode !== parentNode) - node = node.parentNode; - - range = node.$ui; - } - - // Now see if `range` is truthy and either `this` - // or an immediate subrange - - while (range && range !== this) - range = range.owner; - - return range === this; -}; - -DomRange.prototype.$ = function (selector) { - var self = this; - - var parentNode = this.parentNode(); - if (! parentNode) - throw new Error("Can't select in removed DomRange"); - - // Strategy: Find all selector matches under parentNode, - // then filter out the ones that aren't in this DomRange - // using upwards pointers ($ui, owner, parentNode). This is - // asymptotically slow in the presence of O(N) sibling - // content that is under parentNode but not in our range, - // so if performance is an issue, the selector should be - // run on a child element. - - // Since jQuery can't run selectors on a DocumentFragment, - // we don't expect findBySelector to work. - if (parentNode.nodeType === 11 /* DocumentFragment */ || - parentNode.$_uiIsOffscreen) - throw new Error("Can't use $ on an offscreen component"); - - var results = DomBackend.findBySelector(selector, parentNode); - - // We don't assume `results` has jQuery API; a plain array - // should do just as well. However, if we do have a jQuery - // array, we want to end up with one also, so we use - // `.filter`. - - - // Function that selects only elements that are actually - // in this DomRange, rather than simply descending from - // `parentNode`. - var filterFunc = function (elem) { - // handle jQuery's arguments to filter, where the node - // is in `this` and the index is the first argument. - if (typeof elem === 'number') - elem = this; - - return self.contains(elem); - }; - - if (! results.filter) { - // not a jQuery array, and not a browser with - // Array.prototype.filter (e.g. IE <9) - var newResults = []; - for (var i = 0; i < results.length; i++) { - var x = results[i]; - if (filterFunc(x)) - newResults.push(x); - } - results = newResults; - } else { - // `results.filter` is either jQuery's or ECMAScript's `filter` - results = results.filter(filterFunc); - } - - return results; -}; - - -///// EVENTS - -// List of events to always delegate, never capture. -// Since jQuery fakes bubbling for certain events in -// certain browsers (like `submit`), we don't want to -// get in its way. -// -// We could list all known bubbling -// events here to avoid creating speculative capturers -// for them, but it would only be an optimization. -var eventsToDelegate = { - blur: 1, change: 1, click: 1, focus: 1, focusin: 1, - focusout: 1, reset: 1, submit: 1 -}; - -var EVENT_MODE_TBD = 0; -var EVENT_MODE_BUBBLING = 1; -var EVENT_MODE_CAPTURING = 2; - -var HandlerRec = function (elem, type, selector, handler, $ui) { - this.elem = elem; - this.type = type; - this.selector = selector; - this.handler = handler; - this.$ui = $ui; - - this.mode = EVENT_MODE_TBD; - - // It's important that delegatedHandler be a different - // instance for each handlerRecord, because its identity - // is used to remove it. - // - // It's also important that the closure have access to - // `this` when it is not called with it set. - this.delegatedHandler = (function (h) { - return function (evt) { - if ((! h.selector) && evt.currentTarget !== evt.target) - // no selector means only fire on target - return; - if (! h.$ui.contains(evt.currentTarget)) - return; - return h.handler.apply(h.$ui, arguments); - }; - })(this); - - // WHY CAPTURE AND DELEGATE: jQuery can't delegate - // non-bubbling events, because - // event capture doesn't work in IE 8. However, there - // are all sorts of new-fangled non-bubbling events - // like "play" and "touchenter". We delegate these - // events using capture in all browsers except IE 8. - // IE 8 doesn't support these events anyway. - - var tryCapturing = elem.addEventListener && - (! eventsToDelegate.hasOwnProperty( - DomBackend.parseEventType(type))); - - if (tryCapturing) { - this.capturingHandler = (function (h) { - return function (evt) { - if (h.mode === EVENT_MODE_TBD) { - // must be first time we're called. - if (evt.bubbles) { - // this type of event bubbles, so don't - // get called again. - h.mode = EVENT_MODE_BUBBLING; - DomBackend.unbindEventCapturer( - h.elem, h.type, h.capturingHandler); - return; - } else { - // this type of event doesn't bubble, - // so unbind the delegation, preventing - // it from ever firing. - h.mode = EVENT_MODE_CAPTURING; - DomBackend.undelegateEvents( - h.elem, h.type, h.delegatedHandler); - } - } - - h.delegatedHandler(evt); - }; - })(this); - - } else { - this.mode = EVENT_MODE_BUBBLING; - } -}; - -HandlerRec.prototype.bind = function () { - // `this.mode` may be EVENT_MODE_TBD, in which case we bind both. in - // this case, 'capturingHandler' is in charge of detecting the - // correct mode and turning off one or the other handlers. - if (this.mode !== EVENT_MODE_BUBBLING) { - DomBackend.bindEventCapturer( - this.elem, this.type, this.selector || '*', - this.capturingHandler); - } - - if (this.mode !== EVENT_MODE_CAPTURING) - DomBackend.delegateEvents( - this.elem, this.type, - this.selector || '*', this.delegatedHandler); -}; - -HandlerRec.prototype.unbind = function () { - if (this.mode !== EVENT_MODE_BUBBLING) - DomBackend.unbindEventCapturer(this.elem, this.type, - this.capturingHandler); - - if (this.mode !== EVENT_MODE_CAPTURING) - DomBackend.undelegateEvents(this.elem, this.type, - this.delegatedHandler); -}; - - -// XXX could write the form of arguments for this function -// in several different ways, including simply as an event map. -DomRange.prototype.on = function (events, selector, handler) { - var parentNode = this.parentNode(); - if (! parentNode) - // if we're not in the DOM, silently fail. - return; - // haven't been added yet; error - if (parentNode.$_uiIsOffscreen) - throw new Error("Can't bind events before DomRange is inserted"); - - var eventTypes = []; - events.replace(/[^ /]+/g, function (e) { - eventTypes.push(e); - }); - - if (! handler && (typeof selector === 'function')) { - // omitted `selector` - handler = selector; - selector = null; - } else if (! selector) { - // take `""` to `null` - selector = null; - } - - var newHandlerRecs = []; - for (var i = 0, N = eventTypes.length; i < N; i++) { - var type = eventTypes[i]; - - var eventDict = parentNode.$_uievents; - if (! eventDict) - eventDict = (parentNode.$_uievents = {}); - - var info = eventDict[type]; - if (! info) { - info = eventDict[type] = {}; - info.handlers = []; - } - var handlerList = info.handlers; - var handlerRec = new HandlerRec( - parentNode, type, selector, handler, this); - newHandlerRecs.push(handlerRec); - handlerRec.bind(); - handlerList.push(handlerRec); - // move handlers of enclosing ranges to end - for (var r = this.owner; r; r = r.owner) { - // r is an enclosing DomRange - for (var j = 0, Nj = handlerList.length; - j < Nj; j++) { - var h = handlerList[j]; - if (h.$ui === r) { - h.unbind(); - h.bind(); - handlerList.splice(j, 1); // remove handlerList[j] - handlerList.push(h); - j--; // account for removed handler - Nj--; // don't visit appended handlers - } - } - } - } - - this.stopHandles = (this.stopHandles || []); - this.stopHandles.push({ - // closes over just `parentNode` and `newHandlerRecs` - stop: function () { - var eventDict = parentNode.$_uievents; - if (! eventDict) - return; - - for (var i = 0; i < newHandlerRecs.length; i++) { - var handlerToRemove = newHandlerRecs[i]; - var info = eventDict[handlerToRemove.type]; - if (! info) - continue; - var handlerList = info.handlers; - for (var j = handlerList.length - 1; j >= 0; j--) { - if (handlerList[j] === handlerToRemove) { - handlerToRemove.unbind(); - handlerList.splice(j, 1); // remove handlerList[j] - } - } - } - newHandlerRecs.length = 0; - } - }); -}; - - // Returns true if element a contains node b and is not node b. - var elementContains = function (a, b) { - if (a.nodeType !== 1) // ELEMENT - return false; - if (a === b) - return false; - - if (a.compareDocumentPosition) { - return a.compareDocumentPosition(b) & 0x10; - } else { - // Should be only old IE and maybe other old browsers here. - // Modern Safari has both functions but seems to get contains() wrong. - // IE can't handle b being a text node. We work around this - // by doing a direct parent test now. - b = b.parentNode; - if (! (b && b.nodeType === 1)) // ELEMENT - return false; - if (a === b) - return true; - - return a.contains(b); - } - }; - - -UI.DomRange = DomRange; diff --git a/packages/ui/domrange_tests.js b/packages/ui/domrange_tests.js deleted file mode 100644 index 2fd7f2b519..0000000000 --- a/packages/ui/domrange_tests.js +++ /dev/null @@ -1,961 +0,0 @@ - -var DomRange = UI.DomRange; -var parseHTML = UI.DomBackend.parseHTML; - -// fake component; DomRange host -var Comp = function (which) { - this.which = which; - this.dom = new DomRange; - this.dom.component = this; -}; - -var isStartMarker = function (n) { - return (n.$ui && n === n.$ui.start); -}; - -var isEndMarker = function (n) { - return (n.$ui && n === n.$ui.end); -}; - -var inDocument = function (range, func) { - var onscreen = document.createElement("DIV"); - onscreen.style.display = 'none'; - document.body.appendChild(onscreen); - DomRange.insert(range, onscreen); - try { - func(range); - } finally { - if (onscreen.parentNode === document.body) - document.body.removeChild(onscreen); - } -}; - -var htmlRange = function (html) { - var r = new DomRange; - _.each(parseHTML(html), function (node) { - r.add(node); - }); - return r; -}; - -Tinytest.add("ui - DomRange - basic", function (test) { - var r = new DomRange; - r.which = 'R'; - - // `r.start` and `r.end` -- accessed via - // `r.startNode() and `r.endNode()` -- are adjacent empty - // text nodes used as markers. They are initially created - // in a DocumentFragment or other offscreen container. - // At all times, the members of a DomRange have the same - // parent element (`r.parentNode()`), though this element - // may change (typically just once when the DomRange is - // first put into the DOM). - var rStart = r.startNode(); - var rEnd = r.endNode(); - - test.isTrue(isStartMarker(rStart)); - test.isTrue(isEndMarker(rEnd)); - test.equal(rStart.nextSibling, rEnd); - test.isTrue(rStart.parentNode); - test.equal(r.parentNode(), rStart.parentNode); - - test.equal(typeof r.members, 'object'); - test.equal(_.keys(r.members).length, 0); - - test.equal(rStart.$ui, r); - test.equal(rEnd.$ui, r); - - // add a node - var div = document.createElement("DIV"); - r.add(div); - - test.equal(_.keys(r.members).length, 1); - test.equal(div.previousSibling, rStart); - test.equal(div.nextSibling, rEnd); - test.equal(div.$ui, r); - - // add a subrange - var s = new DomRange; - s.which = 'S'; - var span = document.createElement("SPAN"); - s.add(span); - r.add(s); - test.equal(_.keys(r.members).length, 2); - test.isFalse(r.owner); - test.equal(s.owner, r); - - // DOM should go: rStart, DIV, sStart, SPAN, sEnd, rEnd. - test.equal(span.previousSibling, s.startNode()); - test.equal(span.nextSibling, s.endNode()); - test.equal(span.nextSibling.nextSibling, rEnd); - test.equal(span.previousSibling.previousSibling, - div); - test.equal(span.$ui, s); - - // eachMember - var buf = []; - r.eachMember(function (node) { - buf.push(node.nodeName); - }, function (range) { - buf.push('range ' + range.which); - }); - buf.sort(); - test.equal(buf, ['DIV', 'range S']); - - // removal - s.remove(); - test.isFalse(s.owner); - // sStart, SPAN, sEnd are gone from the DOM. - test.equal(rStart.nextSibling, div); - test.equal(rEnd.previousSibling, div); - // `r` still has two members - test.equal(_.keys(r.members).length, 2); - // until we refresh - r.refresh(); - test.equal(_.keys(r.members).length, 1); - // remove all - r.removeAll(); - test.equal(rStart.nextSibling, rEnd); - test.equal(_.keys(r.members).length, 0); -}); - -Tinytest.add("ui - DomRange - shuffling", function (test) { - var r = new DomRange; - - var B = document.createElement("B"); - var I = document.createElement("I"); - var U = document.createElement("U"); - - r.add('B', B); - r.add('I', I); - r.add('U', U); - - var spellDom = function () { - var frag = r.parentNode(); - var str = ''; - _.each(frag.childNodes, function (n) { - if (n.nodeType === 3 || isStartMarker(n) || - isEndMarker(n)) { - if (isStartMarker(n)) - str += '('; - else if (isEndMarker(n)) - str += ')'; - else - str += '-'; - } else { - if (n.$ui.component && n.$ui.component.which) - str += n.$ui.component.which; - else - str += (n.nodeName || '?'); - } - }); - return str; - }; - - test.equal(spellDom(), '(BIU)'); - r.moveBefore('B'); - test.equal(spellDom(), '(IUB)'); - r.moveBefore('I', 'U'); - test.equal(spellDom(), '(IUB)'); - r.moveBefore('I', 'B'); - test.equal(spellDom(), '(UIB)'); - r.moveBefore('B', 'U'); - test.equal(spellDom(), '(BUI)'); - r.moveBefore('U', null); - test.equal(spellDom(), '(BIU)'); - - test.equal(B.$ui, r); - - // add some member rangers, with host objects - var X = new Comp('X'); - var Y = new Comp('Y'); - var Z = new Comp('Z'); - r.add('X', X.dom, 'I'); - X.dom.add(document.createElement("SPAN")); - Y.dom.add(document.createElement("SPAN")); - Z.dom.add(document.createElement("SPAN")); - r.add('Y', Y.dom, 'U'); - r.add('Z', Z.dom); - - test.equal(spellDom(), '(B(X)I(Y)U(Z))'); - - r.add([document.createElement('A'), - document.createElement('A')], 'X'); - - test.equal(spellDom(), '(BAA(X)I(Y)U(Z))'); - - r.moveBefore('I', 'X'); - r.moveBefore('X', 'B'); - r.moveBefore('Z', 'U'); - r.moveBefore('U', 'Y'); - test.equal(spellDom(), '((X)BAAIU(Y)(Z))'); - - - r.moveBefore('Z', 'X'); - r.moveBefore('Y', 'X'); - test.equal(spellDom(), '((Z)(Y)(X)BAAIU)'); - - test.isTrue(r.get('X') === X.dom); - test.isTrue(r.get('Y') === Y.dom); - test.isTrue(r.get('Z') === Z.dom); - test.isTrue(r.get('B') === B); - test.isTrue(r.get('I') === I); - test.isTrue(r.get('U') === U); - - test.isFalse(r.owner); - test.isTrue(X.dom.owner === r); - test.isTrue(Y.dom.owner === r); - test.isTrue(Z.dom.owner === r); - - r.remove('Y'); - test.equal(spellDom(), '((Z)(X)BAAIU)'); - test.equal(r.get('Y'), null); - - r.remove('X'); - test.equal(spellDom(), '((Z)BAAIU)'); - - r.removeAll(); - test.equal(spellDom(), '()'); -}); - -Tinytest.add("ui - DomRange - nested", function (test) { - var r = new DomRange; - - var spellDom = function () { - var frag = r.parentNode(); - var str = ''; - _.each(frag.childNodes, function (n) { - var ui = n.$ui; - if (isStartMarker(n)) - str += (ui.component ? ui.component.which : '('); - else if (isEndMarker(n)) - str += (ui.component ? ui.component.which.toLowerCase() : ')'); - else - str += '?'; - }); - return str; - }; - - // nest empty ranges; should work even though - // there are no element nodes - var A,B,C,D,E,F; - - test.equal(spellDom(), '()'); - r.add((A = new Comp('A')).dom); - test.equal(spellDom(), '(Aa)'); - r.add('B', (B = new Comp('B')).dom); - r.add('C', (C = new Comp('C')).dom, 'B'); - test.equal(spellDom(), '(AaCcBb)'); - - r.get('B').add('D', (D = new Comp('D')).dom); - D.dom.add('E', (E = new Comp('E')).dom); - test.equal(spellDom(), '(AaCcBDEedb)'); - B.dom.add('F', (F = new Comp('F')).dom); - test.equal(spellDom(), '(AaCcBDEedFfb)'); - - r.moveBefore('B', 'C'); - test.equal(spellDom(), '(AaBDEedFfbCc)'); - B.dom.moveBefore('D', null); - test.equal(spellDom(), '(AaBFfDEedbCc)'); - r.moveBefore('C', 'B'); - test.equal(spellDom(), '(AaCcBFfDEedb)'); - D.dom.remove('E'); - test.equal(spellDom(), '(AaCcBFfDdb)'); - r.remove('B'); - test.equal(spellDom(), '(AaCc)'); - - test.isFalse(r.owner); - test.equal(A.dom.owner, r); - test.equal(C.dom.owner, r); -}); - -Tinytest.add("ui - DomRange - external moves", function (test) { - // In this one, uppercase letters are div elements, - // lowercase letters are marker text nodes, as follows: - // - // a-X-b - c-d-Y-Z-e-f - g-h-i-W-j-k-l V - // - // In other words, one DomRange containing an element (X), - // then two nested DomRanges containing two elements (Y,Z), - // etc. - - var wsp = function () { - return document.createTextNode(' '); - }; - - var X = document.createElement("DIV"); - X.id = 'X'; - var Y = document.createElement("DIV"); - Y.id = 'Y'; - var Z = document.createElement("DIV"); - Z.id = 'Z'; - var W = document.createElement("DIV"); - W.id = 'W'; - var V = document.createElement("DIV"); - V.id = 'V'; - - var ab = new Comp('ab'); - ab.dom.add(wsp()); - ab.dom.add('X', X); - ab.dom.add(wsp()); - var cf = new Comp('cf'); - var de = new Comp('de'); - de.dom.add(wsp()); - de.dom.add('Y', Y); - de.dom.add(wsp()); - de.dom.add('Z', Z); - de.dom.add(wsp()); - cf.dom.add(wsp()); - cf.dom.add('de', de.dom); - cf.dom.add(wsp()); - var gl = new Comp('gl'); - var hk = new Comp('hk'); - var ij = new Comp('ij'); - ij.dom.add(wsp()); - ij.dom.add('W', W); - ij.dom.add(wsp()); - // i-W-j - test.equal(ij.dom.getNodes().length, 5); - gl.dom.add(wsp()); - gl.dom.add('hk', hk.dom); - gl.dom.add(wsp()); - // g-hk-l - test.equal(gl.dom.getNodes().length, 6); - hk.dom.add(wsp()); - hk.dom.add('ij', ij.dom); - hk.dom.add(wsp()); - // h-i-W-j-k - test.equal(hk.dom.getNodes().length, 9); - // g-h-i-W-j-k-l - test.equal(gl.dom.getNodes().length, 13); - - var r = new DomRange; - r.add('ab', ab.dom); - r.add(wsp()); - r.add('cf', cf.dom); - r.add(wsp()); - r.add('gl', gl.dom); - r.add('V', V); - - var spellDom = function () { - var frag = r.parentNode(); - var str = ''; - _.each(frag.childNodes, function (n) { - var ui = n.$ui; - if (isStartMarker(n)) - str += (ui.component ? ui.component.which.charAt(0) : '('); - else if (isEndMarker(n)) - str += (ui.component ? ui.component.which.charAt(1) : ')'); - else if (n.nodeType === 3) - str += '-'; - else - str += (n.id || '?'); - }); - return str; - }; - var strip = function (str) { - return str.replace(/[^-\w()]+/g, ''); - }; - - test.equal(spellDom(), - strip('(a-X-b - c-d-Y-Z-e-f - g-h-i-W-j-k-l V)')); - - test.isTrue(ab.dom.owner === r); - test.isTrue(cf.dom.owner === r); - test.isTrue(de.dom.owner === cf.dom); - test.isTrue(gl.dom.owner === r); - test.isTrue(hk.dom.owner === gl.dom); - test.isTrue(ij.dom.owner === hk.dom); - - // all right, now let's mess around with these elements! - - $([Y,Z]).insertBefore(X); - - // jQuery lifted Y,Z right out and stuck them before X - test.equal(spellDom(), - strip('(a-YZX-b - c-d---e-f - g-h-i-W-j-k-l V)')); - - r.moveBefore('cf', 'ab'); - - // the move causes a refresh of `ab` and `cf` and their - // descendent members, re-establishing proper organization - // (ignoring whitespace textnodes) - test.equal(spellDom(), - strip('(- cdYZef aX-b ------- g-h-i-W-j-k-l V)')); - - $(W).insertBefore(X); - - test.equal(spellDom(), - strip('(- cdYZef aWX-b ------- g-h-i--j-k-l V)')); - - $(Z).insertBefore(W); - - test.equal(spellDom(), - strip('(- cdYef aZWX-b ------- g-h-i--j-k-l V)')); - - r.moveBefore('ab', 'cf'); - - // WOW! `ab` and `cf` have been fixed. Here's what - // happened: - // - Because `cf` is serving as an insertion point, it - // is refreshed first, and it recursively refreshes - // `de`. This causes `e` and then `f` to move to the - // right of `Z`. There's still `a` floating in the middle. - // - Then `ab` is refreshed. This moves `a` to right before - // `X`. - // - Finally, `aX-b` is moved before `c`. - test.equal(spellDom(), - strip('(- aX-b cdYZef W ------- g-h-i--j-k-l V)')); - - r.moveBefore('ab', 'gl'); - - // Because `gl` is being used as a reference point, - // it is refreshed to contain `W`. - // Because the `-` that was initial came from `ab`, - // it is recaptured. - test.equal(spellDom(), - strip('(cdYZef a-X-b ghiWjkl ------------- V)')); - - $(Z).insertBefore(X); - - test.equal(spellDom(), - strip('(cdYef a-ZX-b ghiWjkl ------------- V)')); - - r.moveBefore('gl', 'cf'); - - // Note that the `a` is still misplaced here. - test.equal(spellDom(), - strip('(ghiWjkl cdY a-ZefX-b ------------- V)')); - - r.moveBefore('cf', 'V'); - - test.equal(spellDom(), - strip('(ghiWjkl X-b ------------- cdY a-Zef V)')); - - - $(X).insertBefore(Y); - - // holy crap, now `aXb` is a mess. Really `a` and `b` - // are in the completely wrong place. - test.equal(spellDom(), - strip('(ghiWjkl -b ------------- cdXY a-Zef V)')); - - r.moveBefore('gl', 'ab'); - - // Now `c` and `d` are wrong. It looks like `cdYZef` - // also includes `W` and `X`. - test.equal(spellDom(), - strip('(-------------- cd ghiWjkl aXbY-Zef V)')); - - // However, remove `cf` will do a refresh first. - r.remove('cf'); - - test.equal(spellDom(), - strip('(-------------- ghiWjkl aXb V)')); - - $(X).insertBefore(W); - r.parentNode().appendChild(W); - - test.equal(spellDom(), - strip('(-------------- ghiXjkl ab V) W')); - - r.moveBefore('ab', 'gl'); - - - test.equal(spellDom(), - strip('(-------------- V) aXb ghiWjkl')); - - r.remove('V'); - - test.equal(spellDom(), - strip('(--------------) aXb ghiWjkl')); - - - // Manual refresh is required for move-to-end - // (or add-at-end) if elements may have moved externally, - // because the `end` pointer could be totally wrong. - // Otherwise, the order of `ab` and `gl` would swap, - // meaning the DomRange operations would do something - // different from the jQuery operations. - // - // See `range.getInsertionPoint`. - - // Same as `r.refresh()` but tests - // the convenience function `DomRange.refresh(element)`: - DomRange.refresh(r.parentNode()); - - r.moveBefore('gl', null); - - test.equal(spellDom(), - strip('-------------- (aXb ghiWjkl)')); -}); - -Tinytest.add("ui - DomRange - basic events", function (test) { - // test.equal doesn't work on arrays of DOM nodes, so - // we need this. It's `===` that descends recursively - // into any arrays. - var arrayEqual = function (a, b) { - test.equal(_.isArray(a), _.isArray(b)); - if (_.isArray(a)) { - test.equal(a.length, b.length); - for (var i = 0; i < a.length; i++) { - arrayEqual(a[i], b[i]); - } - } else { - test.isTrue(a[i] === b[i]); - } - }; - - var q = new DomRange; - test.throws(function () { - // can't bind events before DomRange is added to - // the DOM - q.on('click', function (evt) {}); - }); - - inDocument( - htmlRange("Foo"), - function (r) { - var buf = []; - - r.on('click', 'span', function (evt) { - buf.push([evt.type, evt.target, evt.currentTarget]); - }); - - arrayEqual(buf, []); - var span = r.elements()[0]; - clickElement(span); - arrayEqual(buf, [['click', span, span]]); - }); - - inDocument( - htmlRange("
    Foo
    "), - function (r) { - var buf = []; - - // test click with no selector; should only - // fire on the event target. - r.on('click', function (evt) { - buf.push([evt.type, evt.target, evt.currentTarget]); - }); - - arrayEqual(buf, []); - var span = r.$('span')[0]; - clickElement(span); - arrayEqual(buf, [['click', span, span]]); - }); - - inDocument( - htmlRange('
    Foo
    ' + - '
    Bar
    '), - function (r) { - var buf = []; - - // test click on particular div, which is - // not the target or the bound element - r.on('click', '#yeah', function (evt) { - buf.push([evt.type, evt.target, evt.currentTarget]); - }); - - arrayEqual(buf, []); - clickElement(r.$('#no')[0]); - arrayEqual(buf, []); - var yeah = r.$('#yeah')[0]; - clickElement(yeah); - arrayEqual(buf, [['click', yeah, yeah]]); - }); - - inDocument( - new DomRange, - function (r) { - var s; - r.add(s = htmlRange('
    ')); - r.add(htmlRange('
    ')); - var one = r.$('#one')[0]; - var two = r.$('#two')[0]; - - var buf = []; - - // test that click must be in range to fire - // event handler - s.on('click', 'div', function (evt) { - buf.push([evt.type, evt.target, evt.currentTarget]); - }); - - arrayEqual(buf, []); - clickElement(two); - arrayEqual(buf, []); - clickElement(one); - arrayEqual(buf, [['click', one, one]]); - }); - -}); - -Tinytest.add("ui - DomRange - contains", function (test) { - inDocument(new DomRange, function (r) { - var s = htmlRange('
    Foo
    '); - var t = new DomRange; - t.add(s); - r.add(t); - r.add(htmlRange('
    ')); - var one = r.$('#one')[0]; - var two = r.$('#two')[0]; - var span = r.$('span')[0]; - - test.isFalse(r.contains(r)); - test.isTrue(r.contains(s)); - test.isTrue(r.contains(t)); - test.isTrue(r.contains(one)); - test.isTrue(s.contains(one)); - test.isTrue(t.contains(one)); - test.isTrue(r.contains(two)); - test.isFalse(s.contains(two)); - test.isFalse(t.contains(two)); - test.isTrue(r.contains(span)); - test.isTrue(s.contains(span)); - test.isTrue(t.contains(span)); - test.isFalse(r.contains(r.parentNode())); - test.isFalse(r.contains(document.createElement("DIV"))); - }); -}); - -Tinytest.add("ui - DomRange - constructor", function (test) { - var r = new DomRange; - - test.isTrue(r.parentNode()); - - test.isTrue(r.start.$ui === r); - test.isTrue(r.end.$ui === r); - - var div = document.createElement('div'); - r.add(div); - test.isTrue(div.$ui === r); -}); - -Tinytest.add("ui - DomRange - get", function (test) { - var r = new DomRange; - var a = document.createElement('div'); - var b = document.createElement('div'); - var c = document.createElement('div'); - var d = document.createElement('div'); - - r.add(a); - r.add(null, b); - r.add('c', c); - test.throws(function () { - r.add(0, d); - }); - test.throws(function () { - r.add(1, d); - }); - test.throws(function () { - r.add('', d); - }); - - test.isTrue(r.get('toString') === null); - test.isTrue(r.get('__proto__') === null); - test.isTrue(r.get('_proto__') === null); - test.isTrue(r.get('blahblah') === null); - r.add('toString', d); - - test.throws(function () { - r.get(''); - }); - test.throws(function () { - r.get(null); - }); - test.throws(function () { - r.get(1); - }); - - test.equal(r.elements().length, 4); - - test.isTrue(r.get('c') === c); - test.isTrue(r.get('toString') === d); -}); - -// This test targets IE 9 and 10, which allow properties -// to be attached to TextNodes but may lose them over time. -// Specifically, the JavaScript view of a TextNode seems to -// be only weakly retained by the TextNode itself, so if you -// hang an object graph off a TextNode, you need some other -// pointer to the TextNode or an object in the graph to -// retain it. -Tinytest.addAsync("ui - DomRange - IE TextNode GC", function (test, onComplete) { - var r = new DomRange; - var B = document.createElement('B'); - B.id = 'ie_textnode_gc_test'; - document.body.appendChild(B); - DomRange.insert(r, B); - r = null; - B = null; - - // trigger GC... - if (typeof CollectGarbage === 'function') - CollectGarbage(); - - // come back later... - window.setTimeout(function () { - var B = document.getElementById("ie_textnode_gc_test"); - test.isTrue(B.firstChild.$ui); - test.isTrue(B.lastChild.$ui); - window.BBB = B; - document.body.removeChild(B); - onComplete(); - }, 500); -}); - -Tinytest.add("ui - DomRange - more TBODY", function (test) { - inDocument(htmlRange("
    "), function (r) { - var table = r.elements()[0]; - var tableContent = new DomRange; - var buf = []; - DomRange.insert(tableContent, table); - var trRange = htmlRange("Hello"); - tableContent.add(trRange); - test.isTrue(tableContent.contains(trRange)); - }); - - inDocument(htmlRange("
    "), function (r) { - var table = r.elements()[0]; - var tableContent = new DomRange; - var buf = []; - DomRange.insert(tableContent, table); - var trRange = htmlRange("Hello"); - var tr = trRange.elements()[0]; - tableContent.add('tr', tr); - test.equal(_.keys(tableContent.members).length, 1); - test.isTrue(tableContent.contains(tr)); - tableContent.remove('tr'); - // bizarrely, in IE 8, the `tr` still has some - // DocumentFragment as its parent even though `removeChild` - // has been called on it directly. - test.isFalse(tr.parentNode && tr.parentNode.nodeType === 1); - }); -}); - -Tinytest.add("ui - DomRange - events in tables", function (test) { - inDocument(htmlRange("
    "), function (r) { - var table = r.elements()[0]; - var tableContent = new DomRange; - var buf = []; - DomRange.insert(tableContent, table); - tableContent.on('click', 'tr', function (evt) { - buf.push('click ' + evt.currentTarget.nodeName); - }); - var trRange = htmlRange("Hello"); - tableContent.add(trRange); - var tr = trRange.elements()[0]; - test.equal(buf, []); - clickElement(tr); - test.equal(buf, ['click TR']); - // XXX test something that would break if the event data - // is on the TABLE rather than the TBODY (the new - // parentNode of `tableContent`). - }); -}); - -Tinytest.add("ui - DomRange - nested event order", function (test) { - inDocument(new DomRange, function (r) { - var a = new DomRange; - var b = new DomRange; - var c = new DomRange; - var d = new DomRange; - r.add(a); - a.add(b); - b.add(c); - c.add(d); - var div = document.createElement("DIV"); - d.add(div); - - var buf = []; - var appender = function (str) { - return function (evt) { - buf.push(str); - }; - }; - - b.on('click', 'div', appender("B")); - a.on('click', 'div', appender("A")); - d.on('click', appender("D")); - c.on('click', 'div', appender("C")); - test.equal(buf, []); - clickElement(div); - test.equal(buf, ['D', 'C', 'B', 'A']); - buf.length = 0; - - b.on('click', appender("B2")); - d.on('click', 'div', appender("D2")); - clickElement(div); - test.equal(buf, ['D', 'D2', 'C', 'B', 'B2', 'A']); - }); -}); - -Tinytest.add("ui - DomRange - isParented", function (test) { - inDocument(new DomRange, function (r) { - test.equal(r.isParented, true); - var a = new DomRange; - var b = new DomRange; - var c = new DomRange; - var d = new DomRange; - var e = new DomRange; - var abcde = function (ap, bp, cp, dp, ep) { - test.equal(!! a.isParented, !! ap); - test.equal(!! b.isParented, !! bp); - test.equal(!! c.isParented, !! cp); - test.equal(!! d.isParented, !! dp); - test.equal(!! e.isParented, !! ep); - }; - var div = document.createElement("DIV"); - c.add(div); - abcde(0, 0, 0, 0, 0); - d.add(e); - abcde(0, 0, 0, 0, 0); - DomRange.insert(d, div); - abcde(0, 0, 0, 1, 1); - a.add(b); - abcde(0, 0, 0, 1, 1); - r.add(a); - abcde(1, 1, 0, 1, 1); - b.add(c); - abcde(1, 1, 1, 1, 1); - - var container = r.parentNode(); - test.equal(_.keys(container.$_uiranges).length, 1); - test.equal(_.keys(div.$_uiranges).length, 1); - d.remove(); - test.equal(_.keys(div.$_uiranges).length, 0); - r.remove(); - test.equal(_.keys(container.$_uiranges).length, 0); - }); -}); - -Tinytest.add("ui - DomRange - structural removal", function (test) { - inDocument(new DomRange, function (r) { - var a = new DomRange; - test.isFalse(a.isRemoved); - r.add('a', a); - test.isFalse(a.isRemoved); - r.remove('a'); - test.isTrue(a.isRemoved); - - - var b = new DomRange; - test.isFalse(b.isRemoved); - r.add(b); - test.isFalse(b.isRemoved); - r.removeAll(); - test.isTrue(b.isRemoved); - - - var c = new DomRange; - var d = new DomRange; - var e = new DomRange; - c.add(d); - d.add(e); - r.add('c', c); - test.isFalse(c.isRemoved); - test.isFalse(d.isRemoved); - test.isFalse(e.isRemoved); - r.remove('c'); - test.isTrue(c.isRemoved); - test.isTrue(d.isRemoved); - test.isTrue(e.isRemoved); - - - for (var scenario = 0; scenario < 3; scenario++) { - var f = new DomRange; - var g = document.createElement("DIV"); - var h = new DomRange; - var i = document.createElement("DIV"); - var j = document.createElement("DIV"); - var k = new DomRange; - r.add('f', f); - f.add(g); - DomRange.insert(h, g); - h.add(i); - DomRange.insert(k, j); - i.appendChild(j); - test.isFalse(f.isRemoved); - test.isFalse(h.isRemoved); - test.isFalse(k.isRemoved); - if (scenario === 0) - r.removeAll(); - else if (scenario === 1) - r.remove('f'); - else if (scenario === 2) - $(r.parentNode()).remove(); - test.isTrue(f.isRemoved); - test.isTrue(h.isRemoved); - test.isTrue(k.isRemoved); - - r.removeAll(); - } - }); -}); - -Tinytest.add("ui - DomRange - noticed removal", function (test) { - // TODO - // - // e.g. noticed via `eachMember` or `add` -}); - -Tinytest.add("ui - DomRange - jQuery removal", function (test) { - inDocument(htmlRange("
    "), function (r) { - for (var scenario = 0; scenario < 3; scenario++) { - var f = document.createElement("DIV"); - var g = document.createElement("DIV"); - var h = new DomRange; - var i = document.createElement("DIV"); - var j = document.createElement("DIV"); - var k = new DomRange; - r.add(f); - f.appendChild(g); - DomRange.insert(h, g); - h.add(i); - DomRange.insert(k, j); - i.appendChild(j); - test.isFalse(h.isRemoved); - test.isFalse(k.isRemoved); - - $(g).removeData(); - test.isFalse(h.isRemoved); - test.isFalse(k.isRemoved); - - if (scenario === 0) - $(g).remove(); - else if (scenario === 1) - $(f).empty(); - else if (scenario === 2) - $(f).html("
    "); - else if (scenario === 3) - $(g).detach(); - - if (scenario !== 3) { - test.isTrue(h.isRemoved); - test.isTrue(k.isRemoved); - } else { - // `detach` doesn't remove - test.isFalse(h.isRemoved); - test.isFalse(k.isRemoved); - } - - r.removeAll(); - } - }); -}); - -// TO TEST STILL: -// - external remove element -// - double-add, double-remove -// - external entire remove -// - element adoption during move/remove/refresh -// - first arg of add must be string, errors on `0` for example. -// same with remove and move `id` arguments. -// - can't add multiple members with id, but can add array of 1. -// can add 0 with no id. -// - add a node or range with the same id as an old member -// works if that member is gone. -// - events (and other stuff) get moved when wrapping in TBODY -// - event unbinding -// - "noticed" removal due to `eachMembers`, `add`, etc. diff --git a/packages/ui/each.js b/packages/ui/each.js deleted file mode 100644 index ea3ab262c3..0000000000 --- a/packages/ui/each.js +++ /dev/null @@ -1,119 +0,0 @@ -UI.EachImpl = Component.extend({ - typeName: 'Each', - render: function (modeHint) { - var self = this; - var content = self.__content; - var elseContent = self.__elseContent; - - if (modeHint === 'STATIC') { - // This is a hack. The caller gives us a hint if the - // value we return will be static (in HTML or text) - // or dynamic (materialized DOM). The dynamic path - // returns `null` and then we populate the DOM from - // the `materialized` callback. - // - // It would be much cleaner to always return the same - // value here, and to have that value be some special - // object that encapsulates the logic for populating - // the #each using a mode-agnostic interface that - // works for HTML, text, and DOM. Alternatively, we - // could formalize the current pattern, e.g. defining - // a method like component.populate(domRange) and one - // like renderStatic() or even renderHTML / renderText. - var parts = _.map( - ObserveSequence.fetch(self.__sequence()), - function (item) { - return content.extend({data: function () { - return item; - }}); - }); - - if (parts.length) { - return parts; - } else { - return elseContent; - } - return parts; - } else { - return null; - } - }, - materialized: function () { - var self = this; - - var range = self.dom; - - var content = self.__content; - var elseContent = self.__elseContent; - - // if there is an else clause, keep track of the number of - // rendered items. use this to display the else clause when count - // becomes zero, and remove it when count becomes positive. - var itemCount = 0; - var addToCount = function(delta) { - if (!elseContent) // if no else, no need to keep track of count - return; - - if (itemCount + delta < 0) - throw new Error("count should never become negative"); - - if (itemCount === 0) { - // remove else clause - range.removeAll(); - } - itemCount += delta; - if (itemCount === 0) { - UI.materialize(elseContent, range, null, self); - } - }; - - this.observeHandle = ObserveSequence.observe(function () { - return self.__sequence(); - }, { - addedAt: function (id, item, i, beforeId) { - addToCount(1); - id = LocalCollection._idStringify(id); - - var data = item; - var dep = new Deps.Dependency; - - // function to become `comp.data` - var dataFunc = function () { - dep.depend(); - return data; - }; - // Storing `$set` on `comp.data` lets us - // access it from `changed`. - dataFunc.$set = function (v) { - data = v; - dep.changed(); - }; - - if (beforeId) - beforeId = LocalCollection._idStringify(beforeId); - - var renderedItem = UI.render(content.extend({data: dataFunc}), self); - range.add(id, renderedItem.dom, beforeId); - }, - removedAt: function (id, item) { - addToCount(-1); - range.remove(LocalCollection._idStringify(id)); - }, - movedTo: function (id, item, i, j, beforeId) { - range.moveBefore( - LocalCollection._idStringify(id), - beforeId && LocalCollection._idStringify(beforeId)); - }, - changedAt: function (id, newItem, oldItem, atIndex) { - range.get(LocalCollection._idStringify(id)).component.data.$set(newItem); - } - }); - - // on initial render, display the else clause if no items - addToCount(0); - }, - destroyed: function () { - if (this.__component__.observeHandle) - this.__component__.observeHandle.stop(); - } -}); diff --git a/packages/ui/fields.js b/packages/ui/fields.js deleted file mode 100644 index 71341ded99..0000000000 --- a/packages/ui/fields.js +++ /dev/null @@ -1,143 +0,0 @@ - -var global = (function () { return this; })(); - -currentComponent = new Meteor.EnvironmentVariable(); - -// Searches for the given property in `comp` or a parent, -// and returns it as is (without call it if it's a function). -var lookupComponentProp = function (comp, prop) { - comp = findComponentWithProp(prop, comp); - var result = (comp ? comp.data : null); - if (typeof result === 'function') - result = _.bind(result, comp); - return result; -}; - -// Component that's a no-op when used as a block helper like -// `{{#foo}}...{{/foo}}`. Prints a warning that it is deprecated. -var noOpComponent = function (name) { - return Component.extend({ - kind: 'NoOp', - render: function () { - Meteor._debug("{{#" + name + "}} is now unnecessary and deprecated."); - return this.__content; - } - }); -}; - -// This map is searched first when you do something like `{{#foo}}` in -// a template. -var builtInComponents = { - // for past compat: - 'constant': noOpComponent("constant"), - 'isolate': noOpComponent("isolate") -}; - -_extend(UI.Component, { - // Options: - // - // - template {Boolean} If true, look at the list of templates after - // helpers and before data context. - lookup: function (id, opts) { - var self = this; - var template = opts && opts.template; - var result; - var comp; - - if (!id) - throw new Error("must pass id to lookup"); - - if (/^\./.test(id)) { - // starts with a dot. must be a series of dots which maps to an - // ancestor of the appropriate height. - if (!/^(\.)+$/.test(id)) { - throw new Error("id starting with dot must be a series of dots"); - } - - var compWithData = findComponentWithProp('data', self); - for (var i = 1; i < id.length; i++) { - compWithData = compWithData ? findComponentWithProp('data', compWithData.parent) : null; - } - - return (compWithData ? compWithData.data : null); - - } else if ((comp = findComponentWithHelper(id, self))) { - // found a property or method of a component - // (`self` or one of its ancestors) - var result = comp[id]; - - } else if (_.has(builtInComponents, id)) { - return builtInComponents[id]; - - // Code to search the global namespace for capitalized names - // like component classes, `Template`, `StringUtils.foo`, - // etc. - // - // } else if (/^[A-Z]/.test(id) && (id in global)) { - // // Only look for a global identifier if `id` is - // // capitalized. This avoids having `{{name}}` mean - // // `window.name`. - // result = global[id]; - // return function (/*arguments*/) { - // var data = getComponentData(self); - // if (typeof result === 'function') - // return result.apply(data, arguments); - // return result; - // }; - } else if (template && _.has(Template, id)) { - return Template[id]; - - } else if ((result = UI._globalHelper(id))) { - - } else { - // Resolve id `foo` as `data.foo` (with a "soft dot"). - return function (/* arguments */) { - var data = getComponentData(self); - if (template && !(data && _.has(data, id))) - throw new Error("Can't find template, helper or data context " + - "key: " + id); - if (! data) - return data; - var result = data[id]; - if (typeof result === 'function') - return result.apply(data, arguments); - return result; - }; - } - - if (typeof result === 'function' && ! result._isEmboxedConstant) { - // Wrap the function `result`, binding `this` to `getComponentData(self)`. - // This creates a dependency when the result function is called. - // Don't do this if the function is really just an emboxed constant. - return function (/*arguments*/) { - var args = arguments; - return currentComponent.withValue(self, function () { - currentTemplateInstance = null; // lazily computed, since `updateTemplateInstance` is a little slow - var data = getComponentData(self); - return result.apply(data === null ? {} : data, args); - }); - }; - } else { - return result; - }; - }, - lookupTemplate: function (id) { - return this.lookup(id, {template: true}); - }, - get: function (id) { - // support `this.get()` to get the data context. - if (id === undefined) - id = "."; - - var result = this.lookup(id); - return (typeof result === 'function' ? result() : result); - }, - set: function (id, value) { - var comp = findComponentWithProp(id, this); - if (! comp || ! comp[id]) - throw new Error("Can't find field: " + id); - if (typeof comp[id] !== 'function') - throw new Error("Not a settable field: " + id); - comp[id](value); - } -}); diff --git a/packages/ui/handlebars_backcompat.js b/packages/ui/handlebars_backcompat.js index b089dfd7c3..78c2aedd2a 100644 --- a/packages/ui/handlebars_backcompat.js +++ b/packages/ui/handlebars_backcompat.js @@ -1,36 +1,7 @@ -// XXX this file no longer makes sense in isolation. take it apart as -// part file reorg on the 'ui' package -var globalHelpers = {}; - -UI.registerHelper = function (name, func) { - globalHelpers[name] = func; -}; - -UI._globalHelper = function (name) { - return globalHelpers[name]; -}; - Handlebars = {}; Handlebars.registerHelper = UI.registerHelper; -// Utility to HTML-escape a string. -UI._escape = Handlebars._escape = (function() { - var escape_map = { - "<": "<", - ">": ">", - '"': """, - "'": "'", - "`": "`", /* IE allows backtick-delimited attributes?? */ - "&": "&" - }; - var escape_one = function(c) { - return escape_map[c]; - }; - - return function (x) { - return x.replace(/[&<>"'`]/g, escape_one); - }; -})(); +Handlebars._escape = UI._escape; // Return these from {{...}} helpers to achieve the same as returning // strings from {{{...}}} helpers diff --git a/packages/ui/package.js b/packages/ui/package.js index 2f50cf3942..96cc37c64f 100644 --- a/packages/ui/package.js +++ b/packages/ui/package.js @@ -20,18 +20,10 @@ Package.on_use(function (api) { api.use('htmljs'); api.imply('htmljs'); - api.add_files(['exceptions.js', 'base.js']); - - api.add_files(['dombackend.js', - 'domrange.js'], 'client'); - - api.add_files(['attrs.js', - 'render.js', - 'builtins.js', - 'each.js', - 'fields.js' - ]); + api.use('blaze'); + api.imply('blaze'); + api.add_files(['ui.js']); api.add_files(['handlebars_backcompat.js']); }); @@ -40,14 +32,11 @@ Package.on_test(function (api) { api.use('jquery'); // strong dependency, for testing jQuery backend api.use('ui'); api.use(['test-helpers', 'underscore'], 'client'); - api.use('spacebars-compiler'); // for `HTML.toJS` + api.use('blaze-tools'); // for `HTML.toJS` api.use('html-tools'); api.add_files([ - 'base_tests.js', - 'domrange_tests.js', - 'render_tests.js', - 'dombackend_tests.js' + 'render_tests.js' ], 'client'); }); diff --git a/packages/ui/render.js b/packages/ui/render.js deleted file mode 100644 index 95c7ab305c..0000000000 --- a/packages/ui/render.js +++ /dev/null @@ -1,479 +0,0 @@ - -UI.Component.instantiate = function (parent) { - var kind = this; - - // check arguments - if (UI.isComponent(kind)) { - if (kind.isInited) - throw new Error("A component kind is required, not an instance"); - } else { - throw new Error("Expected Component kind"); - } - - var inst = kind.extend(); // XXX args go here - inst.isInited = true; - - // XXX messy to define this here - inst.templateInstance = { - $: function(selector) { - // XXX check that `.dom` exists here? - return inst.dom.$(selector); - }, - findAll: function (selector) { - return $.makeArray(this.$(selector)); - }, - find: function (selector) { - var result = this.$(selector); - return result[0] || null; - }, - firstNode: null, - lastNode: null, - data: null, - __component__: inst - }; - - inst.parent = (parent || null); - - if (inst.init) - inst.init(); - - if (inst.created) { - updateTemplateInstance(inst); - inst.created.call(inst.templateInstance); - } - - return inst; -}; - -UI.Component.render = function () { - return null; -}; - -var Box = function (func, equals) { - var self = this; - - self.func = func; - self.equals = equals; - - self.curResult = null; - - self.dep = new Deps.Dependency; - - self.resultComputation = Deps.nonreactive(function () { - return Deps.autorun(function (c) { - var func = self.func; - - var newResult = func(); - - if (! c.firstRun) { - var equals = self.equals; - var oldResult = self.curResult; - - if (equals ? equals(newResult, oldResult) : - newResult === oldResult) { - // same as last time - return; - } - } - - self.curResult = newResult; - self.dep.changed(); - }); - }); -}; - -Box.prototype.stop = function () { - this.resultComputation.stop(); -}; - -Box.prototype.get = function () { - if (Deps.active && ! this.resultComputation.stopped) - this.dep.depend(); - - return this.curResult; -}; - -// Takes a reactive function (call it `inner`) and returns a reactive function -// `outer` which is equivalent except in its reactive behavior. Specifically, -// `outer` has the following two special properties: -// -// 1. Isolation: An invocation of `outer()` only invalidates its context -// when the value of `inner()` changes. For example, `inner` may be a -// function that gets one or more Session variables and calculates a -// true/false value. `outer` blocks invalidation signals caused by the -// Session variables changing and sends a signal out only when the value -// changes between true and false (in this example). The value can be -// of any type, and it is compared with `===` unless an `equals` function -// is provided. -// -// 2. Value Sharing: The `outer` function returned by `emboxValue` can be -// shared between different contexts, for example by assigning it to an -// object as a method that can be accessed at any time, such as by -// different templates or different parts of a template. No matter -// how many times `outer` is called, `inner` is only called once until -// it changes. The most recent value is stored internally. -// -// Conceptually, an emboxed value is much like a Session variable which is -// kept up to date by an autorun. Session variables provide storage -// (value sharing) and they don't notify their listeners unless a value -// actually changes (isolation). The biggest difference is that such an -// autorun would never be stopped, and the Session variable would never be -// deleted even if it wasn't used any more. An emboxed value, on the other -// hand, automatically stops computing when it's not being used, and starts -// again when called from a reactive context. This means that when it stops -// being used, it can be completely garbage-collected. -// -// If a non-function value is supplied to `emboxValue` instead of a reactive -// function, then `outer` is still a function but it simply returns the value. -// -UI.emboxValue = function (funcOrValue, equals) { - if (typeof funcOrValue === 'function') { - - var func = funcOrValue; - var box = new Box(func, equals); - - var f = function () { - return box.get(); - }; - - f.stop = function () { - box.stop(); - }; - - return f; - - } else { - var value = funcOrValue; - var result = function () { - return value; - }; - result._isEmboxedConstant = true; - return result; - } -}; - - -UI.namedEmboxValue = function (name, funcOrValue, equals) { - if (! Deps.active) { - var f = UI.emboxValue(funcOrValue, equals); - f.stop(); - return f; - } - - var c = Deps.currentComputation; - if (! c[name]) - c[name] = UI.emboxValue(funcOrValue, equals); - - return c[name]; -}; - -//////////////////////////////////////// - -UI.insert = function (renderedTemplate, parentElement, nextNode) { - // parentElement must be a DOM node. in particular, can't be the - // result of a call to `$`. Can't check if `parentElement instanceof - // Node` since 'Node' is undefined in IE8. - if (! parentElement || typeof parentElement.nodeType !== 'number') - throw new Error("'parentElement' must be a DOM node"); - if (nextNode && typeof nextNode.nodeType !== 'number') // 'nextNode' is optional - throw new Error("'nextNode' must be a DOM node"); - - if (! renderedTemplate.dom) - throw new Error("Expected template rendered with UI.render"); - - UI.DomRange.insert(renderedTemplate.dom, parentElement, nextNode); -}; - -// Insert a DOM node or DomRange into a DOM element or DomRange. -// -// One of three things happens depending on what needs to be inserted into what: -// - `range.add` (anything into DomRange) -// - `UI.DomRange.insert` (DomRange into element) -// - `elem.insertBefore` (node into element) -// -// The optional `before` argument is an existing node or id to insert before in -// the parent element or DomRange. -var insert = function (nodeOrRange, parent, before) { - if (! parent) - throw new Error("Materialization parent required"); - - if (parent instanceof UI.DomRange) { - parent.add(nodeOrRange, before); - } else if (nodeOrRange instanceof UI.DomRange) { - // parent is an element; inserting a range - UI.DomRange.insert(nodeOrRange, parent, before); - } else { - // parent is an element; inserting an element - parent.insertBefore(nodeOrRange, before || null); // `null` for IE - } -}; - -// options include: -// - _nestInCurrentComputation: defaults to false. If true, then -// `render`'s autoruns will be nested inside the current -// computation, so if the current computation is invalidated, then -// the autoruns set up inside `render` will be stopped. If false, -// the autoruns will be set up in a fresh Deps context, so -// invalidating the current computation will have no effect on them. -UI.render = function (kind, parentComponent, options) { - options = options || {}; - - if (kind.isInited) - throw new Error("Can't render component instance, only component kind"); - - var inst, content, range; - - Deps.nonreactive(function () { - - inst = kind.instantiate(parentComponent); - - content = (inst.render && inst.render()); - - range = new UI.DomRange; - inst.dom = range; - range.component = inst; - - if (! options._nestInCurrentComputation) { - materialize(content, range, null, inst); - } - - }); - - if (options._nestInCurrentComputation) { - materialize(content, range, null, inst); - } - - range.removed = function () { - inst.isDestroyed = true; - if (inst.destroyed) { - Deps.nonreactive(function () { - updateTemplateInstance(inst); - inst.destroyed.call(inst.templateInstance); - }); - } - }; - - return inst; -}; - -// options are the same as for UI.render. -UI.renderWithData = function (kind, data, parentComponent, options) { - if (! UI.isComponent(kind)) - throw new Error("Component required here"); - if (kind.isInited) - throw new Error("Can't render component instance, only component kind"); - if (typeof data === 'function') - throw new Error("Data argument can't be a function"); - - return UI.render(kind.extend({data: function () { return data; }}), - parentComponent, options); -}; - -var contentEquals = function (a, b) { - if (a instanceof HTML.Raw) { - return (b instanceof HTML.Raw) && (a.value === b.value); - } else if (a == null) { - return (b == null); - } else { - return (a === b) && - ((typeof a === 'number') || (typeof a === 'boolean') || - (typeof a === 'string')); - } -}; - -UI.InTemplateScope = function (tmplInstance, content) { - if (! (this instanceof UI.InTemplateScope)) - // called without `new` - return new UI.InTemplateScope(tmplInstance, content); - - var parentPtr = tmplInstance.parent; - if (parentPtr.__isTemplateWith) - parentPtr = parentPtr.parent; - - this.parentPtr = parentPtr; - this.content = content; -}; - -UI.InTemplateScope.prototype.toHTML = function (parentComponent) { - return HTML.toHTML(this.content, this.parentPtr); -}; - -UI.InTemplateScope.prototype.toText = function (textMode, parentComponent) { - return HTML.toText(this.content, textMode, this.parentPtr); -}; - -var isSVGAnchor = function (node) { - // We generally aren't able to detect SVG elements because - // if "A" were in our list of known svg element names, then all - // nodes would be created using - // `document.createElementNS`. But in the special case of , we can at least detect that attribute and - // create an SVG tag in that case. - // - // However, we still have a general problem of knowing when to - // use document.createElementNS and when to use - // document.createElement; for example, font tags will always - // be created as SVG elements which can cause other - // problems. #1977 - return (node.tagName === "a" && - node.attrs && - node.attrs["xlink:href"] !== undefined); -}; - -// Convert the pseudoDOM `node` into reactive DOM nodes and insert them -// into the element or DomRange `parent`, before the node or id `before`. -var materialize = function (node, parent, before, parentComponent) { - // XXX should do more error-checking for the case where user is supplying the tags. - // For example, check that CharRef has `html` and `str` properties and no content. - // Check that Comment has a single string child and no attributes. Etc. - - if (node == null) { - // null or undefined. - // do nothinge. - } else if ((typeof node === 'string') || (typeof node === 'boolean') || (typeof node === 'number')) { - node = String(node); - insert(document.createTextNode(node), parent, before); - } else if (node instanceof Array) { - for (var i = 0; i < node.length; i++) - materialize(node[i], parent, before, parentComponent); - } else if (typeof node === 'function') { - - var range = new UI.DomRange; - var lastContent = null; - var rangeUpdater = Deps.autorun(function (c) { - var content = node(); - // normalize content a little, for easier comparison - if (HTML.isNully(content)) - content = null; - else if ((content instanceof Array) && content.length === 1) - content = content[0]; - - // update if content is different from last time - if (! contentEquals(content, lastContent)) { - lastContent = content; - - if (! c.firstRun) - range.removeAll(); - - materialize(content, range, null, parentComponent); - } - }); - range.removed = function () { - rangeUpdater.stop(); - if (node.stop) - node.stop(); - }; - // XXXX HACK - if (Deps.active && node.stop) { - Deps.onInvalidate(function () { - node.stop(); - }); - } - insert(range, parent, before); - } else if (node instanceof HTML.Tag) { - var tagName = node.tagName; - var elem; - if ((HTML.isKnownSVGElement(tagName) || - isSVGAnchor(node)) && - document.createElementNS) { - elem = document.createElementNS('http://www.w3.org/2000/svg', tagName); - } else { - elem = document.createElement(node.tagName); - } - - var rawAttrs = node.attrs; - var children = node.children; - if (node.tagName === 'textarea') { - rawAttrs = (rawAttrs || {}); - rawAttrs.value = children; - children = []; - }; - - if (rawAttrs) { - var attrComp = Deps.autorun(function (c) { - var attrUpdater = c.updater; - if (! attrUpdater) { - attrUpdater = c.updater = new ElementAttributesUpdater(elem); - } - - try { - var attrs = HTML.evaluateAttributes(rawAttrs, parentComponent); - var stringAttrs = {}; - if (attrs) { - for (var k in attrs) { - stringAttrs[k] = HTML.toText(attrs[k], HTML.TEXTMODE.STRING, - parentComponent); - } - attrUpdater.update(stringAttrs); - } - } catch (e) { - reportUIException(e); - } - }); - UI.DomBackend.onElementTeardown(elem, function () { - attrComp.stop(); - }); - } - materialize(children, elem, null, parentComponent); - - insert(elem, parent, before); - } else if (typeof node.instantiate === 'function') { - // component - var instance = UI.render(node, parentComponent, { - _nestInCurrentComputation: true - }); - - // Call internal callback, which may take advantage of the current - // Deps computation. - if (instance.materialized) - instance.materialized(instance.dom); - - insert(instance.dom, parent, before); - } else if (node instanceof HTML.CharRef) { - insert(document.createTextNode(node.str), parent, before); - } else if (node instanceof HTML.Comment) { - insert(document.createComment(node.sanitizedValue), parent, before); - } else if (node instanceof HTML.Raw) { - // Get an array of DOM nodes by using the browser's HTML parser - // (like innerHTML). - var htmlNodes = UI.DomBackend.parseHTML(node.value); - for (var i = 0; i < htmlNodes.length; i++) - insert(htmlNodes[i], parent, before); - } else if (Package['html-tools'] && (node instanceof Package['html-tools'].HTMLTools.Special)) { - throw new Error("Can't materialize Special tag, it's just an intermediate rep"); - } else if (node instanceof UI.InTemplateScope) { - materialize(node.content, parent, before, node.parentPtr); - } else { - // can't get here - throw new Error("Unexpected node in htmljs: " + node); - } -}; - - - -// XXX figure out the right names, and namespace, for these. -// for example, maybe some of them go in the HTML package. -UI.materialize = materialize; - -UI.body = UI.Component.extend({ - kind: 'body', - contentParts: [], - render: function () { - return this.contentParts; - }, - // XXX revisit how body works. - INSTANTIATED: false, - __helperHost: true -}); - -UI.block = function (renderFunc) { - return UI.Component.extend({ render: renderFunc }); -}; - -UI.toHTML = function (content, parentComponent) { - return HTML.toHTML(content, parentComponent); -}; - -UI.toRawText = function (content, parentComponent) { - return HTML.toText(content, HTML.TEXTMODE.STRING, parentComponent); -}; diff --git a/packages/ui/render_tests.js b/packages/ui/render_tests.js index 1df8038ca5..d724a65dda 100644 --- a/packages/ui/render_tests.js +++ b/packages/ui/render_tests.js @@ -1,6 +1,4 @@ -var materialize = UI.materialize; -var toHTML = HTML.toHTML; -var toCode = HTML.toJS; +var toCode = BlazeTools.toJS; var P = HTML.P; var CharRef = HTML.CharRef; @@ -15,6 +13,18 @@ var HR = HTML.HR; var TEXTAREA = HTML.TEXTAREA; var INPUT = HTML.INPUT; +var materialize = function (content, parent) { + var func = content; + if (typeof content !== 'function') { + func = function () { + return content; + }; + } + Blaze.render(func).attach(parent); +}; + +var toHTML = Blaze.toHTML; + Tinytest.add("ui - render - basic", function (test) { var run = function (input, expectedInnerHTML, expectedHTML, expectedCode) { var div = document.createElement("DIV"); @@ -84,8 +94,8 @@ Tinytest.add("ui - render - basic", function (test) { 'HTML.BR({a: [[""]]})'); run(BR({ - x: function () { return function () { return []; }; }, - a: function () { return function () { return ''; }; }}), + x: function () { return Blaze.View(function () { return Blaze.View(function () { return []; }); }); }, + a: function () { return Blaze.View(function () { return Blaze.View(function () { return ''; }); }); }}), '
    ', '
    '); }); @@ -133,7 +143,7 @@ Tinytest.add("ui - render - textarea", function (test) { optNode = null; } var div = document.createElement("DIV"); - var node = TEXTAREA(optNode || text); + var node = TEXTAREA({value: optNode || text}); materialize(node, div); var value = div.querySelector('textarea').value; @@ -147,35 +157,38 @@ Tinytest.add("ui - render - textarea", function (test) { run('Hello', '', - 'HTML.TEXTAREA("Hello")'); + 'HTML.TEXTAREA({value: "Hello"})'); run('\nHello', '', - 'HTML.TEXTAREA("\\nHello")'); + 'HTML.TEXTAREA({value: "\\nHello"})'); run('', '', - 'HTML.TEXTAREA("")'); + 'HTML.TEXTAREA({value: ""})'); run(CharRef({html: '&', str: '&'}), '&', '', - 'HTML.TEXTAREA(HTML.CharRef({html: "&", str: "&"}))'); + 'HTML.TEXTAREA({value: HTML.CharRef({html: "&", str: "&"})})'); - run(['a', function () { return 'b'; }, 'c'], + run(function () { + return ['a', Blaze.View(function () { return 'b'; }), 'c']; + }, 'abc', ''); - }); -Tinytest.add("ui - render - closures", function (test) { +Tinytest.add("ui - render - view isolation", function (test) { // Reactively change a text node (function () { var R = ReactiveVar('Hello'); - var test1 = P(function () { return R.get(); }); + var test1 = function () { + return P(Blaze.View(function () { return R.get(); })); + }; - test.equal(toHTML(test1), '

    Hello

    '); + test.equal(toHTML(test1()), '

    Hello

    '); var div = document.createElement("DIV"); materialize(test1, div); @@ -189,9 +202,11 @@ Tinytest.add("ui - render - closures", function (test) { // Reactively change an array of text nodes (function () { var R = ReactiveVar(['Hello', ' World']); - var test1 = P(function () { return R.get(); }); + var test1 = function () { + return P(Blaze.View(function () { return R.get(); })); + }; - test.equal(toHTML(test1), '

    Hello World

    '); + test.equal(toHTML(test1()), '

    Hello World

    '); var div = document.createElement("DIV"); materialize(test1, div); @@ -213,11 +228,11 @@ var malformedStylesAllowed = function () { return (div.getAttribute("style") === "bar::d;"); }; -Tinytest.add("ui - render - closure GC", function (test) { +Tinytest.add("ui - render - view GC", function (test) { // test that removing parent element removes listeners and stops autoruns. (function () { var R = ReactiveVar('Hello'); - var test1 = P(function () { return R.get(); }); + var test1 = P(Blaze.View(function () { return R.get(); })); var div = document.createElement("DIV"); materialize(test1, div); @@ -246,21 +261,25 @@ Tinytest.add("ui - render - reactive attributes", function (test) { var R = ReactiveVar({'class': ['david gre', CharRef({html: 'ë', str: '\u00eb'}), 'nspan'], id: 'foo'}); - var spanCode = SPAN({$dynamic: [function () { return R.get(); }]}); + var spanFunc = function () { + return SPAN(HTML.Attrs( + function () { return R.get(); })); + }; - test.equal(toHTML(spanCode), ''); + test.equal(Blaze.toHTML(spanFunc()), + ''); test.equal(R.numListeners(), 0); var div = document.createElement("DIV"); - materialize(spanCode, div); + Blaze.render(spanFunc).attach(div); test.equal(canonicalizeHtml(div.innerHTML), ''); test.equal(R.numListeners(), 1); var span = div.firstChild; test.equal(span.nodeName, 'SPAN'); - span.className += ' blah'; + span.className += ' blah'; // change the element's class outside of Blaze. this simulates what a jQuery could do R.set({'class': 'david smith', id: 'bar'}); Deps.flush(); @@ -283,14 +302,16 @@ Tinytest.add("ui - render - reactive attributes", function (test) { var R = ReactiveVar({'style': 'foo: "a;aa"; bar: b;', id: 'foo'}); - var spanCode = SPAN({$dynamic: [function () { return R.get(); }]}); + var spanFunc = function () { + return SPAN(HTML.Attrs(function () { return R.get(); })); + }; - test.equal(toHTML(spanCode), ''); + test.equal(Blaze.toHTML(spanFunc()), ''); test.equal(R.numListeners(), 0); var div = document.createElement("DIV"); - materialize(spanCode, div); + Blaze.render(spanFunc).attach(div); test.equal(canonicalizeHtml(div.innerHTML), ''); test.equal(R.numListeners(), 1); @@ -319,11 +340,13 @@ Tinytest.add("ui - render - reactive attributes", function (test) { var R = ReactiveVar({'style': 'foo: a;'}); - var spanCode = SPAN({$dynamic: [function () { return R.get(); }]}); + var spanFunc = function () { + return SPAN(HTML.Attrs(function () { return R.get(); })); + }; var div = document.createElement("DIV"); document.body.appendChild(div); - materialize(spanCode, div); + Blaze.render(spanFunc).attach(div); test.equal(canonicalizeHtml(div.innerHTML), ''); var span = div.firstChild; @@ -369,14 +392,17 @@ Tinytest.add("ui - render - reactive attributes", function (test) { fff: [[]], ggg: ['x', ['y', ['z']]]}); - var spanCode = SPAN({$dynamic: [function () { return R.get(); }]}); + var spanFunc = function () { + return SPAN(HTML.Attrs( + function () { return R.get(); })); + }; - test.equal(toHTML(spanCode), ''); + test.equal(Blaze.toHTML(spanFunc()), ''); test.equal(toCode(SPAN(R.get())), 'HTML.SPAN({id: "foo", ggg: ["x", ["y", ["z"]]]})'); var div = document.createElement("DIV"); - materialize(spanCode, div); + Blaze.render(spanFunc).attach(div); var span = div.firstChild; test.equal(span.nodeName, 'SPAN'); @@ -393,42 +419,37 @@ Tinytest.add("ui - render - reactive attributes", function (test) { Deps.flush(); test.equal(canonicalizeHtml(div.innerHTML), ''); - $(span).remove(); + $(div).remove(); test.equal(R.numListeners(), 0); })(); }); -Tinytest.add("ui - render - components", function (test) { +Tinytest.add("ui - render - views", function (test) { (function () { var counter = 1; var buf = []; - var myComponent = UI.Component.extend({ - init: function () { - // `this` is the component instance - var number = counter++; - this.number = number; - - if (this.parent) - buf.push('parent of ' + this.number + ' is ' + this.parent.number); - - this.data = function () { - return this.number; - }; - }, - created: function () { - // `this` is the template instance - buf.push('created ' + this.data); - }, - render: function () { - // `this` is the component instance + var makeView = function () { + var view = Blaze.View('myView', function () { return [String(this.number), + (this.number < 3 ? makeView() : HR())]; + }); - (this.number < 3 ? myComponent : HR())]; - }, - rendered: function () { - // `this` is the template instance + var number = counter++; + view.number = number; + + view.onCreated(function () { + var parent = Blaze.getParentView(view, 'myView'); + if (parent) { + buf.push('parent of ' + view.number + ' is ' + + parent.number); + } + + buf.push('created ' + Blaze.getCurrentData()); + }); + + view.onRendered(function () { var nodeDescr = function (node) { if (node.nodeType === 8) // comment return ''; @@ -438,28 +459,29 @@ Tinytest.add("ui - render - components", function (test) { return node.nodeName; }; - var start = this.firstNode; - var end = this.lastNode; + var start = this.domrange.firstNode(); + var end = this.domrange.lastNode(); // skip marker nodes while (start !== end && ! nodeDescr(start)) start = start.nextSibling; while (end !== start && ! nodeDescr(end)) end = end.previousSibling; - - // `this` is the template instance - buf.push('dom-' + this.data + ' is ' + nodeDescr(start) +'..' + + buf.push('dom-' + Blaze.getCurrentData() + + ' is ' + nodeDescr(start) +'..' + nodeDescr(end)); - }, - destroyed: function () { - // `this` is the template instance - buf.push('destroyed ' + this.data); - } - }); + }); + + view.onDestroyed(function () { + buf.push('destroyed ' + Blaze.getCurrentData()); + }); + + return Blaze.With(number, function () { return view; }); + }; var div = document.createElement("DIV"); - materialize(myComponent, div); + Blaze.render(makeView).attach(div); buf.push('---flush---'); Deps.flush(); test.equal(buf, ['created 1', @@ -469,9 +491,9 @@ Tinytest.add("ui - render - components", function (test) { 'created 3', '---flush---', // (proper order for these has not be thought out:) - 'dom-1 is 1..HR', + 'dom-3 is 3..HR', 'dom-2 is 2..HR', - 'dom-3 is 3..HR']); + 'dom-1 is 1..HR']); test.equal(canonicalizeHtml(div.innerHTML), '123
    '); @@ -485,13 +507,16 @@ Tinytest.add("ui - render - components", function (test) { buf.length = 0; counter = 1; - var html = toHTML(myComponent); + var html = Blaze.toHTML(makeView()); test.equal(buf, ['created 1', 'parent of 2 is 1', 'created 2', 'parent of 3 is 2', - 'created 3']); + 'created 3', + 'destroyed 3', + 'destroyed 2', + 'destroyed 1']); test.equal(html, '123
    '); })(); @@ -501,19 +526,19 @@ Tinytest.add("ui - render - findAll", function (test) { var found = null; var $found = null; - var myComponent = UI.Component.extend({ - render: function() { + var myTemplate = Template.__create__( + 'findAllTest', + function() { return DIV([P('first'), P('second')]); - }, - rendered: function() { - found = this.findAll('p'); - $found = this.$('p'); - }, - }); + }); + myTemplate.rendered = function() { + found = this.findAll('p'); + $found = this.$('p'); + }; var div = document.createElement("DIV"); - materialize(myComponent, div); + Blaze.render(myTemplate).attach(div); Deps.flush(); test.equal(_.isArray(found), true); @@ -526,15 +551,16 @@ Tinytest.add("ui - render - reactive attributes 2", function (test) { var R1 = ReactiveVar(['foo']); var R2 = ReactiveVar(['bar']); - var spanCode = SPAN({ - blah: function () { return R1.get(); }, - $dynamic: [function () { return { blah: [function () { return R2.get(); }] }; }] - }); + var spanFunc = function () { + return SPAN(HTML.Attrs( + { blah: function () { return R1.get(); } }, + function () { return { blah: R2.get() }; })); + }; var div = document.createElement("DIV"); - materialize(spanCode, div); + Blaze.render(spanFunc).attach(div); var check = function (expected) { - test.equal(toHTML(spanCode), expected); + test.equal(Blaze.toHTML(spanFunc()), expected); test.equal(canonicalizeHtml(div.innerHTML), expected); }; check(''); @@ -545,19 +571,16 @@ Tinytest.add("ui - render - reactive attributes 2", function (test) { R2.set([[]]); Deps.flush(); // We combine `['foo']` with what evaluates to `[[[]]]`, which is nully. - test.equal(spanCode.evaluateAttributes().blah, ["foo"]); check(''); R2.set([['']]); Deps.flush(); // We combine `['foo']` with what evaluates to `[[['']]]`, which is non-nully. - test.equal(spanCode.evaluateAttributes().blah, [[['']]]); check(''); R2.set(null); Deps.flush(); // We combine `['foo']` with `[null]`, which is nully. - test.equal(spanCode.evaluateAttributes().blah, ['foo']); check(''); R1.set([[], []]); @@ -611,101 +634,10 @@ Tinytest.add("ui - render - SVG", function (test) { test.equal(circle.parentNode.namespaceURI, "http://www.w3.org/2000/svg"); }); -Tinytest.add("ui - UI.render", function (test) { - var div = document.createElement("DIV"); - document.body.appendChild(div); +Tinytest.add("ui - attributes", function (test) { + var SPAN = HTML.SPAN; + var amp = HTML.CharRef({html: '&', str: '&'}); - var R = ReactiveVar('aaa'); - var tmpl = UI.Component.extend({ - render: function () { - var self = this; - return SPAN(function () { - return (self.get('greeting') || 'Hello') + ' ' + R.get(); - }); - } - }); - - UI.insert(UI.render(tmpl), div); - UI.insert(UI.renderWithData(tmpl, {greeting: 'Bye'}), div); - test.equal(canonicalizeHtml(div.innerHTML), - "Hello aaaBye aaa"); - R.set('bbb'); - Deps.flush(); - test.equal(canonicalizeHtml(div.innerHTML), - "Hello bbbBye bbb"); - - document.body.removeChild(div); -}); - -Tinytest.add("ui - UI.insert fails on jQuery objects", function (test) { - var tmpl = UI.Component.extend({ - render: function () { - return SPAN(); - } - }); - test.throws(function () { - UI.insert(UI.render(tmpl), $('body')); - }, /'parentElement' must be a DOM node/); - test.throws(function () { - UI.insert(UI.render(tmpl), document.body, $('body')); - }, /'nextNode' must be a DOM node/); -}); - -Tinytest.add("ui - UI.getDataContext", function (test) { - var div = document.createElement("DIV"); - - var tmpl = UI.Component.extend({ - render: function () { - return SPAN(); - } - }); - - UI.insert(UI.renderWithData(tmpl, {foo: "bar"}), div); - var span = $(div).children('SPAN')[0]; - test.isTrue(span); - test.equal(UI.getElementData(span), {foo: "bar"}); -}); - -Tinytest.add("ui - UI.render _nestInCurrentComputation flag", function (test) { - _.each([true, false], function (nest) { - - var firstComputation; - var rv1 = new ReactiveVar; - var rv2 = new ReactiveVar; - - // Render a component in an autorun. Save the current computation - // from the first time we run the render function. Invalidate the - // autorun, and check whether that stops the computation from the - // first time the component rendered. - - var tmpl = UI.Component.extend({ - render: function () { - return function () { - if (! firstComputation) { - firstComputation = Deps.currentComputation; - } - return rv1.get(); - }; - } - }); - - Deps.autorun(function () { - rv2.get(); // register a dependency - UI.render(tmpl, undefined, { - _nestInCurrentComputation: nest - }); - }); - - rv2.set("foo"); - Deps.flush(); - - // If we nested inside the current computation, then we expect the - // computation from within the render function to have been stopped - // when the outer computation was invalidated. - if (nest) { - test.equal(firstComputation.stopped, true); - } else { - test.equal(firstComputation.stopped, false); - } - }); + test.equal(HTML.toHTML(SPAN({title: ['M', amp, 'Ms']}, 'M', amp, 'M candies')), + 'M&M candies'); }); diff --git a/packages/ui/ui.js b/packages/ui/ui.js new file mode 100644 index 0000000000..e129a29445 --- /dev/null +++ b/packages/ui/ui.js @@ -0,0 +1,38 @@ +UI = {}; + +UI._globalHelpers = {}; + +UI.registerHelper = function (name, func) { + UI._globalHelpers[name] = func; +}; + +// Utility to HTML-escape a string. +UI._escape = (function() { + var escape_map = { + "<": "<", + ">": ">", + '"': """, + "'": "'", + "`": "`", /* IE allows backtick-delimited attributes?? */ + "&": "&" + }; + var escape_one = function(c) { + return escape_map[c]; + }; + + return function (x) { + return x.replace(/[&<>"'`]/g, escape_one); + }; +})(); + +var jsUrlsAllowed = false; +UI._allowJavascriptUrls = function () { + jsUrlsAllowed = true; +}; +UI._javascriptUrlsAllowed = function () { + return jsUrlsAllowed; +}; + +UI._parentData = Blaze._parentData; + +UI.getElementData = Blaze.getElementData; diff --git a/packages/underscore-tests/each_test.js b/packages/underscore-tests/each_test.js index 50b071ee8d..3b367b5816 100644 --- a/packages/underscore-tests/each_test.js +++ b/packages/underscore-tests/each_test.js @@ -1,4 +1,4 @@ -Tinytest.add("underscore - each", function (test) { +Tinytest.add("underscore-tests - each", function (test) { // arrays _.each([42], function (val, index) { test.equal(index, 0); diff --git a/packages/webapp/webapp_server.js b/packages/webapp/webapp_server.js index 93a29cd7e5..a1f2353648 100644 --- a/packages/webapp/webapp_server.js +++ b/packages/webapp/webapp_server.js @@ -224,7 +224,7 @@ WebApp._timeoutAdjustmentRequestCallback = function (req, res) { }; // Will be updated by main before we listen. -var boilerplateTemplate = null; +var boilerplateFunc = null; var boilerplateBaseData = null; var memoizedBoilerplate = {}; @@ -248,12 +248,9 @@ var getBoilerplate = function (request) { htmlAttributes: htmlAttributes, inlineScriptsAllowed: WebAppInternals.inlineScriptsAllowed() }, boilerplateBaseData); - var boilerplateInstance = boilerplateTemplate.extend({ - data: boilerplateData - }); - var boilerplateHtmlJs = boilerplateInstance.render(); + memoizedBoilerplate[boilerplateKey] = "\n" + - HTML.toHTML(boilerplateHtmlJs, boilerplateInstance); + Blaze.toHTML(Blaze.With(boilerplateData, boilerplateFunc)); } return memoizedBoilerplate[boilerplateKey]; }; @@ -489,8 +486,8 @@ var runWebAppServer = function () { if (! appUrl(req.url)) return next(); - if (!boilerplateTemplate) - throw new Error("boilerplateTemplate should be set before listening!"); + if (!boilerplateFunc) + throw new Error("boilerplateFunc should be set before listening!"); if (!boilerplateBaseData) throw new Error("boilerplateBaseData should be set before listening!"); @@ -663,17 +660,12 @@ var runWebAppServer = function () { }); var boilerplateTemplateSource = Assets.getText("boilerplate.html"); - var boilerplateRenderCode = Spacebars.compile( + var boilerplateRenderCode = SpacebarsCompiler.compile( boilerplateTemplateSource, { isBody: true }); // Note that we are actually depending on eval's local environment capture // so that UI and HTML are visible to the eval'd code. - var boilerplateRender = eval(boilerplateRenderCode); - - boilerplateTemplate = UI.Component.extend({ - kind: "MainPage", - render: boilerplateRender - }); + boilerplateFunc = eval(boilerplateRenderCode); // only start listening after all the startup code has run. var localPort = parseInt(process.env.PORT) || 0; diff --git a/scripts/doctool.js b/scripts/doctool.js new file mode 100755 index 0000000000..1a3622c542 --- /dev/null +++ b/scripts/doctool.js @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +/// # doctool.js +/// +/// Usage: `doctool.js ...jsfiles...` +/// +/// Reads each `.js` file and writes a `.md` file in the same directory. +/// The output file consists of the concatenation of the "doc comments" +/// in the input file, which are assumed to contain Markdown content, +/// including any section headings necessary to organize the file. +/// +/// A "doc comment" must begin at the start of a line or after +/// whitespace. There are two kinds of doc comments: `/** ... */` +/// (block) comments and `/// ...` (triple-slash) comments. +/// +/// If a file begins with the magic string "///!README", the output +/// filename is changed to `README.md`. +/// +/// Examples: +/// +/// ``` +/// /** +/// * This is a block comment. The parser strips the sequence, +/// * [optional whitespace, `*`, optional single space] from +/// * every line that has it. +/// * +/// For lines that don't, no big deal. +/// +/// Leading whitspace will be preserved here. +/// +/// * We can create a bullet list in here: +/// * +/// * * This is a bullet +/// */ +/// ``` +/// +/// ``` +/// /** Single-line block comments are also ok. */ +/// ``` +/// +/// ``` +/// /** +/// A block comment whose first line doesn't have a `*` receives +/// no stripping of `*` characters on any line. +/// +/// * This is a bullet +/// +/// */ +/// ``` +/// +/// ``` +/// /// A triple-slash comment starts with `///` followed by an +/// /// optional space (i.e. one space is removed if present). +/// /// Multiple consecutive lines that start with `///` are +/// /// treated together as a single doc comment. +/// /** Separate doc comments get separate paragraphs. */ +/// ``` + +var fs = require('fs'); +var path = require('path'); + +process.argv.slice(2).forEach(function (fileName) { + var text = fs.readFileSync(fileName, "utf8"); + + var outFileName = fileName.replace(/\.js$/, '') + '.md'; + if (text.slice(0, 10) === '///!README') { + outFileName = path.join(path.dirname(fileName), 'README.md'); + text = text.slice(10); + } + + var docComments = []; + for (;;) { + // This regex breaks down as follows: + // + // 1. Start of line + // 2. Optional whitespace (not newline!) + // 3. `///` (capturing group 1) or `/**` (group 2) + // 4. Looking ahead, NOT `/` or `*` + var nextOpener = /^[ \t]*(?:(\/\/\/)|(\/\*\*))(?![\/\*])/m.exec(text); + if (! nextOpener) + break; + text = text.slice(nextOpener.index + nextOpener[0].length); + if (nextOpener[1]) { + // triple-slash + text = text.replace(/^[ \t]/, ''); // optional space + var comment = text.match(/^[^\n]*/)[0]; + text = text.slice(comment.length); + var match; + while ((match = /^\n[ \t]*\/\/\/[ \t]?/.exec(text))) { + // multiple lines in a row become one comment + text = text.slice(match[0].length); + var restOfLine = text.match(/^[^\n]*/)[0]; + text = text.slice(restOfLine.length); + comment += '\n' + restOfLine; + } + if (comment.trim()) + docComments.push(['///', comment]); + } else if (nextOpener[2]) { + // block comment + var rawComment = text.match(/^[\s\S]*?\*\//); + if ((! rawComment) || (! rawComment[0])) + continue; + rawComment = rawComment[0]; + text = text.slice(rawComment.length); + rawComment = rawComment.slice(0, -2); // remove final `*/` + if (rawComment.slice(-1) === ' ') + // make that ' */' for the benefit of single-line blocks + rawComment = rawComment.slice(0, -1); + + var lines = rawComment.split('\n'); + + var stripStars = false; + if (lines[0].trim().length === 0) { + // The comment has a newline after the `/**` (with possible whitespace + // between). This is like most comments, though occasionally people + // may write `/** foo */` on one line. Skip the blank line. + lines.splice(0, 1); + if (! lines.length) + continue; + // Now we determine whether this is block comment with a column of + // asterisks running down the left side, so we can strip them. + stripStars = /^[ \t]*\*/.test(lines[1]); + } else { + // Trim beginning of line after `/**` + lines[0] = lines[0].replace(/^\s*/, ''); + } + + lines = lines.map(function (s) { + // Strip either up to an asterisk and then an optional space, + // or just an optional space, depending on `stripStars`. + if (stripStars) + return s.replace(/^[ \t]*\* ?/, ''); + else + return s; + }); + + var result = lines.join('\n'); + + if (result.trim()) + docComments.push(['/**', result]); + } + } + + if (docComments.length) { + var output = docComments.map(function (x) { return x[1]; }).join('\n\n'); + var fileShortName = path.basename(fileName); + output = '*This file is automatically generated from [`' + + fileShortName + '`](' + fileShortName + ').*\n\n' + output; + fs.writeFileSync(outFileName, output, 'utf8'); + console.log("Wrote " + docComments.length + " comments to " + outFileName); + } + + +}); diff --git a/scripts/doctool.md b/scripts/doctool.md new file mode 100644 index 0000000000..38f8ae5a6a --- /dev/null +++ b/scripts/doctool.md @@ -0,0 +1,57 @@ +*This file is automatically generated from [`doctool.js`](doctool.js).* + +# doctool.js + +Usage: `doctool.js ...jsfiles...` + +Reads each `.js` file and writes a `.md` file in the same directory. +The output file consists of the concatenation of the "doc comments" +in the input file, which are assumed to contain Markdown content, +including any section headings necessary to organize the file. + +A "doc comment" must begin at the start of a line or after +whitespace. There are two kinds of doc comments: `/** ... */` +(block) comments and `/// ...` (triple-slash) comments. + +If a file begins with the magic string "///!README", the output +filename is changed to `README.md`. + +Examples: + +``` +/** + * This is a block comment. The parser strips the sequence, + * [optional whitespace, `*`, optional single space] from + * every line that has it. + * +For lines that don't, no big deal. + + Leading whitspace will be preserved here. + + * We can create a bullet list in here: + * + * * This is a bullet + */ +``` + +``` +/** Single-line block comments are also ok. */ +``` + +``` +/** +A block comment whose first line doesn't have a `*` receives +no stripping of `*` characters on any line. + +* This is a bullet + +*/ +``` + +``` +/// A triple-slash comment starts with `///` followed by an +/// optional space (i.e. one space is removed if present). +/// Multiple consecutive lines that start with `///` are +/// treated together as a single doc comment. +/** Separate doc comments get separate paragraphs. */ +``` \ No newline at end of file diff --git a/scripts/doctool.md.md b/scripts/doctool.md.md new file mode 100644 index 0000000000..3988cc27ef --- /dev/null +++ b/scripts/doctool.md.md @@ -0,0 +1,30 @@ +*This file is automatically generated from [`doctool.md`](doctool.md).* + +This is a block comment. The parser strips the sequence, +[optional whitespace, `*`, optional single space] from +every line that has it. + +For lines that don't, no big deal. + + Leading whitspace will be preserved here. + +We can create a bullet list in here: + +* This is a bullet + + +Single-line block comments are also ok. + +A block comment whose first line doesn't have a `*` receives +no stripping of `*` characters on any line. + +* This is a bullet + + + +A triple-slash comment starts with `///` followed by an +optional space (i.e. one space is removed if present). +Multiple consecutive lines that start with `///` are +treated together as a single doc comment. + +Separate doc comments get separate paragraphs. \ No newline at end of file