From af9591e629bd02dd82ef06069664783fa534a877 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Mon, 7 Nov 2016 17:33:22 -0600 Subject: [PATCH 01/15] Implement server-side components --- src/index.js | 115 ++++++++++++++++++--- test/multiple-element-interactions-test.js | 3 +- test/serverside-components-test.js | 73 +++++++++++++ 3 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 test/serverside-components-test.js diff --git a/src/index.js b/src/index.js index 4ecfa28..606486e 100644 --- a/src/index.js +++ b/src/index.js @@ -48,10 +48,76 @@ exports.registerElement = function registerElement(name, options) { return registeredElements[name].constructor; }; -function recurseTree(rootNode, callback) { - for (let node of rootNode.childNodes) { - callback(node); - recurseTree(node, callback); +/** + * Registers an element that is intended to run server-side only, and thus + * replaced with a resolved value or nothing. + * + * Server-side elements ARE NOT required to contain a hyphen. + */ +exports.registerServerElement = function registerServerElement(name, handler) { + if ( registeredElements[name] && typeof registeredElements[name] !== 'function' ) { + throw new Error(`Registration failed for '${name}'. Name is already taken by a non-server-side element.`); + } + registeredElements[name] = handler; + return handler; +}; + + + +function transformTree(document, currentNode, callback) { + + var task = callback(currentNode); + + if ( task !== undefined ) { + let replaceNode = function replaceNode (results) { + if (results === null) { + currentNode.parentNode.removeChild(currentNode) + return Promise.resolve() + } + if (typeof results === 'string') { + var temp = document.createElement('template'); + temp.innerHTML = results; + results = temp.content.childNodes; + } + if (results) { + var fragment = document.createDocumentFragment(); + var newNodes = results.length ? slice.call(results) : [results]; + + newNodes.map( (newNode) => { + newNode.parentNode === currentNode && currentNode.removeChild(newNode); + fragment.appendChild(newNode); + }); + currentNode.parentNode.replaceChild(fragment, currentNode); + + return Promise.all( + newNodes.map((child) => transformTree(document, child, callback)) + ); + } + else { + return Promise.all( + map(currentNode.childNodes, (child) => transformTree(document, child, callback)) + ); + } + }; + + if ( task === null ) { + return replaceNode(null) + } + if ( task.then ) { + // Promise task; potential transformation + return task.then(replaceNode); + } + else { + // Syncronous transformation + return replaceNode(task); + } + } + else { + // This element has opted to do nothing to itself. + // Recurse on its children. + return Promise.all( + map(currentNode.childNodes, (child) => transformTree(document, child, callback)) + ); } } @@ -90,23 +156,38 @@ function renderNode(rootNode) { var document = getDocument(rootNode); - recurseTree(rootNode, (foundNode) => { + return transformTree(document, rootNode, (foundNode) => { if (foundNode.tagName) { let nodeType = foundNode.tagName.toLowerCase(); let customElement = registeredElements[nodeType]; - if (customElement) { + + if (customElement && typeof customElement === 'function') { + var subResult = customElement(foundNode, document); + + // Replace with children by default + return (subResult === undefined) ? null : subResult; + } + else if (customElement) { // TODO: Should probably clone node, not change prototype, for performance Object.setPrototypeOf(foundNode, customElement); + if (customElement.createdCallback) { - createdPromises.push(new Promise((resolve) => { - resolve(customElement.createdCallback.call(foundNode, document)); - })); + try { + var result = customElement.createdCallback.call(foundNode, document); + if ( result && result.then ) { + // Client-side custom elements never replace themselves; + // resolve with undefined to prevent such a scenario. + return result.then( () => undefined ); + } + } + catch (err) { + return Promise.reject(err); + } } } } - }); - - return Promise.all(createdPromises).then(() => rootNode); + }) + .then(() => rootNode); } /** @@ -154,3 +235,13 @@ function getDocument(rootNode) { return rootNode; } } + +function map (arrayLike, fn) { + var results = []; + for (var i=0; i < arrayLike.length; i++) { + results.push( fn(arrayLike[i]) ); + } + return results; +} + +var slice = Array.prototype.slice; diff --git a/test/multiple-element-interactions-test.js b/test/multiple-element-interactions-test.js index bcb8063..b067e91 100644 --- a/test/multiple-element-interactions-test.js +++ b/test/multiple-element-interactions-test.js @@ -42,7 +42,8 @@ describe("When multiple DOM elements are present", () => { }); }); - it("can read attributes from custom child element's prototypes", () => { + // Pending until we implement custom elements v1 + xit("can read attributes from custom child element's prototypes", () => { var DataSource = components.newElement(); DataSource.data = [1, 2, 3]; components.registerElement("data-source", { prototype: DataSource }); diff --git a/test/serverside-components-test.js b/test/serverside-components-test.js new file mode 100644 index 0000000..d359f79 --- /dev/null +++ b/test/serverside-components-test.js @@ -0,0 +1,73 @@ +var expect = require('chai').expect; + +var components = require("../src/index.js"); + +describe("A component that renders on the server", () => { + + it("removes itself by default", () => { + var itRan = false; + + components.registerServerElement("my-analytics", function (node) { + itRan = true; + }); + + return components.renderFragment( + "

OneTwo

Three
" + ).then((output) => { + expect(itRan).to.equal(true); + expect(output).to.equal("

One

Three
"); + }); + }); + + it("can replace itself with a new node via an HTML string", () => { + components.registerServerElement("my-timestamp", function (node) { + return `
123
`; + }); + + return components.renderFragment( + "

" + ).then((output) => { + expect(output).to.equal("

123

"); + }); + }); + + it("can replace itself with a child", () => { + components.registerServerElement("latter", function (node) { + return node.children[1]; + }); + + return components.renderFragment( + "

One

Two

" + ).then((output) => { + expect(output).to.equal("

Two

"); + }); + }); + + it("can replace itself with its children", () => { + var somethingHappened = false + components.registerServerElement("log", function (node) { + somethingHappened = true; + return node.children; + }); + + return components.renderFragment( + "

One

Two

" + ).then((output) => { + expect(output).to.equal("

One

Two

"); + }); + }) + + it("can make async requests", () => { + components.registerServerElement("user-count", function (node) { + return new Promise((resolve) => { + setTimeout(() => resolve("10"), 25) + }) + }); + + return components.renderFragment( + "" + ).then((output) => { + expect(output).to.equal("10"); + }); + }) +}); From 2671b99b331f450245bcac6b73de0edc5616610e Mon Sep 17 00:00:00 2001 From: Gilbert Date: Tue, 8 Nov 2016 19:36:59 -0600 Subject: [PATCH 02/15] Convert to custom elements spec v1. Closes #27 --- src/index.js | 173 ++++++----- src/registry.js | 315 +++++++++++++++++++++ test/asynchrony-test.js | 41 +-- test/basics-test.js | 51 ++-- test/element-validation-test.js | 31 +- test/example-components.js | 45 +-- test/multiple-element-interactions-test.js | 99 +++---- test/programmatic-usage-test.js | 9 +- test/serverside-components-test.js | 91 +++++- 9 files changed, 645 insertions(+), 210 deletions(-) create mode 100644 src/registry.js diff --git a/src/index.js b/src/index.js index 606486e..e18bb86 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,6 @@ "use strict"; var domino = require("domino"); -var validateElementName = require("validate-element-name"); /** * The DOM object (components.dom) exposes tradition DOM objects (normally globally available @@ -17,62 +16,39 @@ exports.dom = domino.impl; * with an element name, and options (typically including the prototype returned here as your * 'prototype' value). */ -exports.newElement = function newElement() { - return Object.create(domino.impl.HTMLElement.prototype); -}; +var CustomElementRegistry = require('./registry'); +exports.customElements = CustomElementRegistry.instance(); +exports.HTMLElement = CustomElementRegistry.HTMLElement; -var registeredElements = {}; +const _upgradedProp = '__$CE_upgraded'; /** - * Registers an element, so that it will be used when the given element name is found during parsing. - * - * Element names are required to contain a hyphen (to disambiguate them from existing element names), - * be entirely lower-case, and not start with a hyphen. + * Registers a transformer for a tag that is intended to run server-side. * - * The only option currently supported is 'prototype', which sets the prototype of the given element. - * This prototype will have its various callbacks called when it is found during document parsing, - * and properties of the prototype will be exposed within the DOM to other elements there in turn. + * At the moment, only one transformer is permitted per tag. */ -exports.registerElement = function registerElement(name, options) { - var nameValidationResult = validateElementName(name); - if (!nameValidationResult.isValid) { - throw new Error(`Registration failed for '${name}'. ${nameValidationResult.message}`); - } - - if (options && options.prototype) { - registeredElements[name] = options.prototype; - } else { - registeredElements[name] = exports.newElement(); - } - - return registeredElements[name].constructor; -}; +var transformers = {}; -/** - * Registers an element that is intended to run server-side only, and thus - * replaced with a resolved value or nothing. - * - * Server-side elements ARE NOT required to contain a hyphen. - */ -exports.registerServerElement = function registerServerElement(name, handler) { - if ( registeredElements[name] && typeof registeredElements[name] !== 'function' ) { - throw new Error(`Registration failed for '${name}'. Name is already taken by a non-server-side element.`); +exports.registerTransformer = function registerTransformer (name, handler) { + if ( transformers[name] && typeof transformers[name] !== 'function' ) { + throw new Error(`Registration failed for '${name}'. Name is already taken by another transformer.`); } - registeredElements[name] = handler; + transformers[name] = handler; return handler; }; +function transformTree(document, visitedNodes, currentNode, callback) { -function transformTree(document, currentNode, callback) { + var task = visitedNodes.has(currentNode) ? undefined : callback(currentNode); - var task = callback(currentNode); + visitedNodes.add(currentNode); if ( task !== undefined ) { let replaceNode = function replaceNode (results) { if (results === null) { - currentNode.parentNode.removeChild(currentNode) - return Promise.resolve() + currentNode.parentNode.removeChild(currentNode); + return Promise.resolve(); } if (typeof results === 'string') { var temp = document.createElement('template'); @@ -84,24 +60,24 @@ function transformTree(document, currentNode, callback) { var newNodes = results.length ? slice.call(results) : [results]; newNodes.map( (newNode) => { - newNode.parentNode === currentNode && currentNode.removeChild(newNode); + if (newNode.parentNode === currentNode) currentNode.removeChild(newNode); fragment.appendChild(newNode); }); currentNode.parentNode.replaceChild(fragment, currentNode); return Promise.all( - newNodes.map((child) => transformTree(document, child, callback)) + newNodes.map((child) => transformTree(document, visitedNodes, child, callback)) ); } else { return Promise.all( - map(currentNode.childNodes, (child) => transformTree(document, child, callback)) + map(currentNode.childNodes, (child) => transformTree(document, visitedNodes, child, callback)) ); } }; if ( task === null ) { - return replaceNode(null) + return replaceNode(null); } if ( task.then ) { // Promise task; potential transformation @@ -116,7 +92,7 @@ function transformTree(document, currentNode, callback) { // This element has opted to do nothing to itself. // Recurse on its children. return Promise.all( - map(currentNode.childNodes, (child) => transformTree(document, child, callback)) + map(currentNode.childNodes, (child) => transformTree(document, visitedNodes, child, callback)) ); } } @@ -155,35 +131,63 @@ function renderNode(rootNode) { let createdPromises = []; var document = getDocument(rootNode); + var visitedNodes = new Set(); + var upgradedNodes = new Set(); + var customElements = exports.customElements; + + return transformTree(document, visitedNodes, rootNode, function render (element) { + + var transformer = transformers[element.localName]; - return transformTree(document, rootNode, (foundNode) => { - if (foundNode.tagName) { - let nodeType = foundNode.tagName.toLowerCase(); - let customElement = registeredElements[nodeType]; + if (transformer && ! element.serverTransformed) { + let result = transformer(element, document); + element.serverTransformed = true; - if (customElement && typeof customElement === 'function') { - var subResult = customElement(foundNode, document); + let handleTransformerResult = (result) => { + if ( result === undefined && customElements.get(element.localName) ) { + // Re-render the transformed element as a custom element, + // since a corresponding custom tag is defined. + return render(element); + } + if ( result === undefined ) { + // Replace the element with its children; its server-side duties are fulfilled. + return element.childNodes; + } + else { + // The transformer has opted to do something specific. + return result; + } + }; - // Replace with children by default - return (subResult === undefined) ? null : subResult; + if ( result && result.then ) { + return result.then(handleTransformerResult); } - else if (customElement) { - // TODO: Should probably clone node, not change prototype, for performance - Object.setPrototypeOf(foundNode, customElement); - - if (customElement.createdCallback) { - try { - var result = customElement.createdCallback.call(foundNode, document); - if ( result && result.then ) { - // Client-side custom elements never replace themselves; - // resolve with undefined to prevent such a scenario. - return result.then( () => undefined ); - } - } - catch (err) { - return Promise.reject(err); + else { + return handleTransformerResult(result); + } + } + + const definition = customElements.getDefinition(element.localName); + + if (definition) { + if ( upgradedNodes.has(element[_upgradedProp]) ) { + return; + } + upgradeElement(element, definition, true); + upgradedNodes.add(element); + + if (definition.connectedCallback) { + try { + let result = definition.connectedCallback.call(element, document); + if ( result && result.then ) { + // Client-side custom elements never replace themselves; + // resolve with undefined to prevent such a scenario. + return result.then( () => undefined ); } } + catch (err) { + return Promise.reject(err); + } } } }) @@ -236,6 +240,35 @@ function getDocument(rootNode) { } } +function upgradeElement (element, definition, callConstructor) { + const prototype = definition.constructor.prototype; + Object.setPrototypeOf(element, prototype); + if (callConstructor) { + CustomElementRegistry.instance()._setNewInstance(element); + new (definition.constructor)(); + element[_upgradedProp] = true; + console.assert(CustomElementRegistry.instance()._newInstance === null); + } + + const observedAttributes = definition.observedAttributes; + const attributeChangedCallback = definition.attributeChangedCallback; + if (attributeChangedCallback && observedAttributes.length > 0) { + + // Trigger attributeChangedCallback for existing attributes. + // https://html.spec.whatwg.org/multipage/scripting.html#upgrades + for (let i = 0; i < observedAttributes.length; i++) { + const name = observedAttributes[i]; + if (element.hasAttribute(name)) { + const value = element.getAttribute(name); + attributeChangedCallback.call(element, name, null, value, null); + } + } + } + } + +// +// Helpers +// function map (arrayLike, fn) { var results = []; for (var i=0; i < arrayLike.length; i++) { @@ -244,4 +277,8 @@ function map (arrayLike, fn) { return results; } +function isClass(v) { + return typeof v === 'function' && /^\s*class\s+/.test(v.toString()); +} + var slice = Array.prototype.slice; diff --git a/src/registry.js b/src/registry.js new file mode 100644 index 0000000..e7c3cc2 --- /dev/null +++ b/src/registry.js @@ -0,0 +1,315 @@ +var domino = require("domino"); +var Document = require('domino/lib/Document'); +var Element = require('domino/lib/Element'); + +const _upgradedProp = '__$CE_upgraded'; + +const _customElements = () => CustomElementRegistry.instance(); + +/** + * A registry of custom element definitions. + * + * See https://html.spec.whatwg.org/multipage/scripting.html#customelementsregistry + * + * Implementation based on https://github.com/webcomponents/custom-elements/blob/master/src/custom-elements.js + * + */ +var _instance = null; +class CustomElementRegistry { + + static instance () { + if ( ! _instance ) _instance = new CustomElementRegistry(); + return _instance; + } + + constructor() { + this._definitions = new Map(); + this._constructors = new Map(); + this._whenDefinedMap = new Map(); + + this._newInstance = null; + } + + // HTML spec part 4.13.4 + // https://html.spec.whatwg.org/multipage/scripting.html#dom-customelementsregistry-define + /** + * @param {string} name + * @param {function(new:HTMLElement)} constructor + * @param {{extends: string}} options + * @return {undefined} + */ + define(name, constructor, options) { + // 1: + if (typeof constructor !== 'function') { + throw new TypeError('constructor must be a Constructor'); + } + + // 2. If constructor is an interface object whose corresponding interface + // either is HTMLElement or has HTMLElement in its set of inherited + // interfaces, throw a TypeError and abort these steps. + // + // It doesn't appear possible to check this condition from script + + // 3: + const nameError = checkValidCustomElementName(name); + if (nameError) throw nameError; + + // 4, 5: + // Note: we don't track being-defined names and constructors because + // define() isn't normally reentrant. The only time user code can run + // during define() is when getting callbacks off the prototype, which + // would be highly-unusual. We can make define() reentrant-safe if needed. + if (this._definitions.has(name)) { + throw new Error(`An element with name '${name}' is already defined`); + } + + // 6, 7: + if (this._constructors.has(constructor)) { + throw new Error(`Definition failed for '${name}': ` + + `The constructor is already used.`); + } + + // 8: + /** @type {string} */ + const localName = name; + + // 9, 10: We do not support extends currently. + + // 11, 12, 13: Our define() isn't rentrant-safe + + // 14.1: + const prototype = constructor.prototype; + + // 14.2: + if (typeof prototype !== 'object') { + throw new TypeError(`Definition failed for '${name}': ` + + `constructor.prototype must be an object`); + } + + function getCallback(callbackName) { + const callback = prototype[callbackName]; + if (callback !== undefined && typeof callback !== 'function') { + throw new Error(`${localName} '${callbackName}' is not a Function`); + } + return callback; + } + + // 3, 4: + const connectedCallback = getCallback('connectedCallback'); + + // 5, 6: + const disconnectedCallback = getCallback('disconnectedCallback'); + + // Divergence from spec: we always throw if attributeChangedCallback is + // not a function. + + // 7, 9.1: + const attributeChangedCallback = getCallback('attributeChangedCallback'); + + // 8, 9.2, 9.3: + const observedAttributes = + (attributeChangedCallback && constructor.observedAttributes) || []; + + // 15: + /** @type {CustomElementDefinition} */ + const definition = { + name: name, + localName: localName, + constructor: constructor, + connectedCallback: connectedCallback, + disconnectedCallback: disconnectedCallback, + attributeChangedCallback: attributeChangedCallback, + observedAttributes: observedAttributes, + }; + + // 16: + this._definitions.set(localName, definition); + this._constructors.set(constructor, localName); + + // 17, 18, 19: + // Since we are rendering server-side, no need to upgrade doc; + // custom elements will be defined before rendering takes place. + // this._upgradeDoc(); + + // 20: + const deferred = this._whenDefinedMap.get(localName); + if (deferred) { + deferred.resolve(undefined); + this._whenDefinedMap.delete(localName); + } + } + + /** + * Returns the constructor defined for `name`, or `null`. + * + * @param {string} name + * @return {Function|undefined} + */ + get(name) { + // https://html.spec.whatwg.org/multipage/scripting.html#custom-elements-api + const def = this._definitions.get(name); + return def ? def.constructor : undefined; + } + + /** + * Returns a `Promise` that resolves when a custom element for `name` has + * been defined. + * + * @param {string} name + * @return {!Promise} + */ + whenDefined(name) { + // https://html.spec.whatwg.org/multipage/scripting.html#dom-customelementsregistry-whendefined + const nameError = checkValidCustomElementName(name); + if (nameError) return Promise.reject(nameError); + if (this._definitions.has(name)) return Promise.resolve(); + + let deferred = this._whenDefinedMap.get(name); + if (deferred) return deferred.promise; + + let resolve; + const promise = new Promise(function(_resolve, _) { + resolve = _resolve; + }); + deferred = {promise, resolve}; + this._whenDefinedMap.set(name, deferred); + return promise; + } + + /** + * @param {?HTMLElement} instance + * @private + */ + _setNewInstance(instance) { + this._newInstance = instance; + } + + /** + * WARNING: NOT PART OF THE SPEC + * + * @param {string} localName + * @return {?CustomElementDefinition} + */ + getDefinition(localName) { + return this._definitions.get(localName); + } + + /** + * WARNING: NOT PART OF THE SPEC + * + * @param {string} localName + * @return {undefined} + */ + undefine(localName) { + this._definitions.delete(localName); + this._constructors.delete(localName); + this._whenDefinedMap.delete(localName); + } +} +exports = module.exports = CustomElementRegistry; + + +// +// Overwrite domino's new element constructor +// +const origHTMLElement = domino.impl.HTMLElement; + +const newHTMLElement = function HTMLElement() { + const customElements = _customElements(); + + // If there's an being upgraded, return that + if (customElements._newInstance) { + const i = customElements._newInstance; + customElements._newInstance = null; + return i; + } + if (this.constructor) { + // Find the tagname of the constructor and create a new element with it + const tagName = customElements._constructors.get(this.constructor); + return _createElement(doc, tagName, undefined, false); + } + throw new Error('Unknown constructor. Did you call customElements.define()?'); +}; +exports.HTMLElement = newHTMLElement; +exports.HTMLElement.prototype = Object.create(domino.impl.HTMLElement.prototype, { + constructor: {value: exports.HTMLElement, configurable: true, writable: true}, +}); + + +// +// Patch document.createElement +// +const _origCreateElement = Document.prototype.createElement; + +/** + * Creates a new element and upgrades it if it's a custom element. + * @param {!Document} doc + * @param {!string} tagName + * @param {Object|undefined} options + * @param {boolean} callConstructor whether or not to call the elements + * constructor after upgrading. If an element is created by calling its + * constructor, then `callConstructor` should be false to prevent double + * initialization. + */ +function _createElement(doc, tagName, options, callConstructor) { + const customElements = _customElements(); + const element = options ? _origCreateElement.call(doc, tagName, options) : + _origCreateElement.call(doc, tagName); + const definition = customElements._definitions.get(tagName.toLowerCase()); + if (definition) { + customElements._upgradeElement(element, definition, callConstructor); + } + return element; +} +Document.prototype.createElement = function(tagName, options) { + return _createElement(this, tagName, options, true); +}; + +// +// Patch doc.createElementNS +// +const HTMLNS = 'http://www.w3.org/1999/xhtml'; +const _origCreateElementNS = Document.prototype.createElementNS; + +Document.prototype.createElementNS = function(namespaceURI, qualifiedName) { + if (namespaceURI === 'http://www.w3.org/1999/xhtml') { + return this.createElement(qualifiedName); + } else { + return _origCreateElementNS.call(this, namespaceURI, qualifiedName); + } +}; + + +/** + * 2.3 + * http://w3c.github.io/webcomponents/spec/custom/#dfn-element-definition + * @typedef {{ + * name: string, + * localName: string, + * constructor: function(new:HTMLElement), + * connectedCallback: (Function|undefined), + * disconnectedCallback: (Function|undefined), + * attributeChangedCallback: (Function|undefined), + * observedAttributes: Array, + * }} + */ +let CustomElementDefinition; + + +const reservedTagList = [ + 'annotation-xml', + 'color-profile', + 'font-face', + 'font-face-src', + 'font-face-uri', + 'font-face-format', + 'font-face-name', + 'missing-glyph', +]; + +function checkValidCustomElementName(name) { + if (!(/^[a-z][.0-9_a-z]*-[\-.0-9_a-z]*$/.test(name) && + reservedTagList.indexOf(name) === -1)) { + return new Error(`The element name '${name}' is not valid.`); + } +} diff --git a/test/asynchrony-test.js b/test/asynchrony-test.js index 4d6bf88..72fd7fa 100644 --- a/test/asynchrony-test.js +++ b/test/asynchrony-test.js @@ -1,19 +1,21 @@ +"use strict"; var expect = require('chai').expect; var components = require("../src/index.js"); describe("An asynchronous element", () => { it("blocks rendering until they complete", () => { - var SlowElement = components.newElement(); - SlowElement.createdCallback = function () { - return new Promise((resolve, reject) => { - setTimeout(() => { - this.textContent = "loaded!"; - resolve(); - }, 1); - }); - }; - components.registerElement("slow-element", { prototype: SlowElement }); + class SlowElement extends components.HTMLElement { + connectedCallback() { + return new Promise((resolve, reject) => { + setTimeout(() => { + this.textContent = "loaded!"; + resolve(); + }, 1); + }); + } + } + components.customElements.define("slow-element", SlowElement); return components.renderFragment("").then((output) => { expect(output).to.equal("loaded!"); @@ -21,9 +23,12 @@ describe("An asynchronous element", () => { }); it("throw an async error if a component fails to render synchronously", () => { - var FailingElement = components.newElement(); - FailingElement.createdCallback = () => { throw new Error(); }; - components.registerElement("failing-element", { prototype: FailingElement }); + class FailingElement extends components.HTMLElement { + connectedCallback() { + throw new Error(); + } + } + components.customElements.define("failing-element", FailingElement); return components.renderFragment( "" @@ -33,9 +38,13 @@ describe("An asynchronous element", () => { }); it("throw an async error if a component fails to render asynchronously", () => { - var FailingElement = components.newElement(); - FailingElement.createdCallback = () => Promise.reject(new Error()); - components.registerElement("failing-element", { prototype: FailingElement }); + class FailingElement extends components.HTMLElement { + connectedCallback() { + return Promise.reject(new Error()); + } + } + components.customElements.undefine("failing-element"); + components.customElements.define("failing-element", FailingElement); return components.renderFragment( "" diff --git a/test/basics-test.js b/test/basics-test.js index 3282670..0bf4188 100644 --- a/test/basics-test.js +++ b/test/basics-test.js @@ -1,3 +1,4 @@ +"use strict"; var expect = require('chai').expect; var components = require("../src/index.js"); @@ -12,9 +13,12 @@ describe("Basic component functionality", () => { }); it("replaces components with their rendered result", () => { - var NewElement = components.newElement(); - NewElement.createdCallback = function () { this.textContent = "hi there"; }; - components.registerElement("my-element", { prototype: NewElement }); + class NewElement extends components.HTMLElement { + connectedCallback() { + this.textContent = "hi there"; + } + } + components.customElements.define("my-element", NewElement); return components.renderFragment("").then((output) => { expect(output).to.equal("hi there"); @@ -22,13 +26,12 @@ describe("Basic component functionality", () => { }); it("can wrap existing content", () => { - var PrefixedElement = components.newElement(); - PrefixedElement.createdCallback = function () { - this.innerHTML = "prefix:" + this.innerHTML; - }; - components.registerElement("prefixed-element", { - prototype: PrefixedElement - }); + class PrefixedElement extends components.HTMLElement { + connectedCallback() { + this.innerHTML = "prefix:" + this.innerHTML; + } + } + components.customElements.define("prefixed-element", PrefixedElement); return components.renderFragment( "existing-content" @@ -38,12 +41,13 @@ describe("Basic component functionality", () => { }); it("allows attribute access", () => { - var BadgeElement = components.newElement(); - BadgeElement.createdCallback = function () { - var name = this.getAttribute("name"); - this.innerHTML = "My name is:
" + name + "
"; - }; - components.registerElement("name-badge", { prototype: BadgeElement }); + class BadgeElement extends components.HTMLElement { + connectedCallback() { + var name = this.getAttribute("name"); + this.innerHTML = "My name is:
" + name + "
"; + } + } + components.customElements.define("name-badge", BadgeElement); return components.renderFragment( '' @@ -53,13 +57,14 @@ describe("Basic component functionality", () => { }); it("can use normal document methods like QuerySelector", () => { - var SelfFindingElement = components.newElement(); - SelfFindingElement.createdCallback = function (document) { - var hopefullyThis = document.querySelector("self-finding-element"); - if (hopefullyThis === this) this.innerHTML = "Found!"; - else this.innerHTML = "Not found, found " + hopefullyThis; - }; - components.registerElement("self-finding-element", { prototype: SelfFindingElement }); + class SelfFindingElement extends components.HTMLElement { + connectedCallback(document) { + var hopefullyThis = document.querySelector("self-finding-element"); + if (hopefullyThis === this) this.innerHTML = "Found!"; + else this.innerHTML = "Not found, found " + hopefullyThis; + } + } + components.customElements.define("self-finding-element", SelfFindingElement); return components.renderFragment( '' diff --git a/test/element-validation-test.js b/test/element-validation-test.js index d3523c3..73d6bdc 100644 --- a/test/element-validation-test.js +++ b/test/element-validation-test.js @@ -1,47 +1,42 @@ +"use strict"; var expect = require('chai').expect; var components = require("../src/index.js"); describe("Custom element validation", () => { - it("allows elements without options", () => { - components.registerElement("my-element"); - - return components.renderFragment(" { - var InvalidElement = components.newElement(); + class InvalidElement {} expect(() => { - components.registerElement("", { prototype: InvalidElement }); + components.customElements.define("", InvalidElement); }).to.throw( - /Registration failed for ''. Missing element name./ + /The element name '' is not valid./ ); }); it("requires a hyphen in the element name", () => { - var InvalidElement = components.newElement(); + class InvalidElement {} expect(() => { - components.registerElement("invalidname", { prototype: InvalidElement }); + components.customElements.define("invalidname", InvalidElement); }).to.throw( - /Registration failed for 'invalidname'. Custom element names must contain a hyphen./ + /The element name 'invalidname' is not valid./ ); }); it("doesn't allow elements to start with a hyphen", () => { - var InvalidElement = components.newElement(); + class InvalidElement {} expect(() => { - components.registerElement("-invalid-name", { prototype: InvalidElement }); + components.customElements.define("-invalid-name", InvalidElement); }).to.throw( - /Registration failed for '-invalid-name'. Custom element names must not start with a hyphen./ + /The element name '-invalid-name' is not valid./ ); }); it("requires element names to be lower case", () => { - var InvalidElement = components.newElement(); + class InvalidElement {} expect(() => { - components.registerElement("INVALID-NAME", { prototype: InvalidElement }); + components.customElements.define("INVALID-NAME", InvalidElement); }).to.throw( - /Registration failed for 'INVALID-NAME'. Custom element names must not contain uppercase ASCII characters./ + /The element name 'INVALID-NAME' is not valid./ ); }); }); diff --git a/test/example-components.js b/test/example-components.js index 67f03af..72741c7 100644 --- a/test/example-components.js +++ b/test/example-components.js @@ -1,3 +1,4 @@ +"use strict"; var expect = require('chai').expect; var components = require("../src/index.js"); @@ -6,12 +7,13 @@ var linkify = require("linkifyjs/element"); describe("An example component:", () => { describe("using static rendering", () => { before(() => { - var StaticElement = components.newElement(); - StaticElement.createdCallback = function () { - this.innerHTML = "Hi there"; - }; - - components.registerElement("my-greeting", { prototype: StaticElement }); + class StaticElement extends components.HTMLElement { + connectedCallback() { + this.innerHTML = "Hi there"; + } + } + components.customElements.undefine("my-greeting"); + components.customElements.define("my-greeting", StaticElement); }); it("replaces its content with the given text", () => { @@ -23,15 +25,16 @@ describe("An example component:", () => { describe("using dynamic logic for rendering", () => { before(() => { - var CounterElement = components.newElement(); var currentCount = 0; - CounterElement.createdCallback = function () { - currentCount += 1; - this.innerHTML = "There have been " + currentCount + " visitors."; - }; - - components.registerElement("visitor-counter", { prototype: CounterElement }); + class CounterElement extends components.HTMLElement { + connectedCallback() { + currentCount += 1; + this.innerHTML = "There have been " + currentCount + " visitors."; + } + } + components.customElements.undefine("visitor-counter"); + components.customElements.define("visitor-counter", CounterElement); }); it("dynamically changes its content", () => { @@ -49,14 +52,14 @@ describe("An example component:", () => { describe("parameterised by HTML content", () => { before(() => { - var LinkifyElement = components.newElement(); - - LinkifyElement.createdCallback = function (document) { - // Delegate the whole thing to a real normal front-end library! - linkify(this, { target: () => null, linkClass: "autolinked" }, document); - }; - - components.registerElement("linkify-urls", { prototype: LinkifyElement }); + class LinkifyElement extends components.HTMLElement { + connectedCallback(document) { + // Delegate the whole thing to a real front-end library! + linkify(this, { target: () => null, linkClass: "autolinked" }, document); + } + } + components.customElements.undefine("linkify-urls"); + components.customElements.define("linkify-urls", LinkifyElement); }); it("should be able to parse and manipulate it's content", () => { diff --git a/test/multiple-element-interactions-test.js b/test/multiple-element-interactions-test.js index b067e91..8454c2e 100644 --- a/test/multiple-element-interactions-test.js +++ b/test/multiple-element-interactions-test.js @@ -1,3 +1,4 @@ +"use strict"; var expect = require('chai').expect; var components = require("../src/index.js"); @@ -5,13 +6,13 @@ var components = require("../src/index.js"); describe("When multiple DOM elements are present", () => { describe("nested elements", () => { it("are rendered correctly", () => { - var PrefixedElement = components.newElement(); - PrefixedElement.createdCallback = function () { - this.innerHTML = "prefix:" + this.innerHTML; - }; - components.registerElement("prefixed-element", { - prototype: PrefixedElement - }); + class PrefixedElement extends components.HTMLElement { + connectedCallback() { + this.innerHTML = "prefix:" + this.innerHTML; + } + } + components.customElements.undefine("prefixed-element"); + components.customElements.define("prefixed-element", PrefixedElement); return components.renderFragment( "existing-content" @@ -25,13 +26,14 @@ describe("When multiple DOM elements are present", () => { describe("parent elements", () => { it("can see child elements", () => { - var ChildCountElement = components.newElement(); - ChildCountElement.createdCallback = function () { - var newNode = this.doc.createElement("div"); - newNode.textContent = this.childNodes.length + " children"; - this.insertBefore(newNode, this.firstChild); - }; - components.registerElement("child-count", { prototype: ChildCountElement }); + class ChildCountElement extends components.HTMLElement { + connectedCallback() { + var newNode = this.doc.createElement("div"); + newNode.textContent = this.childNodes.length + " children"; + this.insertBefore(newNode, this.firstChild); + } + } + components.customElements.define("child-count", ChildCountElement); return components.renderFragment( "
A child
Another child
" @@ -42,54 +44,55 @@ describe("When multiple DOM elements are present", () => { }); }); - // Pending until we implement custom elements v1 + // Pending until we decide on a good solution xit("can read attributes from custom child element's prototypes", () => { - var DataSource = components.newElement(); - DataSource.data = [1, 2, 3]; - components.registerElement("data-source", { prototype: DataSource }); + class DataSource extends components.HTMLElement { + connectedCallback() { + return new Promise((resolve) => { + // Has to be async, as child node prototypes aren't set: http://stackoverflow.com/questions/36187227/ + // This is a web components limitation generally. TODO: Find a nicer pattern for handle this. + setTimeout(() => { + var data = this.childNodes[0].data; + this.textContent = "Data: " + JSON.stringify(data); + resolve(); + }, 0); + }); + } + } + DataSource.data = [10, 20, 30]; - var DataDisplayer = components.newElement(); - DataDisplayer.createdCallback = function () { - return new Promise((resolve) => { - // Has to be async, as child node prototypes aren't set: http://stackoverflow.com/questions/36187227/ - // This is a web components limitation generally. TODO: Find a nicer pattern for handle this. - setTimeout(() => { - var data = this.childNodes[0].data; - this.textContent = "Data: " + JSON.stringify(data); - resolve(); - }, 0); - }); - }; - components.registerElement("data-displayer", { prototype: DataDisplayer }); + components.customElements.define("data-displayer", DataDisplayer); return components.renderFragment( "" ).then((output) => { expect(output).to.equal( - "Data: [1,2,3]" + "Data: [10,20,30]" ); }); }); it("receive bubbling events from child elements", () => { - var EventRecorder = components.newElement(); - EventRecorder.createdCallback = function (document) { - var resultsNode = document.createElement("p"); - this.appendChild(resultsNode); + class EventRecorder extends components.HTMLElement { + connectedCallback(document) { + var resultsNode = document.createElement("p"); + this.appendChild(resultsNode); - this.addEventListener("my-event", (event) => { - resultsNode.innerHTML = "Event received"; - }); - }; - components.registerElement("event-recorder", { prototype: EventRecorder }); + this.addEventListener("my-event", (event) => { + resultsNode.innerHTML = "Event received"; + }); + } + } + components.customElements.define("event-recorder", EventRecorder); - var EventElement = components.newElement(); - EventElement.createdCallback = function () { - this.dispatchEvent(new components.dom.CustomEvent('my-event', { - bubbles: true - })); - }; - components.registerElement("event-source", { prototype: EventElement }); + class EventElement extends components.HTMLElement { + connectedCallback() { + this.dispatchEvent(new components.dom.CustomEvent('my-event', { + bubbles: true + })); + } + } + components.customElements.define("event-source", EventElement); return components.renderFragment( "" diff --git a/test/programmatic-usage-test.js b/test/programmatic-usage-test.js index 076bfca..906b954 100644 --- a/test/programmatic-usage-test.js +++ b/test/programmatic-usage-test.js @@ -1,11 +1,14 @@ +"use strict"; var expect = require('chai').expect; var components = require("../src/index.js"); describe("Programmatic usage", () => { - it("returns the element constructor from the registration call", () => { - var NewElement = components.newElement(); - var registrationResult = components.registerElement("my-element", { prototype: NewElement }); + + // Pending until we decide what we want from this + xit("returns the element constructor from the registration call", () => { + class NewElement extends components.HTMLElement {} + var registrationResult = components.customElements.define("test-element", NewElement); expect(NewElement.constructor).to.equal(registrationResult); }); }); diff --git a/test/serverside-components-test.js b/test/serverside-components-test.js index d359f79..748b43f 100644 --- a/test/serverside-components-test.js +++ b/test/serverside-components-test.js @@ -1,26 +1,59 @@ +"use strict"; var expect = require('chai').expect; var components = require("../src/index.js"); describe("A component that renders on the server", () => { - it("removes itself by default", () => { + it("replaces itself with its children by default", () => { var itRan = false; - components.registerServerElement("my-analytics", function (node) { + components.registerTransformer("my-analytics", function (node) { itRan = true; }); return components.renderFragment( - "

OneTwo

Three
" + "

OneTwoThings

Three
" ).then((output) => { expect(itRan).to.equal(true); - expect(output).to.equal("

One

Three
"); + expect(output).to.equal("

OneTwoThings

Three
"); + }); + }); + + it("replaces itself with its children by default (async)", () => { + var itRan = false; + + components.registerTransformer("my-analytics", function (node) { + itRan = true; + return Promise.resolve(); + }); + + return components.renderFragment( + "

OneTwoThings

Three
" + ).then((output) => { + expect(itRan).to.equal(true); + expect(output).to.equal("

OneTwoThings

Three
"); + }); + }); + + it("can remove itself and its children", () => { + var itRan = false; + + components.registerTransformer("ghost", function (node) { + itRan = true; + return null; + }); + + return components.renderFragment( + "

One

Two

Three

" + ).then((output) => { + expect(itRan).to.equal(true); + expect(output).to.equal("

One

Three

"); }); }); it("can replace itself with a new node via an HTML string", () => { - components.registerServerElement("my-timestamp", function (node) { + components.registerTransformer("my-timestamp", function (node) { return `
123
`; }); @@ -32,7 +65,7 @@ describe("A component that renders on the server", () => { }); it("can replace itself with a child", () => { - components.registerServerElement("latter", function (node) { + components.registerTransformer("latter", function (node) { return node.children[1]; }); @@ -44,8 +77,8 @@ describe("A component that renders on the server", () => { }); it("can replace itself with its children", () => { - var somethingHappened = false - components.registerServerElement("log", function (node) { + var somethingHappened = false; + components.registerTransformer("log", function (node) { somethingHappened = true; return node.children; }); @@ -55,13 +88,13 @@ describe("A component that renders on the server", () => { ).then((output) => { expect(output).to.equal("

One

Two

"); }); - }) + }); it("can make async requests", () => { - components.registerServerElement("user-count", function (node) { + components.registerTransformer("user-count", function (node) { return new Promise((resolve) => { - setTimeout(() => resolve("10"), 25) - }) + setTimeout(() => resolve("10"), 25); + }); }); return components.renderFragment( @@ -69,5 +102,37 @@ describe("A component that renders on the server", () => { ).then((output) => { expect(output).to.equal("10"); }); - }) + }); + + it("can transform custom elements", () => { + var itRan = false; + var itRanToo = false; + + components.registerTransformer("double-render", function (node) { + itRan = true; + return new Promise((resolve) => { + setTimeout(function () { + node.setAttribute('data-preset', JSON.stringify({ x: 10 })); + resolve(); + }, 5); + }); + }); + + class MyElement extends components.HTMLElement { + connectedCallback() { + itRanToo = true; + this.textContent = this.getAttribute('data-preset'); + this.setAttribute('data-preset', '99'); + } + } + components.customElements.define("double-render", MyElement); + + return components.renderFragment( + "" + ).then((output) => { + expect(itRan).to.equal(true); + expect(itRanToo).to.equal(true); + expect(output).to.equal(`{"x":10}`); + }); + }); }); From 63ad32ab888e157040878ebd1fd2afd6305fbc17 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Thu, 10 Nov 2016 11:18:22 -0600 Subject: [PATCH 03/15] Add docs --- README.md | 40 ++++++++++++++++++---------------------- component-examples.md | 41 ++++++++++++++++++++--------------------- 2 files changed, 38 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 296585f..a8905a0 100644 --- a/README.md +++ b/README.md @@ -27,19 +27,19 @@ You can take the same ideas (and standards), apply them directly server side, to var components = require("server-components"); // Get the prototype for a new element -var NewElement = components.newElement(); - -// When the element is created during DOM parsing, you can transform the HTML inside it. -// This can be configurable too, either by setting attributes or adding HTML content -// inside it or elsewhere in the page it can interact with. Elements can fire events -// that other elements can receive to allow interactions, or even expose methods -// or data that other elements in the page can access directly. -NewElement.createdCallback = function () { - this.innerHTML = "Hi there"; -}; +class NewElement extends components.HTMLElement { + // When the element is created during DOM parsing, you can transform the HTML inside it. + // This can be configurable too, either by setting attributes or adding HTML content + // inside it or elsewhere in the page it can interact with. Elements can fire events + // that other elements can receive to allow interactions, or even expose methods + // or data that other elements in the page can access directly. + connectedCallback() { + this.innerHTML = "Hi there"; + } +} // Register the element with an element name -components.registerElement("my-new-element", { prototype: NewElement }); +components.customElements.define("my-new-element", NewElement); ``` For examples of more complex component definitions, take a look at the [example components](https://github.com/pimterry/server-components/blob/master/component-examples.md) @@ -83,7 +83,7 @@ There aren't many published sharable components to drop in quite yet, as it's st ### Top-level API -#### `components.newElement()` +#### `components.HTMLElement` Creates a returns a new custom HTML element prototype, extending the HTMLElement prototype. @@ -91,7 +91,7 @@ Note that this does *not* register the element. To do that, call `components.reg This is broadly equivalent to `Object.create(HTMLElement.prototype)` in browser land, and exactly equivalent here to `Object.create(components.dom.HTMLElement.prototype)`. You can call that yourself instead if you like, but it's a bit of a mouthful. -#### `components.registerElement(componentName, options)` +#### `components.customElements.define(componentName, Constructor)` Registers an element, so that it will be used when the given element name is found during parsing. @@ -131,9 +131,9 @@ These methods are methods you can implement on your component prototype (as retu Any methods that are implemented, from this selection or otherwise, will be exposed on your element in the DOM during rendering. I.e. you can call `document.querySelector("my-element").setTitle("New Title")` and to call the `setTitle` method on your object, which can then potentially change how your component is rendered. -#### `yourComponent.createdCallback(document)` +#### `yourComponentConstructor.prototype.createdCallback(document)` -Called when an element is created. +Called when an element is attached to the faux DOM. **This is where you put your magic!** Rewrite the elements contents to dynamically generate what your users will actually see client side. Read configuration from attributes or the initial child nodes to create flexible reconfigurable reusable elements. Register for events to create elements that interact with the rest of the application structure. Build your page. @@ -143,17 +143,13 @@ If this callback returns a promise, the rendering process will not resolve until These callbacks are called in opening tag order, so a parent's createdCallback is called, then each of its children's, then its next sibling element. -#### `yourComponent.attachedCallback(document)` - -Called when the element is attached to the DOM. This is different to when it's created when your component is being built programmatically, not through HTML parsing. *Not yet implemented* - -#### `yourComponent.detachedCallback(document)` +#### `yourComponentConstructor.prototype.disconnectedCallback(document)` Called when the element is removed from the DOM. *Not yet implemented* -#### `yourComponent.attributeChangedCallback(document)` +#### `yourComponentConstructor.prototype.attributeChangedCallback(document)` -Called when an attribute of the element is added, changed, or removed. *Not yet implemented*. +Called when an attribute of the element is added, changed, or removed. *Partially implemented;* runs on component initialization. **So far only the createdCallback is implemented here, as the others are less relevant initially for the key simpler cases. Each of those will be coming in time though! Watch this space.** diff --git a/component-examples.md b/component-examples.md index e873e0a..660b853 100644 --- a/component-examples.md +++ b/component-examples.md @@ -16,12 +16,12 @@ With the web component below, rendering `` will resul ```javascript var components = require("server-components"); -var StaticElement = components.newElement(); -StaticElement.createdCallback = function () { - this.innerHTML = "Hi there"; -}; - -components.registerElement("my-greeting", { prototype: StaticElement }); +class StaticElement extends components.HTMLElement { + connectedCallback() { + this.innerHTML = "Hi there" + } +} +components.customElements.define("my-greeting", StaticElement); ``` This is very basic, and toy cases like this aren't immediately useful, but this can be helpful for standard @@ -42,15 +42,15 @@ comeback! ```javascript var components = require("server-components"); -var CounterElement = components.newElement(); var currentCount = 0; -CounterElement.createdCallback = function () { - currentCount += 1; - this.innerHTML = "There have been " + currentCount + " visitors."; -}; - -components.registerElement("visitor-counter", { prototype: CounterElement }); +class CounterElement extends components.HTMLElement { + connectedCallback() { + currentCount += 1; + this.innerHTML = "There have been " + currentCount + " visitors."; + } +} +components.customElements.define("visitor-counter", CounterElement); ``` After a few visitors, this will render `` into something like @@ -84,14 +84,13 @@ For example, you might want a component that wraps HTML, parses all the text wit var components = require("server-components"); var linkify = require("linkifyjs/element"); -var LinkifyElement = components.newElement(); - -LinkifyElement.createdCallback = function (document) { - // Delegate the whole thing to a real normal front-end library! - linkify(this, { target: () => null, linkClass: "autolinked" }, document); - }; - -components.registerElement("linkify-urls", { prototype: LinkifyElement }); +class LinkifyElement extends components.HTMLElement { + connectedCallback() { + // Delegate the whole thing to a real front-end library! + linkify(this, { target: () => null, linkClass: "autolinked" }, document); + } +} +components.customElements.define("linkify-urls", LinkifyElement); ``` With this, we can pass HTML into Server Components that looks like From 88aaf5dedbdb72ff084ddfdb1cfcf86914774b50 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Thu, 10 Nov 2016 11:41:02 -0600 Subject: [PATCH 04/15] Remove server-side components feature for now --- package.json | 13 +- src/index.js | 118 ++---------------- test/multiple-element-interactions-test.js | 2 +- test/serverside-components-test.js | 138 --------------------- 4 files changed, 22 insertions(+), 249 deletions(-) delete mode 100644 test/serverside-components-test.js diff --git a/package.json b/package.json index 326553f..d7f5c27 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,18 @@ }, "jshintConfig": { "esversion": 6, - "node": true + "node": true, + "globals": { + "describe": false, + "xdescribe": false, + "it": false, + "xit": false, + "before": false, + "beforeEach": false, + "after": false, + "afterEach": false, + "expect": false + } }, "engines": { "node": ">= 4.0.0" diff --git a/src/index.js b/src/index.js index e18bb86..2cd310f 100644 --- a/src/index.js +++ b/src/index.js @@ -22,21 +22,6 @@ exports.HTMLElement = CustomElementRegistry.HTMLElement; const _upgradedProp = '__$CE_upgraded'; -/** - * Registers a transformer for a tag that is intended to run server-side. - * - * At the moment, only one transformer is permitted per tag. - */ -var transformers = {}; - -exports.registerTransformer = function registerTransformer (name, handler) { - if ( transformers[name] && typeof transformers[name] !== 'function' ) { - throw new Error(`Registration failed for '${name}'. Name is already taken by another transformer.`); - } - transformers[name] = handler; - return handler; -}; - function transformTree(document, visitedNodes, currentNode, callback) { @@ -44,56 +29,15 @@ function transformTree(document, visitedNodes, currentNode, callback) { visitedNodes.add(currentNode); - if ( task !== undefined ) { - let replaceNode = function replaceNode (results) { - if (results === null) { - currentNode.parentNode.removeChild(currentNode); - return Promise.resolve(); - } - if (typeof results === 'string') { - var temp = document.createElement('template'); - temp.innerHTML = results; - results = temp.content.childNodes; - } - if (results) { - var fragment = document.createDocumentFragment(); - var newNodes = results.length ? slice.call(results) : [results]; - - newNodes.map( (newNode) => { - if (newNode.parentNode === currentNode) currentNode.removeChild(newNode); - fragment.appendChild(newNode); - }); - currentNode.parentNode.replaceChild(fragment, currentNode); - - return Promise.all( - newNodes.map((child) => transformTree(document, visitedNodes, child, callback)) - ); - } - else { - return Promise.all( - map(currentNode.childNodes, (child) => transformTree(document, visitedNodes, child, callback)) - ); - } - }; + let visitChildren = () => Promise.all( + map(currentNode.childNodes, (child) => transformTree(document, visitedNodes, child, callback)) + ); - if ( task === null ) { - return replaceNode(null); - } - if ( task.then ) { - // Promise task; potential transformation - return task.then(replaceNode); - } - else { - // Syncronous transformation - return replaceNode(task); - } + if ( task && task.then ) { + return task.then(visitChildren); } else { - // This element has opted to do nothing to itself. - // Recurse on its children. - return Promise.all( - map(currentNode.childNodes, (child) => transformTree(document, visitedNodes, child, callback)) - ); + return visitChildren(); } } @@ -137,36 +81,6 @@ function renderNode(rootNode) { return transformTree(document, visitedNodes, rootNode, function render (element) { - var transformer = transformers[element.localName]; - - if (transformer && ! element.serverTransformed) { - let result = transformer(element, document); - element.serverTransformed = true; - - let handleTransformerResult = (result) => { - if ( result === undefined && customElements.get(element.localName) ) { - // Re-render the transformed element as a custom element, - // since a corresponding custom tag is defined. - return render(element); - } - if ( result === undefined ) { - // Replace the element with its children; its server-side duties are fulfilled. - return element.childNodes; - } - else { - // The transformer has opted to do something specific. - return result; - } - }; - - if ( result && result.then ) { - return result.then(handleTransformerResult); - } - else { - return handleTransformerResult(result); - } - } - const definition = customElements.getDefinition(element.localName); if (definition) { @@ -177,17 +91,9 @@ function renderNode(rootNode) { upgradedNodes.add(element); if (definition.connectedCallback) { - try { - let result = definition.connectedCallback.call(element, document); - if ( result && result.then ) { - // Client-side custom elements never replace themselves; - // resolve with undefined to prevent such a scenario. - return result.then( () => undefined ); - } - } - catch (err) { - return Promise.reject(err); - } + return new Promise(function(resolve, reject) { + resolve( definition.connectedCallback.call(element, document) ); + }); } } }) @@ -276,9 +182,3 @@ function map (arrayLike, fn) { } return results; } - -function isClass(v) { - return typeof v === 'function' && /^\s*class\s+/.test(v.toString()); -} - -var slice = Array.prototype.slice; diff --git a/test/multiple-element-interactions-test.js b/test/multiple-element-interactions-test.js index 8454c2e..795a2a0 100644 --- a/test/multiple-element-interactions-test.js +++ b/test/multiple-element-interactions-test.js @@ -61,7 +61,7 @@ describe("When multiple DOM elements are present", () => { } DataSource.data = [10, 20, 30]; - components.customElements.define("data-displayer", DataDisplayer); + components.customElements.define("data-displayer", DataSource); return components.renderFragment( "" diff --git a/test/serverside-components-test.js b/test/serverside-components-test.js deleted file mode 100644 index 748b43f..0000000 --- a/test/serverside-components-test.js +++ /dev/null @@ -1,138 +0,0 @@ -"use strict"; -var expect = require('chai').expect; - -var components = require("../src/index.js"); - -describe("A component that renders on the server", () => { - - it("replaces itself with its children by default", () => { - var itRan = false; - - components.registerTransformer("my-analytics", function (node) { - itRan = true; - }); - - return components.renderFragment( - "

OneTwoThings

Three
" - ).then((output) => { - expect(itRan).to.equal(true); - expect(output).to.equal("

OneTwoThings

Three
"); - }); - }); - - it("replaces itself with its children by default (async)", () => { - var itRan = false; - - components.registerTransformer("my-analytics", function (node) { - itRan = true; - return Promise.resolve(); - }); - - return components.renderFragment( - "

OneTwoThings

Three
" - ).then((output) => { - expect(itRan).to.equal(true); - expect(output).to.equal("

OneTwoThings

Three
"); - }); - }); - - it("can remove itself and its children", () => { - var itRan = false; - - components.registerTransformer("ghost", function (node) { - itRan = true; - return null; - }); - - return components.renderFragment( - "

One

Two

Three

" - ).then((output) => { - expect(itRan).to.equal(true); - expect(output).to.equal("

One

Three

"); - }); - }); - - it("can replace itself with a new node via an HTML string", () => { - components.registerTransformer("my-timestamp", function (node) { - return `
123
`; - }); - - return components.renderFragment( - "

" - ).then((output) => { - expect(output).to.equal("

123

"); - }); - }); - - it("can replace itself with a child", () => { - components.registerTransformer("latter", function (node) { - return node.children[1]; - }); - - return components.renderFragment( - "

One

Two

" - ).then((output) => { - expect(output).to.equal("

Two

"); - }); - }); - - it("can replace itself with its children", () => { - var somethingHappened = false; - components.registerTransformer("log", function (node) { - somethingHappened = true; - return node.children; - }); - - return components.renderFragment( - "

One

Two

" - ).then((output) => { - expect(output).to.equal("

One

Two

"); - }); - }); - - it("can make async requests", () => { - components.registerTransformer("user-count", function (node) { - return new Promise((resolve) => { - setTimeout(() => resolve("10"), 25); - }); - }); - - return components.renderFragment( - "" - ).then((output) => { - expect(output).to.equal("10"); - }); - }); - - it("can transform custom elements", () => { - var itRan = false; - var itRanToo = false; - - components.registerTransformer("double-render", function (node) { - itRan = true; - return new Promise((resolve) => { - setTimeout(function () { - node.setAttribute('data-preset', JSON.stringify({ x: 10 })); - resolve(); - }, 5); - }); - }); - - class MyElement extends components.HTMLElement { - connectedCallback() { - itRanToo = true; - this.textContent = this.getAttribute('data-preset'); - this.setAttribute('data-preset', '99'); - } - } - components.customElements.define("double-render", MyElement); - - return components.renderFragment( - "" - ).then((output) => { - expect(itRan).to.equal(true); - expect(itRanToo).to.equal(true); - expect(output).to.equal(`{"x":10}`); - }); - }); -}); From 329eff30a3d21025be7edc783756c2a9c2b958de Mon Sep 17 00:00:00 2001 From: Gilbert Date: Thu, 10 Nov 2016 13:00:19 -0600 Subject: [PATCH 05/15] Make travis happy --- src/registry.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/registry.js b/src/registry.js index e7c3cc2..7fde206 100644 --- a/src/registry.js +++ b/src/registry.js @@ -226,7 +226,8 @@ const newHTMLElement = function HTMLElement() { if (this.constructor) { // Find the tagname of the constructor and create a new element with it const tagName = customElements._constructors.get(this.constructor); - return _createElement(doc, tagName, undefined, false); + // Domino does not need a doc as a `this` parameter + return _createElement(null, tagName, undefined, false); } throw new Error('Unknown constructor. Did you call customElements.define()?'); }; From 568ee368938a481bbcce351389b3f7e9c3f60163 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Thu, 10 Nov 2016 13:37:58 -0600 Subject: [PATCH 06/15] Refactor to support older node versions --- src/extend-domino.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/registry.js | 34 +++++++--------------------------- 2 files changed, 51 insertions(+), 27 deletions(-) create mode 100644 src/extend-domino.js diff --git a/src/extend-domino.js b/src/extend-domino.js new file mode 100644 index 0000000..e06305b --- /dev/null +++ b/src/extend-domino.js @@ -0,0 +1,44 @@ +// +// Strict mode disallows us to overwrite Document.prototype properties. +// This file is to stay out of strict mode. +// +var domino = require("domino"); +var Document = require('domino/lib/Document'); +var Element = require('domino/lib/Element'); + + +module.exports = function (newHTMLElement, _createElement) { + var result = {}; + + // + // Patch document.createElement + // + Document.prototype.createElement = function(tagName, options) { + return _createElement(this, tagName, options, true); + }; + + // + // Patch HTMLElement + // + result.HTMLElement = newHTMLElement; + result.HTMLElement.prototype = Object.create(domino.impl.HTMLElement.prototype, { + constructor: {value: result.HTMLElement, configurable: true, writable: true}, + }); + + + // + // Patch doc.createElementNS + // + var HTMLNS = 'http://www.w3.org/1999/xhtml'; + var _origCreateElementNS = Document.prototype.createElementNS; + + Document.prototype.createElementNS = function(namespaceURI, qualifiedName) { + if (namespaceURI === 'http://www.w3.org/1999/xhtml') { + return this.createElement(qualifiedName); + } else { + return _origCreateElementNS.call(this, namespaceURI, qualifiedName); + } + }; + + return result; +}; diff --git a/src/registry.js b/src/registry.js index 7fde206..65d30e5 100644 --- a/src/registry.js +++ b/src/registry.js @@ -1,3 +1,4 @@ +"use strict"; var domino = require("domino"); var Document = require('domino/lib/Document'); var Element = require('domino/lib/Element'); @@ -210,9 +211,11 @@ exports = module.exports = CustomElementRegistry; // -// Overwrite domino's new element constructor +// - Overwrite domino's new element constructor +// - Patch domino's document.createElement // const origHTMLElement = domino.impl.HTMLElement; +const _origCreateElement = Document.prototype.createElement; const newHTMLElement = function HTMLElement() { const customElements = _customElements(); @@ -231,16 +234,6 @@ const newHTMLElement = function HTMLElement() { } throw new Error('Unknown constructor. Did you call customElements.define()?'); }; -exports.HTMLElement = newHTMLElement; -exports.HTMLElement.prototype = Object.create(domino.impl.HTMLElement.prototype, { - constructor: {value: exports.HTMLElement, configurable: true, writable: true}, -}); - - -// -// Patch document.createElement -// -const _origCreateElement = Document.prototype.createElement; /** * Creates a new element and upgrades it if it's a custom element. @@ -262,23 +255,10 @@ function _createElement(doc, tagName, options, callConstructor) { } return element; } -Document.prototype.createElement = function(tagName, options) { - return _createElement(this, tagName, options, true); -}; -// -// Patch doc.createElementNS -// -const HTMLNS = 'http://www.w3.org/1999/xhtml'; -const _origCreateElementNS = Document.prototype.createElementNS; - -Document.prototype.createElementNS = function(namespaceURI, qualifiedName) { - if (namespaceURI === 'http://www.w3.org/1999/xhtml') { - return this.createElement(qualifiedName); - } else { - return _origCreateElementNS.call(this, namespaceURI, qualifiedName); - } -}; + +var patched = require('./extend-domino')(newHTMLElement, _createElement); +exports.HTMLElement = patched.HTMLElement; /** From 106d66a9ccbbb5d3d1c863c90257593302cb0624 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Mon, 14 Nov 2016 11:19:52 -0600 Subject: [PATCH 07/15] Updates per @pimterry's review --- README.md | 10 +++++----- src/index.js | 7 +------ test/programmatic-usage-test.js | 8 +++++--- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a8905a0..2d1c889 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ You can take the same ideas (and standards), apply them directly server side, to ```javascript var components = require("server-components"); -// Get the prototype for a new element +// Define a new class that extends a native HTML Element class NewElement extends components.HTMLElement { // When the element is created during DOM parsing, you can transform the HTML inside it. // This can be configurable too, either by setting attributes or adding HTML content @@ -131,9 +131,9 @@ These methods are methods you can implement on your component prototype (as retu Any methods that are implemented, from this selection or otherwise, will be exposed on your element in the DOM during rendering. I.e. you can call `document.querySelector("my-element").setTitle("New Title")` and to call the `setTitle` method on your object, which can then potentially change how your component is rendered. -#### `yourComponentConstructor.prototype.createdCallback(document)` +#### `yourComponentConstructor.prototype.connectedCallback(document)` -Called when an element is attached to the faux DOM. +Called when an element is attached to the DOM. **This is where you put your magic!** Rewrite the elements contents to dynamically generate what your users will actually see client side. Read configuration from attributes or the initial child nodes to create flexible reconfigurable reusable elements. Register for events to create elements that interact with the rest of the application structure. Build your page. @@ -141,7 +141,7 @@ This method is called with `this` bound to the element that's being rendered (ju If this callback returns a promise, the rendering process will not resolve until that promise does, and will fail if that promise fails. You can use this to perform asynchronous actions without your component definitions. Pull tweets from twitter and draw them into the page, or anything else you can imagine. -These callbacks are called in opening tag order, so a parent's createdCallback is called, then each of its children's, then its next sibling element. +These callbacks are called in opening tag order, so a parent's connectedCallback is called, then each of its children's, then its next sibling element. #### `yourComponentConstructor.prototype.disconnectedCallback(document)` @@ -151,7 +151,7 @@ Called when the element is removed from the DOM. *Not yet implemented* Called when an attribute of the element is added, changed, or removed. *Partially implemented;* runs on component initialization. -**So far only the createdCallback is implemented here, as the others are less relevant initially for the key simpler cases. Each of those will be coming in time though! Watch this space.** +**So far only the connectedCallback is implemented here, as the others are less relevant initially for the key simpler cases. Each of those will be coming in time though! Watch this space.** ## Why does this exist? diff --git a/src/index.js b/src/index.js index 2cd310f..63446e1 100644 --- a/src/index.js +++ b/src/index.js @@ -33,12 +33,7 @@ function transformTree(document, visitedNodes, currentNode, callback) { map(currentNode.childNodes, (child) => transformTree(document, visitedNodes, child, callback)) ); - if ( task && task.then ) { - return task.then(visitChildren); - } - else { - return visitChildren(); - } + return Promise.resolve(task).then(visitChildren); } /** diff --git a/test/programmatic-usage-test.js b/test/programmatic-usage-test.js index 906b954..76290ad 100644 --- a/test/programmatic-usage-test.js +++ b/test/programmatic-usage-test.js @@ -6,9 +6,11 @@ var components = require("../src/index.js"); describe("Programmatic usage", () => { // Pending until we decide what we want from this - xit("returns the element constructor from the registration call", () => { + it("returns the element constructor from the registration call", () => { class NewElement extends components.HTMLElement {} - var registrationResult = components.customElements.define("test-element", NewElement); - expect(NewElement.constructor).to.equal(registrationResult); + components.customElements.define("test-element", NewElement); + + var klass = components.customElements.get("test-element"); + expect(klass).to.equal(NewElement); }); }); From 0b5a395e63c7609af5470047704b1a76c9e9b5c9 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Mon, 14 Nov 2016 11:27:57 -0600 Subject: [PATCH 08/15] Testing: Reset all instead of individual definitions --- src/registry.js | 8 ++++---- test/asynchrony-test.js | 5 ++++- test/example-components.js | 13 +++++++------ test/multiple-element-interactions-test.js | 5 ++++- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/registry.js b/src/registry.js index 65d30e5..6e1c589 100644 --- a/src/registry.js +++ b/src/registry.js @@ -201,10 +201,10 @@ class CustomElementRegistry { * @param {string} localName * @return {undefined} */ - undefine(localName) { - this._definitions.delete(localName); - this._constructors.delete(localName); - this._whenDefinedMap.delete(localName); + reset() { + this._definitions.clear(); + this._constructors.clear(); + this._whenDefinedMap.clear(); } } exports = module.exports = CustomElementRegistry; diff --git a/test/asynchrony-test.js b/test/asynchrony-test.js index 72fd7fa..89be353 100644 --- a/test/asynchrony-test.js +++ b/test/asynchrony-test.js @@ -4,6 +4,10 @@ var expect = require('chai').expect; var components = require("../src/index.js"); describe("An asynchronous element", () => { + beforeEach(() => { + components.customElements.reset(); + }); + it("blocks rendering until they complete", () => { class SlowElement extends components.HTMLElement { connectedCallback() { @@ -43,7 +47,6 @@ describe("An asynchronous element", () => { return Promise.reject(new Error()); } } - components.customElements.undefine("failing-element"); components.customElements.define("failing-element", FailingElement); return components.renderFragment( diff --git a/test/example-components.js b/test/example-components.js index 72741c7..279ee0e 100644 --- a/test/example-components.js +++ b/test/example-components.js @@ -5,14 +5,17 @@ var components = require("../src/index.js"); var linkify = require("linkifyjs/element"); describe("An example component:", () => { + beforeEach(() => { + components.customElements.reset(); + }); + describe("using static rendering", () => { - before(() => { + beforeEach(() => { class StaticElement extends components.HTMLElement { connectedCallback() { this.innerHTML = "Hi there"; } } - components.customElements.undefine("my-greeting"); components.customElements.define("my-greeting", StaticElement); }); @@ -24,7 +27,7 @@ describe("An example component:", () => { }); describe("using dynamic logic for rendering", () => { - before(() => { + beforeEach(() => { var currentCount = 0; class CounterElement extends components.HTMLElement { @@ -33,7 +36,6 @@ describe("An example component:", () => { this.innerHTML = "There have been " + currentCount + " visitors."; } } - components.customElements.undefine("visitor-counter"); components.customElements.define("visitor-counter", CounterElement); }); @@ -51,14 +53,13 @@ describe("An example component:", () => { }); describe("parameterised by HTML content", () => { - before(() => { + beforeEach(() => { class LinkifyElement extends components.HTMLElement { connectedCallback(document) { // Delegate the whole thing to a real front-end library! linkify(this, { target: () => null, linkClass: "autolinked" }, document); } } - components.customElements.undefine("linkify-urls"); components.customElements.define("linkify-urls", LinkifyElement); }); diff --git a/test/multiple-element-interactions-test.js b/test/multiple-element-interactions-test.js index 795a2a0..f0cddbc 100644 --- a/test/multiple-element-interactions-test.js +++ b/test/multiple-element-interactions-test.js @@ -4,6 +4,10 @@ var expect = require('chai').expect; var components = require("../src/index.js"); describe("When multiple DOM elements are present", () => { + beforeEach(() => { + components.customElements.reset(); + }); + describe("nested elements", () => { it("are rendered correctly", () => { class PrefixedElement extends components.HTMLElement { @@ -11,7 +15,6 @@ describe("When multiple DOM elements are present", () => { this.innerHTML = "prefix:" + this.innerHTML; } } - components.customElements.undefine("prefixed-element"); components.customElements.define("prefixed-element", PrefixedElement); return components.renderFragment( From 722040b2a7f718d270d889b3f801c26837667ef1 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Mon, 14 Nov 2016 11:38:06 -0600 Subject: [PATCH 09/15] Remove excessive tracking --- src/index.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 63446e1..4c6d695 100644 --- a/src/index.js +++ b/src/index.js @@ -71,7 +71,6 @@ function renderNode(rootNode) { var document = getDocument(rootNode); var visitedNodes = new Set(); - var upgradedNodes = new Set(); var customElements = exports.customElements; return transformTree(document, visitedNodes, rootNode, function render (element) { @@ -79,11 +78,10 @@ function renderNode(rootNode) { const definition = customElements.getDefinition(element.localName); if (definition) { - if ( upgradedNodes.has(element[_upgradedProp]) ) { + if ( element[_upgradedProp] ) { return; } upgradeElement(element, definition, true); - upgradedNodes.add(element); if (definition.connectedCallback) { return new Promise(function(resolve, reject) { From 723c656409a6c564ac1b1be723b7d640e372373d Mon Sep 17 00:00:00 2001 From: Gilbert Date: Mon, 14 Nov 2016 11:39:35 -0600 Subject: [PATCH 10/15] Remove assert --- src/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/index.js b/src/index.js index 4c6d695..862170a 100644 --- a/src/index.js +++ b/src/index.js @@ -146,7 +146,6 @@ function upgradeElement (element, definition, callConstructor) { CustomElementRegistry.instance()._setNewInstance(element); new (definition.constructor)(); element[_upgradedProp] = true; - console.assert(CustomElementRegistry.instance()._newInstance === null); } const observedAttributes = definition.observedAttributes; From 959bff709c2fd9fec08b58021edeb1ddc425b464 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Mon, 14 Nov 2016 13:58:55 -0600 Subject: [PATCH 11/15] Change map --- src/index.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/index.js b/src/index.js index 862170a..75e8d6f 100644 --- a/src/index.js +++ b/src/index.js @@ -168,9 +168,5 @@ function upgradeElement (element, definition, callConstructor) { // Helpers // function map (arrayLike, fn) { - var results = []; - for (var i=0; i < arrayLike.length; i++) { - results.push( fn(arrayLike[i]) ); - } - return results; + return Array.prototype.slice.call(arrayLike).map(fn); } From 71829e623f18d8e7b993d4e7b61ad22e6804bc6c Mon Sep 17 00:00:00 2001 From: Gilbert Date: Mon, 14 Nov 2016 14:23:21 -0600 Subject: [PATCH 12/15] Remove dependency --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index d7f5c27..e26117f 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,7 @@ "watch": "^0.18.0" }, "dependencies": { - "domino": "^1.0.23", - "validate-element-name": "^1.0.0" + "domino": "^1.0.23" }, "jshintConfig": { "esversion": 6, From 3f7c02ec3b27dc5806f3c491a570a5120fc10936 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Mon, 14 Nov 2016 14:07:01 -0600 Subject: [PATCH 13/15] Set define as the top level --- README.md | 22 ++++++------- component-examples.md | 18 +++++------ src/index.js | 15 +++++++++ test/asynchrony-test.js | 22 ++++++------- test/basics-test.js | 34 ++++++++++---------- test/element-validation-test.js | 10 +++--- test/example-components.js | 28 ++++++++--------- test/multiple-element-interactions-test.js | 36 +++++++++++----------- test/programmatic-usage-test.js | 8 ++--- 9 files changed, 104 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 2d1c889..bd3f5a5 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ You can take the same ideas (and standards), apply them directly server side, to #### Define a component ```javascript -var components = require("server-components"); +var customElements = require("server-components"); // Define a new class that extends a native HTML Element -class NewElement extends components.HTMLElement { +class NewElement extends customElements.HTMLElement { // When the element is created during DOM parsing, you can transform the HTML inside it. // This can be configurable too, either by setting attributes or adding HTML content // inside it or elsewhere in the page it can interact with. Elements can fire events @@ -39,7 +39,7 @@ class NewElement extends components.HTMLElement { } // Register the element with an element name -components.customElements.define("my-new-element", NewElement); +customElements.define("my-new-element", NewElement); ``` For examples of more complex component definitions, take a look at the [example components](https://github.com/pimterry/server-components/blob/master/component-examples.md) @@ -47,13 +47,13 @@ For examples of more complex component definitions, take a look at the [example #### Use your components ```javascript -var components = require("server-components"); +var customElements = require("server-components"); // Render the HTML, and receive a promise for the resulting HTML string. // The result is a promise because elements can render asynchronously, by returning // promises from their callbacks. This allows elements to render content from // external web services, your database, or anything else you can imagine. -components.renderPage(` +customElements.renderPage(` @@ -83,7 +83,7 @@ There aren't many published sharable components to drop in quite yet, as it's st ### Top-level API -#### `components.HTMLElement` +#### `customElements.HTMLElement` Creates a returns a new custom HTML element prototype, extending the HTMLElement prototype. @@ -91,7 +91,7 @@ Note that this does *not* register the element. To do that, call `components.reg This is broadly equivalent to `Object.create(HTMLElement.prototype)` in browser land, and exactly equivalent here to `Object.create(components.dom.HTMLElement.prototype)`. You can call that yourself instead if you like, but it's a bit of a mouthful. -#### `components.customElements.define(componentName, Constructor)` +#### `customElements.define(componentName, Constructor)` Registers an element, so that it will be used when the given element name is found during parsing. @@ -103,7 +103,7 @@ This returns the constructor for the new element, so you can construct and inser This is broadly equivalent to `document.registerElement` in browser land. -#### `components.renderPage(html)` +#### `customElements.renderPage(html)` Takes an HTML string for a full page, and returns a promise for the HTML string of the rendered result. Server Components parses the HTML, and for each registered element within calls its various callbacks (see the Component API) below as it does so. @@ -111,7 +111,7 @@ Unrecognized elements are left unchanged. When calling custom element callbacks To support the full DOM Document API, this method requires that you are rendering a full page (including ``, `` and `` tags). If you don't pass in content wrapped in those tags then they'll be automatically added, ensuring your resulting HTML has a full valid page structure. If that's not what you want, take a look at `renderFragment` below. -#### `components.renderFragment(html)` +#### `customElements.renderFragment(html)` Takes an HTML string for part of a page, and returns a promise for the HTML string of the rendered result. Server Components parses the HTML, and for each registered element within calls its various callbacks (see the Component API) below as it does so. @@ -119,9 +119,9 @@ Unrecognized elements are left unchanged. When calling custom element callbacks This method renders the content as a [Document Fragment](https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment), a sub-part of a full document. This means if you there are any ``, `` or `` tags in your input, they'll be stripped, as they're not legal within a fragment of a document. Note that this means the provided `document` object in your components will actually be a `DocumentFragment`, not a true `Document` object (although in most cases you can merrily ignore this). If you want to render a full page, take a look at `renderPage` above. -#### `components.dom` +#### `customElements.dom` -The DOM object (components.dom) exposes traditional DOM objects (normally globally available in browsers) such as the CustomEvent and various HTMLElement classes, typically use inside your component implementations. +The DOM object (customElements.dom) exposes traditional DOM objects (normally globally available in browsers) such as the CustomEvent and various HTMLElement classes, typically use inside your component implementations. This is (very) broadly equivalent to `window` in browser land. diff --git a/component-examples.md b/component-examples.md index 660b853..7aa3932 100644 --- a/component-examples.md +++ b/component-examples.md @@ -14,14 +14,14 @@ With the web component below, rendering `` will resul `Hi there`. ```javascript -var components = require("server-components"); +var customElements = require("server-components"); -class StaticElement extends components.HTMLElement { +class StaticElement extends customElements.HTMLElement { connectedCallback() { this.innerHTML = "Hi there" } } -components.customElements.define("my-greeting", StaticElement); +customElements.define("my-greeting", StaticElement); ``` This is very basic, and toy cases like this aren't immediately useful, but this can be helpful for standard @@ -40,17 +40,17 @@ example is below: a visitor counter. All the rage in the 90s, with web component comeback! ```javascript -var components = require("server-components"); +var customElements = require("server-components"); var currentCount = 0; -class CounterElement extends components.HTMLElement { +class CounterElement extends customElements.HTMLElement { connectedCallback() { currentCount += 1; this.innerHTML = "There have been " + currentCount + " visitors."; } } -components.customElements.define("visitor-counter", CounterElement); +customElements.define("visitor-counter", CounterElement); ``` After a few visitors, this will render `` into something like @@ -81,16 +81,16 @@ Components can be parameterized in all sorts of ways. One interesting pattern is For example, you might want a component that wraps HTML, parses all the text within, and replaces URL strings with actual links (using the excellent [Linkify library](https://github.com/SoapBox/linkifyjs), but here in a server side DOM, not a real one): ```javascript -var components = require("server-components"); +var customElements = require("server-components"); var linkify = require("linkifyjs/element"); -class LinkifyElement extends components.HTMLElement { +class LinkifyElement extends customElements.HTMLElement { connectedCallback() { // Delegate the whole thing to a real front-end library! linkify(this, { target: () => null, linkClass: "autolinked" }, document); } } -components.customElements.define("linkify-urls", LinkifyElement); +customElements.define("linkify-urls", LinkifyElement); ``` With this, we can pass HTML into Server Components that looks like diff --git a/src/index.js b/src/index.js index 75e8d6f..bc7ca84 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,21 @@ var CustomElementRegistry = require('./registry'); exports.customElements = CustomElementRegistry.instance(); exports.HTMLElement = CustomElementRegistry.HTMLElement; + +/** + * Re-export methods for convenience + */ +exports.define = function (name, constructor, options) { + return CustomElementRegistry.instance().define(name, constructor, options); +}; +exports.get = function (name) { + return CustomElementRegistry.instance().get(name); +}; +exports.whenDefined = function (name) { + return CustomElementRegistry.instance().whenDefined(name); +}; + + const _upgradedProp = '__$CE_upgraded'; diff --git a/test/asynchrony-test.js b/test/asynchrony-test.js index 89be353..22fcf8b 100644 --- a/test/asynchrony-test.js +++ b/test/asynchrony-test.js @@ -1,15 +1,15 @@ "use strict"; var expect = require('chai').expect; -var components = require("../src/index.js"); +var customElements = require("../src/index.js"); describe("An asynchronous element", () => { beforeEach(() => { - components.customElements.reset(); + customElements.customElements.reset(); }); it("blocks rendering until they complete", () => { - class SlowElement extends components.HTMLElement { + class SlowElement extends customElements.HTMLElement { connectedCallback() { return new Promise((resolve, reject) => { setTimeout(() => { @@ -19,22 +19,22 @@ describe("An asynchronous element", () => { }); } } - components.customElements.define("slow-element", SlowElement); + customElements.define("slow-element", SlowElement); - return components.renderFragment("").then((output) => { + return customElements.renderFragment("").then((output) => { expect(output).to.equal("loaded!"); }); }); it("throw an async error if a component fails to render synchronously", () => { - class FailingElement extends components.HTMLElement { + class FailingElement extends customElements.HTMLElement { connectedCallback() { throw new Error(); } } - components.customElements.define("failing-element", FailingElement); + customElements.define("failing-element", FailingElement); - return components.renderFragment( + return customElements.renderFragment( "" ).then((output) => { throw new Error("Should not successfully render"); @@ -42,14 +42,14 @@ describe("An asynchronous element", () => { }); it("throw an async error if a component fails to render asynchronously", () => { - class FailingElement extends components.HTMLElement { + class FailingElement extends customElements.HTMLElement { connectedCallback() { return Promise.reject(new Error()); } } - components.customElements.define("failing-element", FailingElement); + customElements.define("failing-element", FailingElement); - return components.renderFragment( + return customElements.renderFragment( "" ).then((output) => { throw new Error("Should not successfully render"); diff --git a/test/basics-test.js b/test/basics-test.js index 0bf4188..9e9620d 100644 --- a/test/basics-test.js +++ b/test/basics-test.js @@ -1,39 +1,39 @@ "use strict"; var expect = require('chai').expect; -var components = require("../src/index.js"); +var customElements = require("../src/index.js"); describe("Basic component functionality", () => { it("does nothing with vanilla HTML", () => { var input = "
"; - return components.renderFragment(input).then((output) => { + return customElements.renderFragment(input).then((output) => { expect(output).to.equal(input); }); }); - it("replaces components with their rendered result", () => { - class NewElement extends components.HTMLElement { + it("replaces customElements with their rendered result", () => { + class NewElement extends customElements.HTMLElement { connectedCallback() { this.textContent = "hi there"; } } - components.customElements.define("my-element", NewElement); + customElements.define("my-element", NewElement); - return components.renderFragment("").then((output) => { + return customElements.renderFragment("").then((output) => { expect(output).to.equal("hi there"); }); }); it("can wrap existing content", () => { - class PrefixedElement extends components.HTMLElement { + class PrefixedElement extends customElements.HTMLElement { connectedCallback() { this.innerHTML = "prefix:" + this.innerHTML; } } - components.customElements.define("prefixed-element", PrefixedElement); + customElements.define("prefixed-element", PrefixedElement); - return components.renderFragment( + return customElements.renderFragment( "existing-content" ).then((output) => expect(output).to.equal( "prefix:existing-content" @@ -41,15 +41,15 @@ describe("Basic component functionality", () => { }); it("allows attribute access", () => { - class BadgeElement extends components.HTMLElement { + class BadgeElement extends customElements.HTMLElement { connectedCallback() { var name = this.getAttribute("name"); this.innerHTML = "My name is:
" + name + "
"; } } - components.customElements.define("name-badge", BadgeElement); + customElements.define("name-badge", BadgeElement); - return components.renderFragment( + return customElements.renderFragment( '' ).then((output) => expect(output).to.equal( 'My name is:
Tim Perry
' @@ -57,16 +57,16 @@ describe("Basic component functionality", () => { }); it("can use normal document methods like QuerySelector", () => { - class SelfFindingElement extends components.HTMLElement { + class SelfFindingElement extends customElements.HTMLElement { connectedCallback(document) { var hopefullyThis = document.querySelector("self-finding-element"); if (hopefullyThis === this) this.innerHTML = "Found!"; else this.innerHTML = "Not found, found " + hopefullyThis; } } - components.customElements.define("self-finding-element", SelfFindingElement); + customElements.define("self-finding-element", SelfFindingElement); - return components.renderFragment( + return customElements.renderFragment( '' ).then((output) => expect(output).to.equal( 'Found!' @@ -74,7 +74,7 @@ describe("Basic component functionality", () => { }); it("wraps content in valid page content, if rendering a page", () => { - return components.renderPage("").then((output) => { + return customElements.renderPage("").then((output) => { expect(output).to.equal( "" ); @@ -82,7 +82,7 @@ describe("Basic component functionality", () => { }); it("strips , and tags, if only rendering a fragment", () => { - return components.renderFragment("").then((output) => { + return customElements.renderFragment("").then((output) => { expect(output).to.equal( "" ); diff --git a/test/element-validation-test.js b/test/element-validation-test.js index 73d6bdc..ffe6924 100644 --- a/test/element-validation-test.js +++ b/test/element-validation-test.js @@ -1,13 +1,13 @@ "use strict"; var expect = require('chai').expect; -var components = require("../src/index.js"); +var customElements = require("../src/index.js"); describe("Custom element validation", () => { it("requires a non-empty name", () => { class InvalidElement {} expect(() => { - components.customElements.define("", InvalidElement); + customElements.define("", InvalidElement); }).to.throw( /The element name '' is not valid./ ); @@ -16,7 +16,7 @@ describe("Custom element validation", () => { it("requires a hyphen in the element name", () => { class InvalidElement {} expect(() => { - components.customElements.define("invalidname", InvalidElement); + customElements.define("invalidname", InvalidElement); }).to.throw( /The element name 'invalidname' is not valid./ ); @@ -25,7 +25,7 @@ describe("Custom element validation", () => { it("doesn't allow elements to start with a hyphen", () => { class InvalidElement {} expect(() => { - components.customElements.define("-invalid-name", InvalidElement); + customElements.define("-invalid-name", InvalidElement); }).to.throw( /The element name '-invalid-name' is not valid./ ); @@ -34,7 +34,7 @@ describe("Custom element validation", () => { it("requires element names to be lower case", () => { class InvalidElement {} expect(() => { - components.customElements.define("INVALID-NAME", InvalidElement); + customElements.define("INVALID-NAME", InvalidElement); }).to.throw( /The element name 'INVALID-NAME' is not valid./ ); diff --git a/test/example-components.js b/test/example-components.js index 279ee0e..3335e1f 100644 --- a/test/example-components.js +++ b/test/example-components.js @@ -1,26 +1,26 @@ "use strict"; var expect = require('chai').expect; -var components = require("../src/index.js"); +var customElements = require("../src/index.js"); var linkify = require("linkifyjs/element"); describe("An example component:", () => { beforeEach(() => { - components.customElements.reset(); + customElements.customElements.reset(); }); describe("using static rendering", () => { beforeEach(() => { - class StaticElement extends components.HTMLElement { + class StaticElement extends customElements.HTMLElement { connectedCallback() { this.innerHTML = "Hi there"; } } - components.customElements.define("my-greeting", StaticElement); + customElements.define("my-greeting", StaticElement); }); it("replaces its content with the given text", () => { - return components.renderFragment("").then((output) => { + return customElements.renderFragment("").then((output) => { expect(output).to.equal("Hi there"); }); }); @@ -30,21 +30,21 @@ describe("An example component:", () => { beforeEach(() => { var currentCount = 0; - class CounterElement extends components.HTMLElement { + class CounterElement extends customElements.HTMLElement { connectedCallback() { currentCount += 1; this.innerHTML = "There have been " + currentCount + " visitors."; } } - components.customElements.define("visitor-counter", CounterElement); + customElements.define("visitor-counter", CounterElement); }); it("dynamically changes its content", () => { - components.renderFragment(""); - components.renderFragment(""); - components.renderFragment(""); + customElements.renderFragment(""); + customElements.renderFragment(""); + customElements.renderFragment(""); - return components.renderFragment("").then((output) => { + return customElements.renderFragment("").then((output) => { expect(output).to.equal( "There have been 4 visitors." ); @@ -54,17 +54,17 @@ describe("An example component:", () => { describe("parameterised by HTML content", () => { beforeEach(() => { - class LinkifyElement extends components.HTMLElement { + class LinkifyElement extends customElements.HTMLElement { connectedCallback(document) { // Delegate the whole thing to a real front-end library! linkify(this, { target: () => null, linkClass: "autolinked" }, document); } } - components.customElements.define("linkify-urls", LinkifyElement); + customElements.define("linkify-urls", LinkifyElement); }); it("should be able to parse and manipulate it's content", () => { - return components.renderFragment( + return customElements.renderFragment( "Have you heard of www.facebook.com?" ).then((output) => expect(output).to.equal( 'Have you heard of www.facebook.com?' diff --git a/test/multiple-element-interactions-test.js b/test/multiple-element-interactions-test.js index f0cddbc..0f1d3c9 100644 --- a/test/multiple-element-interactions-test.js +++ b/test/multiple-element-interactions-test.js @@ -1,23 +1,23 @@ "use strict"; var expect = require('chai').expect; -var components = require("../src/index.js"); +var customElements = require("../src/index.js"); describe("When multiple DOM elements are present", () => { beforeEach(() => { - components.customElements.reset(); + customElements.customElements.reset(); }); describe("nested elements", () => { it("are rendered correctly", () => { - class PrefixedElement extends components.HTMLElement { + class PrefixedElement extends customElements.HTMLElement { connectedCallback() { this.innerHTML = "prefix:" + this.innerHTML; } } - components.customElements.define("prefixed-element", PrefixedElement); + customElements.define("prefixed-element", PrefixedElement); - return components.renderFragment( + return customElements.renderFragment( "existing-content" ).then((output) => { expect(output).to.equal( @@ -29,16 +29,16 @@ describe("When multiple DOM elements are present", () => { describe("parent elements", () => { it("can see child elements", () => { - class ChildCountElement extends components.HTMLElement { + class ChildCountElement extends customElements.HTMLElement { connectedCallback() { var newNode = this.doc.createElement("div"); newNode.textContent = this.childNodes.length + " children"; this.insertBefore(newNode, this.firstChild); } } - components.customElements.define("child-count", ChildCountElement); + customElements.define("child-count", ChildCountElement); - return components.renderFragment( + return customElements.renderFragment( "
A child
Another child
" ).then((output) => { expect(output).to.equal( @@ -49,11 +49,11 @@ describe("When multiple DOM elements are present", () => { // Pending until we decide on a good solution xit("can read attributes from custom child element's prototypes", () => { - class DataSource extends components.HTMLElement { + class DataSource extends customElements.HTMLElement { connectedCallback() { return new Promise((resolve) => { // Has to be async, as child node prototypes aren't set: http://stackoverflow.com/questions/36187227/ - // This is a web components limitation generally. TODO: Find a nicer pattern for handle this. + // This is a web customElements limitation generally. TODO: Find a nicer pattern for handle this. setTimeout(() => { var data = this.childNodes[0].data; this.textContent = "Data: " + JSON.stringify(data); @@ -64,9 +64,9 @@ describe("When multiple DOM elements are present", () => { } DataSource.data = [10, 20, 30]; - components.customElements.define("data-displayer", DataSource); + customElements.define("data-displayer", DataSource); - return components.renderFragment( + return customElements.renderFragment( "" ).then((output) => { expect(output).to.equal( @@ -76,7 +76,7 @@ describe("When multiple DOM elements are present", () => { }); it("receive bubbling events from child elements", () => { - class EventRecorder extends components.HTMLElement { + class EventRecorder extends customElements.HTMLElement { connectedCallback(document) { var resultsNode = document.createElement("p"); this.appendChild(resultsNode); @@ -86,18 +86,18 @@ describe("When multiple DOM elements are present", () => { }); } } - components.customElements.define("event-recorder", EventRecorder); + customElements.define("event-recorder", EventRecorder); - class EventElement extends components.HTMLElement { + class EventElement extends customElements.HTMLElement { connectedCallback() { - this.dispatchEvent(new components.dom.CustomEvent('my-event', { + this.dispatchEvent(new customElements.dom.CustomEvent('my-event', { bubbles: true })); } } - components.customElements.define("event-source", EventElement); + customElements.define("event-source", EventElement); - return components.renderFragment( + return customElements.renderFragment( "" ).then((output) => { expect(output).to.equal( diff --git a/test/programmatic-usage-test.js b/test/programmatic-usage-test.js index 76290ad..fa5321c 100644 --- a/test/programmatic-usage-test.js +++ b/test/programmatic-usage-test.js @@ -1,16 +1,16 @@ "use strict"; var expect = require('chai').expect; -var components = require("../src/index.js"); +var customElements = require("../src/index.js"); describe("Programmatic usage", () => { // Pending until we decide what we want from this it("returns the element constructor from the registration call", () => { - class NewElement extends components.HTMLElement {} - components.customElements.define("test-element", NewElement); + class NewElement extends customElements.HTMLElement {} + customElements.define("test-element", NewElement); - var klass = components.customElements.get("test-element"); + var klass = customElements.customElements.get("test-element"); expect(klass).to.equal(NewElement); }); }); From 374b24f77824742b7699d38891cca88bda25d637 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Tue, 15 Nov 2016 10:54:37 -0600 Subject: [PATCH 14/15] Update & pass child-accessing test --- src/index.js | 17 ++++++++--------- test/multiple-element-interactions-test.js | 15 ++++++++++----- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/index.js b/src/index.js index bc7ca84..4414bf9 100644 --- a/src/index.js +++ b/src/index.js @@ -44,11 +44,9 @@ function transformTree(document, visitedNodes, currentNode, callback) { visitedNodes.add(currentNode); - let visitChildren = () => Promise.all( - map(currentNode.childNodes, (child) => transformTree(document, visitedNodes, child, callback)) - ); - - return Promise.resolve(task).then(visitChildren); + for (var child of currentNode.childNodes) { + transformTree(document, visitedNodes, child, callback); + } } /** @@ -88,7 +86,7 @@ function renderNode(rootNode) { var visitedNodes = new Set(); var customElements = exports.customElements; - return transformTree(document, visitedNodes, rootNode, function render (element) { + transformTree(document, visitedNodes, rootNode, function render (element) { const definition = customElements.getDefinition(element.localName); @@ -99,13 +97,14 @@ function renderNode(rootNode) { upgradeElement(element, definition, true); if (definition.connectedCallback) { - return new Promise(function(resolve, reject) { + var p = new Promise(function(resolve, reject) { resolve( definition.connectedCallback.call(element, document) ); }); + createdPromises.push(p); } } - }) - .then(() => rootNode); + }); + return Promise.all(createdPromises).then(function(){ return rootNode; }); } /** diff --git a/test/multiple-element-interactions-test.js b/test/multiple-element-interactions-test.js index 0f1d3c9..772ffa2 100644 --- a/test/multiple-element-interactions-test.js +++ b/test/multiple-element-interactions-test.js @@ -47,13 +47,19 @@ describe("When multiple DOM elements are present", () => { }); }); - // Pending until we decide on a good solution - xit("can read attributes from custom child element's prototypes", () => { + it("can read attributes from custom child element's prototypes", () => { class DataSource extends customElements.HTMLElement { + get data() { + return [10, 20, 30]; + } + } + customElements.define("data-source", DataSource); + + class DataDisplayer extends customElements.HTMLElement { connectedCallback() { return new Promise((resolve) => { // Has to be async, as child node prototypes aren't set: http://stackoverflow.com/questions/36187227/ - // This is a web customElements limitation generally. TODO: Find a nicer pattern for handle this. + // This is a web components limitation generally. TODO: Find a nicer pattern for handle this. setTimeout(() => { var data = this.childNodes[0].data; this.textContent = "Data: " + JSON.stringify(data); @@ -62,9 +68,8 @@ describe("When multiple DOM elements are present", () => { }); } } - DataSource.data = [10, 20, 30]; - customElements.define("data-displayer", DataSource); + customElements.define("data-displayer", DataDisplayer); return customElements.renderFragment( "" From 1a9c7d3cbbf7527f3d3daa507488a75e4a25186b Mon Sep 17 00:00:00 2001 From: Gilbert Date: Wed, 16 Nov 2016 15:11:41 -0600 Subject: [PATCH 15/15] customElements -> components --- README.md | 22 ++++++------- src/index.js | 3 ++ test/asynchrony-test.js | 22 ++++++------- test/basics-test.js | 34 +++++++++---------- test/element-validation-test.js | 10 +++--- test/example-components.js | 28 ++++++++-------- test/multiple-element-interactions-test.js | 38 +++++++++++----------- test/programmatic-usage-test.js | 8 ++--- 8 files changed, 84 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index bd3f5a5..a98f643 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ You can take the same ideas (and standards), apply them directly server side, to #### Define a component ```javascript -var customElements = require("server-components"); +var components = require("server-components"); // Define a new class that extends a native HTML Element -class NewElement extends customElements.HTMLElement { +class NewElement extends components.HTMLElement { // When the element is created during DOM parsing, you can transform the HTML inside it. // This can be configurable too, either by setting attributes or adding HTML content // inside it or elsewhere in the page it can interact with. Elements can fire events @@ -39,7 +39,7 @@ class NewElement extends customElements.HTMLElement { } // Register the element with an element name -customElements.define("my-new-element", NewElement); +components.define("my-new-element", NewElement); ``` For examples of more complex component definitions, take a look at the [example components](https://github.com/pimterry/server-components/blob/master/component-examples.md) @@ -47,13 +47,13 @@ For examples of more complex component definitions, take a look at the [example #### Use your components ```javascript -var customElements = require("server-components"); +var components = require("server-components"); // Render the HTML, and receive a promise for the resulting HTML string. // The result is a promise because elements can render asynchronously, by returning // promises from their callbacks. This allows elements to render content from // external web services, your database, or anything else you can imagine. -customElements.renderPage(` +components.renderPage(` @@ -83,7 +83,7 @@ There aren't many published sharable components to drop in quite yet, as it's st ### Top-level API -#### `customElements.HTMLElement` +#### `components.HTMLElement` Creates a returns a new custom HTML element prototype, extending the HTMLElement prototype. @@ -91,7 +91,7 @@ Note that this does *not* register the element. To do that, call `components.reg This is broadly equivalent to `Object.create(HTMLElement.prototype)` in browser land, and exactly equivalent here to `Object.create(components.dom.HTMLElement.prototype)`. You can call that yourself instead if you like, but it's a bit of a mouthful. -#### `customElements.define(componentName, Constructor)` +#### `components.define(componentName, Constructor)` Registers an element, so that it will be used when the given element name is found during parsing. @@ -103,7 +103,7 @@ This returns the constructor for the new element, so you can construct and inser This is broadly equivalent to `document.registerElement` in browser land. -#### `customElements.renderPage(html)` +#### `components.renderPage(html)` Takes an HTML string for a full page, and returns a promise for the HTML string of the rendered result. Server Components parses the HTML, and for each registered element within calls its various callbacks (see the Component API) below as it does so. @@ -111,7 +111,7 @@ Unrecognized elements are left unchanged. When calling custom element callbacks To support the full DOM Document API, this method requires that you are rendering a full page (including ``, `` and `` tags). If you don't pass in content wrapped in those tags then they'll be automatically added, ensuring your resulting HTML has a full valid page structure. If that's not what you want, take a look at `renderFragment` below. -#### `customElements.renderFragment(html)` +#### `components.renderFragment(html)` Takes an HTML string for part of a page, and returns a promise for the HTML string of the rendered result. Server Components parses the HTML, and for each registered element within calls its various callbacks (see the Component API) below as it does so. @@ -119,9 +119,9 @@ Unrecognized elements are left unchanged. When calling custom element callbacks This method renders the content as a [Document Fragment](https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment), a sub-part of a full document. This means if you there are any ``, `` or `` tags in your input, they'll be stripped, as they're not legal within a fragment of a document. Note that this means the provided `document` object in your components will actually be a `DocumentFragment`, not a true `Document` object (although in most cases you can merrily ignore this). If you want to render a full page, take a look at `renderPage` above. -#### `customElements.dom` +#### `components.dom` -The DOM object (customElements.dom) exposes traditional DOM objects (normally globally available in browsers) such as the CustomEvent and various HTMLElement classes, typically use inside your component implementations. +The DOM object (components.dom) exposes traditional DOM objects (normally globally available in browsers) such as the CustomEvent and various HTMLElement classes, typically use inside your component implementations. This is (very) broadly equivalent to `window` in browser land. diff --git a/src/index.js b/src/index.js index 4414bf9..8b0a459 100644 --- a/src/index.js +++ b/src/index.js @@ -33,6 +33,9 @@ exports.get = function (name) { exports.whenDefined = function (name) { return CustomElementRegistry.instance().whenDefined(name); }; +exports.reset = function (name) { + return CustomElementRegistry.instance().reset(); +}; const _upgradedProp = '__$CE_upgraded'; diff --git a/test/asynchrony-test.js b/test/asynchrony-test.js index 22fcf8b..522420b 100644 --- a/test/asynchrony-test.js +++ b/test/asynchrony-test.js @@ -1,15 +1,15 @@ "use strict"; var expect = require('chai').expect; -var customElements = require("../src/index.js"); +var components = require("../src/index.js"); describe("An asynchronous element", () => { beforeEach(() => { - customElements.customElements.reset(); + components.reset(); }); it("blocks rendering until they complete", () => { - class SlowElement extends customElements.HTMLElement { + class SlowElement extends components.HTMLElement { connectedCallback() { return new Promise((resolve, reject) => { setTimeout(() => { @@ -19,22 +19,22 @@ describe("An asynchronous element", () => { }); } } - customElements.define("slow-element", SlowElement); + components.define("slow-element", SlowElement); - return customElements.renderFragment("").then((output) => { + return components.renderFragment("").then((output) => { expect(output).to.equal("loaded!"); }); }); it("throw an async error if a component fails to render synchronously", () => { - class FailingElement extends customElements.HTMLElement { + class FailingElement extends components.HTMLElement { connectedCallback() { throw new Error(); } } - customElements.define("failing-element", FailingElement); + components.define("failing-element", FailingElement); - return customElements.renderFragment( + return components.renderFragment( "" ).then((output) => { throw new Error("Should not successfully render"); @@ -42,14 +42,14 @@ describe("An asynchronous element", () => { }); it("throw an async error if a component fails to render asynchronously", () => { - class FailingElement extends customElements.HTMLElement { + class FailingElement extends components.HTMLElement { connectedCallback() { return Promise.reject(new Error()); } } - customElements.define("failing-element", FailingElement); + components.define("failing-element", FailingElement); - return customElements.renderFragment( + return components.renderFragment( "" ).then((output) => { throw new Error("Should not successfully render"); diff --git a/test/basics-test.js b/test/basics-test.js index 9e9620d..bfa6a66 100644 --- a/test/basics-test.js +++ b/test/basics-test.js @@ -1,39 +1,39 @@ "use strict"; var expect = require('chai').expect; -var customElements = require("../src/index.js"); +var components = require("../src/index.js"); describe("Basic component functionality", () => { it("does nothing with vanilla HTML", () => { var input = "
"; - return customElements.renderFragment(input).then((output) => { + return components.renderFragment(input).then((output) => { expect(output).to.equal(input); }); }); - it("replaces customElements with their rendered result", () => { - class NewElement extends customElements.HTMLElement { + it("replaces components with their rendered result", () => { + class NewElement extends components.HTMLElement { connectedCallback() { this.textContent = "hi there"; } } - customElements.define("my-element", NewElement); + components.define("my-element", NewElement); - return customElements.renderFragment("").then((output) => { + return components.renderFragment("").then((output) => { expect(output).to.equal("hi there"); }); }); it("can wrap existing content", () => { - class PrefixedElement extends customElements.HTMLElement { + class PrefixedElement extends components.HTMLElement { connectedCallback() { this.innerHTML = "prefix:" + this.innerHTML; } } - customElements.define("prefixed-element", PrefixedElement); + components.define("prefixed-element", PrefixedElement); - return customElements.renderFragment( + return components.renderFragment( "existing-content" ).then((output) => expect(output).to.equal( "prefix:existing-content" @@ -41,15 +41,15 @@ describe("Basic component functionality", () => { }); it("allows attribute access", () => { - class BadgeElement extends customElements.HTMLElement { + class BadgeElement extends components.HTMLElement { connectedCallback() { var name = this.getAttribute("name"); this.innerHTML = "My name is:
" + name + "
"; } } - customElements.define("name-badge", BadgeElement); + components.define("name-badge", BadgeElement); - return customElements.renderFragment( + return components.renderFragment( '' ).then((output) => expect(output).to.equal( 'My name is:
Tim Perry
' @@ -57,16 +57,16 @@ describe("Basic component functionality", () => { }); it("can use normal document methods like QuerySelector", () => { - class SelfFindingElement extends customElements.HTMLElement { + class SelfFindingElement extends components.HTMLElement { connectedCallback(document) { var hopefullyThis = document.querySelector("self-finding-element"); if (hopefullyThis === this) this.innerHTML = "Found!"; else this.innerHTML = "Not found, found " + hopefullyThis; } } - customElements.define("self-finding-element", SelfFindingElement); + components.define("self-finding-element", SelfFindingElement); - return customElements.renderFragment( + return components.renderFragment( '' ).then((output) => expect(output).to.equal( 'Found!' @@ -74,7 +74,7 @@ describe("Basic component functionality", () => { }); it("wraps content in valid page content, if rendering a page", () => { - return customElements.renderPage("").then((output) => { + return components.renderPage("").then((output) => { expect(output).to.equal( "" ); @@ -82,7 +82,7 @@ describe("Basic component functionality", () => { }); it("strips , and tags, if only rendering a fragment", () => { - return customElements.renderFragment("").then((output) => { + return components.renderFragment("").then((output) => { expect(output).to.equal( "" ); diff --git a/test/element-validation-test.js b/test/element-validation-test.js index ffe6924..5fb0406 100644 --- a/test/element-validation-test.js +++ b/test/element-validation-test.js @@ -1,13 +1,13 @@ "use strict"; var expect = require('chai').expect; -var customElements = require("../src/index.js"); +var components = require("../src/index.js"); describe("Custom element validation", () => { it("requires a non-empty name", () => { class InvalidElement {} expect(() => { - customElements.define("", InvalidElement); + components.define("", InvalidElement); }).to.throw( /The element name '' is not valid./ ); @@ -16,7 +16,7 @@ describe("Custom element validation", () => { it("requires a hyphen in the element name", () => { class InvalidElement {} expect(() => { - customElements.define("invalidname", InvalidElement); + components.define("invalidname", InvalidElement); }).to.throw( /The element name 'invalidname' is not valid./ ); @@ -25,7 +25,7 @@ describe("Custom element validation", () => { it("doesn't allow elements to start with a hyphen", () => { class InvalidElement {} expect(() => { - customElements.define("-invalid-name", InvalidElement); + components.define("-invalid-name", InvalidElement); }).to.throw( /The element name '-invalid-name' is not valid./ ); @@ -34,7 +34,7 @@ describe("Custom element validation", () => { it("requires element names to be lower case", () => { class InvalidElement {} expect(() => { - customElements.define("INVALID-NAME", InvalidElement); + components.define("INVALID-NAME", InvalidElement); }).to.throw( /The element name 'INVALID-NAME' is not valid./ ); diff --git a/test/example-components.js b/test/example-components.js index 3335e1f..c1f8efd 100644 --- a/test/example-components.js +++ b/test/example-components.js @@ -1,26 +1,26 @@ "use strict"; var expect = require('chai').expect; -var customElements = require("../src/index.js"); +var components = require("../src/index.js"); var linkify = require("linkifyjs/element"); describe("An example component:", () => { beforeEach(() => { - customElements.customElements.reset(); + components.reset(); }); describe("using static rendering", () => { beforeEach(() => { - class StaticElement extends customElements.HTMLElement { + class StaticElement extends components.HTMLElement { connectedCallback() { this.innerHTML = "Hi there"; } } - customElements.define("my-greeting", StaticElement); + components.define("my-greeting", StaticElement); }); it("replaces its content with the given text", () => { - return customElements.renderFragment("").then((output) => { + return components.renderFragment("").then((output) => { expect(output).to.equal("Hi there"); }); }); @@ -30,21 +30,21 @@ describe("An example component:", () => { beforeEach(() => { var currentCount = 0; - class CounterElement extends customElements.HTMLElement { + class CounterElement extends components.HTMLElement { connectedCallback() { currentCount += 1; this.innerHTML = "There have been " + currentCount + " visitors."; } } - customElements.define("visitor-counter", CounterElement); + components.define("visitor-counter", CounterElement); }); it("dynamically changes its content", () => { - customElements.renderFragment(""); - customElements.renderFragment(""); - customElements.renderFragment(""); + components.renderFragment(""); + components.renderFragment(""); + components.renderFragment(""); - return customElements.renderFragment("").then((output) => { + return components.renderFragment("").then((output) => { expect(output).to.equal( "There have been 4 visitors." ); @@ -54,17 +54,17 @@ describe("An example component:", () => { describe("parameterised by HTML content", () => { beforeEach(() => { - class LinkifyElement extends customElements.HTMLElement { + class LinkifyElement extends components.HTMLElement { connectedCallback(document) { // Delegate the whole thing to a real front-end library! linkify(this, { target: () => null, linkClass: "autolinked" }, document); } } - customElements.define("linkify-urls", LinkifyElement); + components.define("linkify-urls", LinkifyElement); }); it("should be able to parse and manipulate it's content", () => { - return customElements.renderFragment( + return components.renderFragment( "Have you heard of www.facebook.com?" ).then((output) => expect(output).to.equal( 'Have you heard of www.facebook.com?' diff --git a/test/multiple-element-interactions-test.js b/test/multiple-element-interactions-test.js index 772ffa2..80976f0 100644 --- a/test/multiple-element-interactions-test.js +++ b/test/multiple-element-interactions-test.js @@ -1,23 +1,23 @@ "use strict"; var expect = require('chai').expect; -var customElements = require("../src/index.js"); +var components = require("../src/index.js"); describe("When multiple DOM elements are present", () => { beforeEach(() => { - customElements.customElements.reset(); + components.reset(); }); describe("nested elements", () => { it("are rendered correctly", () => { - class PrefixedElement extends customElements.HTMLElement { + class PrefixedElement extends components.HTMLElement { connectedCallback() { this.innerHTML = "prefix:" + this.innerHTML; } } - customElements.define("prefixed-element", PrefixedElement); + components.define("prefixed-element", PrefixedElement); - return customElements.renderFragment( + return components.renderFragment( "existing-content" ).then((output) => { expect(output).to.equal( @@ -29,16 +29,16 @@ describe("When multiple DOM elements are present", () => { describe("parent elements", () => { it("can see child elements", () => { - class ChildCountElement extends customElements.HTMLElement { + class ChildCountElement extends components.HTMLElement { connectedCallback() { var newNode = this.doc.createElement("div"); newNode.textContent = this.childNodes.length + " children"; this.insertBefore(newNode, this.firstChild); } } - customElements.define("child-count", ChildCountElement); + components.define("child-count", ChildCountElement); - return customElements.renderFragment( + return components.renderFragment( "
A child
Another child
" ).then((output) => { expect(output).to.equal( @@ -48,14 +48,14 @@ describe("When multiple DOM elements are present", () => { }); it("can read attributes from custom child element's prototypes", () => { - class DataSource extends customElements.HTMLElement { + class DataSource extends components.HTMLElement { get data() { return [10, 20, 30]; } } - customElements.define("data-source", DataSource); + components.define("data-source", DataSource); - class DataDisplayer extends customElements.HTMLElement { + class DataDisplayer extends components.HTMLElement { connectedCallback() { return new Promise((resolve) => { // Has to be async, as child node prototypes aren't set: http://stackoverflow.com/questions/36187227/ @@ -69,9 +69,9 @@ describe("When multiple DOM elements are present", () => { } } - customElements.define("data-displayer", DataDisplayer); + components.define("data-displayer", DataDisplayer); - return customElements.renderFragment( + return components.renderFragment( "" ).then((output) => { expect(output).to.equal( @@ -81,7 +81,7 @@ describe("When multiple DOM elements are present", () => { }); it("receive bubbling events from child elements", () => { - class EventRecorder extends customElements.HTMLElement { + class EventRecorder extends components.HTMLElement { connectedCallback(document) { var resultsNode = document.createElement("p"); this.appendChild(resultsNode); @@ -91,18 +91,18 @@ describe("When multiple DOM elements are present", () => { }); } } - customElements.define("event-recorder", EventRecorder); + components.define("event-recorder", EventRecorder); - class EventElement extends customElements.HTMLElement { + class EventElement extends components.HTMLElement { connectedCallback() { - this.dispatchEvent(new customElements.dom.CustomEvent('my-event', { + this.dispatchEvent(new components.dom.CustomEvent('my-event', { bubbles: true })); } } - customElements.define("event-source", EventElement); + components.define("event-source", EventElement); - return customElements.renderFragment( + return components.renderFragment( "" ).then((output) => { expect(output).to.equal( diff --git a/test/programmatic-usage-test.js b/test/programmatic-usage-test.js index fa5321c..d006e33 100644 --- a/test/programmatic-usage-test.js +++ b/test/programmatic-usage-test.js @@ -1,16 +1,16 @@ "use strict"; var expect = require('chai').expect; -var customElements = require("../src/index.js"); +var components = require("../src/index.js"); describe("Programmatic usage", () => { // Pending until we decide what we want from this it("returns the element constructor from the registration call", () => { - class NewElement extends customElements.HTMLElement {} - customElements.define("test-element", NewElement); + class NewElement extends components.HTMLElement {} + components.define("test-element", NewElement); - var klass = customElements.customElements.get("test-element"); + var klass = components.get("test-element"); expect(klass).to.equal(NewElement); }); });