├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── component-examples.md ├── package.json ├── src └── index.js └── test ├── asynchrony-test.js ├── basics-test.js ├── element-validation-test.js ├── example-components.js ├── multiple-element-interactions-test.js └── programmatic-usage-test.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # Intellij 36 | *.iml 37 | /.idea/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | - "5" 5 | - "5.0" 6 | - "4" 7 | - "4.0.0" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Tim Perry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Server Components [![Travis Build Status](https://img.shields.io/travis/pimterry/server-components.svg)](https://travis-ci.org/pimterry/server-components) [![Join the chat at https://gitter.im/pimterry/server-components](https://badges.gitter.im/pimterry/server-components.svg)](https://gitter.im/pimterry/server-components?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 2 | 3 | Server Components are a simple, lightweight tool for composable HTML rendering in Node.js, broadly following the [web components](http://www.html5rocks.com/en/tutorials/webcomponents/customelements/) browser specification, but on the server side. 4 | 5 | Server Components let you build web pages from ``, `` and ``, in an accessible, incredibly-fast and SEO-loving way, without zero front-end cost or complexity. 6 | 7 | Composable flexible and powerful approaches to building web applications don't have to require heavyweight front-end JS frameworks, buildsteps, pre-compilers, and enormous downloads. 8 | 9 | You can take the same ideas (and standards), apply them directly server side, to gain all that power without *any* of the page weight, without having to maintain all the complexity, and without breaking accessibility/SEO/client-side performance. Even better, you move all your logic into your server-side JS engine: browser discrepancies disappear, testing gets vastly easier, and you can use every JS feature your Node version supports natively, right now. 10 | 11 | **Server Components is still in its very early stages, and subject to change!** The core functionality is in place and working though, and it's stable and ready to play with whenever you are. 12 | 13 | ## Contents: 14 | #### [Basic usage example](#basic-usage) 15 | #### [Setup](#setting-it-up) 16 | #### [API docs](#api-documentation) 17 | #### [Why?](#why-does-this-exist) 18 | #### [Caveats](#some-caveats) 19 | #### [Plugins](#existing-plugins) 20 | #### [Contributing](#how-to-contribute) 21 | 22 | ## Basic Usage 23 | 24 | #### Define a component 25 | 26 | ```javascript 27 | var components = require("server-components"); 28 | 29 | // Get the prototype for a new element 30 | var NewElement = components.newElement(); 31 | 32 | // When the element is created during DOM parsing, you can transform the HTML inside it. 33 | // This can be configurable too, either by setting attributes or adding HTML content 34 | // inside it or elsewhere in the page it can interact with. Elements can fire events 35 | // that other elements can receive to allow interactions, or even expose methods 36 | // or data that other elements in the page can access directly. 37 | NewElement.createdCallback = function () { 38 | this.innerHTML = "Hi there"; 39 | }; 40 | 41 | // Register the element with an element name 42 | components.registerElement("my-new-element", { prototype: NewElement }); 43 | ``` 44 | 45 | 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) 46 | 47 | #### Use your components 48 | 49 | ```javascript 50 | var components = require("server-components"); 51 | 52 | // Render the HTML, and receive a promise for the resulting HTML string. 53 | // The result is a promise because elements can render asynchronously, by returning 54 | // promises from their callbacks. This allows elements to render content from 55 | // external web services, your database, or anything else you can imagine. 56 | components.renderPage(` 57 | 58 | 59 | 60 | 61 | 62 | 63 | `).then(function (output) { 64 | // Output = "Hi there" 65 | }); 66 | ``` 67 | 68 | ## Setting it up 69 | 70 | Want to try this out? Add it to your Node project with: 71 | 72 | ```bash 73 | npm install --save server-components 74 | ``` 75 | 76 | You'll then want to add render calls ([as shown above](#use-your-components)) to your server endpoints that return HTML, to render that HTML with whatever components you have registered. 77 | 78 | From there you can start simplifying your code, moving parts of your logic, page structure and rendering out into standalone components. Install other people's components, writing your own, and then just use them directly in your HTML. 79 | 80 | There aren't many published sharable components to drop in quite yet, as it's still early days, but as they appear you can find them by searching NPM for the `server-component` keyword: https://www.npmjs.com/browse/keyword/server-component. Building your own is easy though, take a look at the [example components](https://github.com/pimterry/server-components/blob/master/component-examples.md) for inspiration and patterns, and get building. 81 | 82 | ## API Documentation 83 | 84 | ### Top-level API 85 | 86 | #### `components.newElement()` 87 | 88 | Creates a returns a new custom HTML element prototype, extending the HTMLElement prototype. 89 | 90 | Note that this does *not* register the element. To do that, call `components.registerElement` with an element name, and options (typically including the prototype returned here as your 'prototype' value). 91 | 92 | 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. 93 | 94 | #### `components.registerElement(componentName, options)` 95 | 96 | Registers an element, so that it will be used when the given element name is found during parsing. 97 | 98 | 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. 99 | 100 | 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. 101 | 102 | This returns the constructor for the new element, so you can construct and insert them into the DOM programmatically if desired. 103 | 104 | This is broadly equivalent to `document.registerElement` in browser land. 105 | 106 | #### `components.renderPage(html)` 107 | 108 | 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. 109 | 110 | Unrecognized elements are left unchanged. When calling custom element callbacks any returned promises are collected, and this call will not return until all these promises have completed. If any promises are rejected, this renderPage call will be rejected too. 111 | 112 | 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. 113 | 114 | #### `components.renderFragment(html)` 115 | 116 | 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. 117 | 118 | Unrecognized elements are left unchanged. When calling custom element callbacks any returned promises are collected, and this call will not return until all these promises have completed. If any promises are rejected, this renderFragment call will be rejected too. 119 | 120 | 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. 121 | 122 | #### `components.dom` 123 | 124 | 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. 125 | 126 | This is (very) broadly equivalent to `window` in browser land. 127 | 128 | ### Component API 129 | 130 | These methods are methods you can implement on your component prototype (as returned by `newElement`) before registering your element with `registerElement`. Implementing every method here is optional. 131 | 132 | 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. 133 | 134 | #### `yourComponent.createdCallback(document)` 135 | 136 | Called when an element is created. 137 | 138 | **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. 139 | 140 | This method is called with `this` bound to the element that's being rendered (just like in browser-land). The `document` object that would normally be available as a global in the browser is instead passed as an argument here for convenience (useful if you want to use `document.querySelectorAll` and friends). Note that if you're rendering with `renderFragment` instead of `renderPage` this will be a DocumentFragment, not a Document, although in almost all cases you can safely ignore this. 141 | 142 | 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. 143 | 144 | 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. 145 | 146 | #### `yourComponent.attachedCallback(document)` 147 | 148 | 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* 149 | 150 | #### `yourComponent.detachedCallback(document)` 151 | 152 | Called when the element is removed from the DOM. *Not yet implemented* 153 | 154 | #### `yourComponent.attributeChangedCallback(document)` 155 | 156 | Called when an attribute of the element is added, changed, or removed. *Not yet implemented*. 157 | 158 | **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.** 159 | 160 | ## Why does this exist? 161 | 162 | Server Components is designed for anybody building web pages who wants to build on the web natively, with all its built-in accessibility, performance and SEO benefits, not floating on a wobbly JavaScript layer on top. 163 | 164 | For 90% of web sites, you don't need the core of your app to run purely inside big ultra-flashy web-breaking client-side JavaScript. Many of us have been doing so not because our sites need to because server side rendering isn't enough to deliver our core experience, but because JS frameworks offer the best developer experience. 165 | 166 | Tools like React, Ember, Angular and friends make building web pages a delight. That's because they've been in a great place to discover and develop better approaches to building and managing UI complexity though, not because they're running client-side. We've conflated the two. 167 | 168 | We can fix this. We can take those same ideas and designs (critically, the key element they all agree on: composing applications together from many standalone elements), and get the magic and maintainability on the server side too, without the costs. 169 | 170 | Server Components is an attempt to do that, by supporting the [Web Components spec](http://www.html5rocks.com/en/tutorials/webcomponents/customelements/) (the W3C work to pull out the core magic of these frameworks into an official standard), when rendering HTML in server-side Node.js. 171 | 172 | #### Example: 173 | 174 | ```html 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 |

My Profile Page

183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | ``` 212 | 213 | It would be fantastic to write websites like the above, render it on the server, and serve up your users a fully populated page the works in browsers from the dawn of time, takes no mountain of JS to run, renders at lightning speed, and that search engines and screen readers can effortlessly understand. 214 | 215 | Code like this is a pleasure to write, clicking abstractions together to build incredible applications at high-speed, but right now it happens only on the client side. If you render this server side though, you can get the power of this, and the benefits of just serving static HTML + CSS (and more JS too, if you like, to progressively enhance your site with extra interactivity as well). 216 | 217 | You can do this right now with Server Components. It's somewhere between a classic JavaScript framework (but much smaller, faster, simpler, and server-side) and a templating library (but much cleverer, more powerful and more flexible). 218 | 219 | This doesn't end there though. The end goal of this is to provide an API so close to the client-side web component standard that it becomes easy to write components which work on both sides, enabling isomorphic JavaScript entirely on web standards. It's server side only for now, but watch this space. 220 | 221 | ## Some Caveats 222 | 223 | Server Components is building on the Web Components specs, but really almost entirely the custom elements spec. HTML Imports are out of scope initially (although it's interesting to think about what that might look like on the server), template tags are supported but are unnecessary really since all DOM is inert here, and the Shadow DOM is challenging and less useful here, so not the main focus right now. 224 | 225 | Core DOM functionality now built on [Domino](https://github.com/fgnass/domino), so DOM manipulation comes with Domino's 226 | limitations. File issues if you hit any of these, Domino is aiming to be an accurate representation of the full DOM spec, so there's any serious divergences should probably be fixable upstream. 227 | 228 | IE 8 and earlier render unknown elements poorly, and will probably render the output of this badly. This is [solvable by hand](https://blog.whatwg.org/supporting-new-elements-in-ie) (although it requires front-end JS), but isn't solved automatically for you here yet. 229 | 230 | This is not intended to be used as an all encompassing framework, but as a tool for rendering a page. It's designed to compose together standalone chunks of HTML, nothing more. For any substantial application there will be steps that happen totally orthogonally to the resulting page structure (e.g. checking authentication, performing the action requested by the request, loading page-wide data), and trying to shoehorn those into server components will be painful for everybody. 231 | 232 | Instead, build general logic as normal, and once you're at the stage where the page-wide logic is compete and you simply have to glue everything together for the bits of your final page, break out the components. Use templating libraries like Mustache and friends to build your purely high-level HTML template with your data, and then use server components to render that HTML into the basic page HTML your users will actually see, letting individual components handling all the complexity behind that. 233 | 234 | ## Existing Plugins 235 | 236 | [Static](https://github.com/pimterry/server-components-static): Static file extension, making it easy to include references to external content in the resulting HTML, and providing a mapping to transform resource URLs used back to find the static content in their corresponding components later. 237 | 238 | [Express](https://github.com/pimterry/server-components-express): Express integration, automatically completely set up static file configuration for Express. 239 | 240 | Writing more plugins to make it easy to integrate Server Components with other tools, and to help enable other useful patterns would be fantastic. If you write one, feel free to file a pull request on this repository to add it to the list. 241 | 242 | ## How to Contribute 243 | 244 | It's great to hear you're interested in helping out! 245 | 246 | Right now the key thing is getting people using Server Components used in practice, and getting feedback 247 | on how well that works. If you're just keen generally, pick up Server Components, try to build 248 | something quick yourself now, and file bugs (or even fixes!) as you go. 249 | 250 | It would also be really great to see more components available for general use. Take a look at 251 | [the examples](https://github.com/pimterry/server-components/blob/master/component-examples.md), and try 252 | putting some together yourself. Don't forget to add the [server-components](https://www.npmjs.com/browse/keyword/server-component) keyword when you push to NPM, 253 | so they're findable! 254 | 255 | Finally, if you'd like to dive into framework development anyway, take a look at the Huboard at 256 | https://huboard.com/pimterry/server-components/ to see the currently prioritised issues and their 257 | status. If you'd like to pick one up, add a quick note on the ticket that you're interested and outlining 258 | your approach, and then jump in! 259 | 260 | #### Building the project 261 | 262 | Everything you need should be installed after a clone and `npm install`. There's very few build scripts, 263 | but they're managed just through NPM directly, take a look at the [package.json](package.json) for details. 264 | 265 | To test the project: `npm test` 266 | 267 | To watch the project locally and automatically run tests on changes: `npm run dev` 268 | -------------------------------------------------------------------------------- /component-examples.md: -------------------------------------------------------------------------------- 1 | # Component examples 2 | 3 | This page is a series of examples of increasing levels of complexity of component. 4 | 5 | This list is still a work in progress, with broad outlines for functionality in some cases rather than completed examples. Every example in here is very buildable though right now, and the full examples will fill out here shortly. 6 | 7 | Have a minute? Have a go at building one of the extras and filling this out yourself! 8 | 9 | ## Static rendering 10 | 11 | The simplest web component just acts as a simple placeholder for some static content. 12 | 13 | With the web component below, rendering `` will result in 14 | `Hi there`. 15 | 16 | ```javascript 17 | var components = require("server-components"); 18 | 19 | var StaticElement = components.newElement(); 20 | StaticElement.createdCallback = function () { 21 | this.innerHTML = "Hi there"; 22 | }; 23 | 24 | components.registerElement("my-greeting", { prototype: StaticElement }); 25 | ``` 26 | 27 | This is very basic, and toy cases like this aren't immediately useful, but this can be helpful for standard 28 | chunks of boilerplate that you need to include repeatedly. 29 | 30 | Static page footers or sets of standard static meta tags can be easily swapped out, so you can ignore 31 | all their cruft entirely and focus on the real content of your page. 32 | 33 | As a more reusable example, you could create a `` component, which 34 | rendered a select dropdown with a list of every country. 35 | 36 | ## Dynamic rendering 37 | 38 | Slightly more interesting web components emerge when we consider rendering dynamic content. A simple 39 | example is below: a visitor counter. All the rage in the 90s, with web components these can make a 40 | comeback! 41 | 42 | ```javascript 43 | var components = require("server-components"); 44 | 45 | var CounterElement = components.newElement(); 46 | var currentCount = 0; 47 | 48 | CounterElement.createdCallback = function () { 49 | currentCount += 1; 50 | this.innerHTML = "There have been " + currentCount + " visitors."; 51 | }; 52 | 53 | components.registerElement("visitor-counter", { prototype: CounterElement }); 54 | ``` 55 | 56 | After a few visitors, this will render `` into something like 57 | `There have been 435 visitors`. 58 | 59 | Some caveats: 60 | 61 | * Storage here is just in a variable, not any persistent storage outside the server process, so this 62 | counter will get reset whenever we restart the server. 63 | 64 | * We count every time the component is rendered as a page load. If you have this component on the same 65 | page repeatedly you'll be double counting. 66 | 67 | ## Parameterising components via attributes 68 | 69 | TODO: A component that renders a QR code 70 | 71 | `` 72 | 73 | becomes 74 | 75 | `"` 76 | 77 | ## Parameterising components via content 78 | 79 | Components can be parameterized in all sorts of ways. One interesting pattern is to wrap some normal HTML content in a component, and use that to transform the content. 80 | 81 | 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): 82 | 83 | ```javascript 84 | var components = require("server-components"); 85 | var linkify = require("linkifyjs/element"); 86 | 87 | var LinkifyElement = components.newElement(); 88 | 89 | LinkifyElement.createdCallback = function (document) { 90 | // Delegate the whole thing to a real normal front-end library! 91 | linkify(this, { target: () => null, linkClass: "autolinked" }, document); 92 | }; 93 | 94 | components.registerElement("linkify-urls", { prototype: LinkifyElement }); 95 | ``` 96 | 97 | With this, we can pass HTML into Server Components that looks like 98 | 99 | ```html 100 | Have you heard of www.facebook.com? 101 | ``` 102 | 103 | and then serve up to our users: 104 | 105 | ```html 106 | 107 | Have you heard of 108 | www.facebook.com? 109 | 110 | ``` 111 | 112 | ## Loading external data 113 | 114 | TODO: A component that queries twitter, and renders the output to the page 115 | 116 | `` 117 | 118 | ## Including client-side content 119 | 120 | TODO: An element that renders social media icons, from its own set of bundled icons 121 | 122 | `` 123 | 124 | becomes 125 | 126 | ```html 127 | 128 | 129 | My Twitter 130 | 131 | 132 | 133 | 134 | ## Element interactions through DOM properties 135 | 136 | TODO: A component that renders a table of contents from the headings on the page 137 | 138 | ```html 139 | 140 | 141 |

Newsflash!

142 | ... 143 |

A subtitle

144 | ... 145 |

More news

146 | ... 147 |

And even more!

148 | ``` 149 | 150 | becomes 151 | 152 | ```html 153 | 154 |
    155 |
  1. 156 | Newsflash 157 |
      158 |
    1. 159 | A subheading 160 |
        161 |
      1. More news
      2. 162 |
      163 |
    2. 164 |
    3. And even more!
    4. 165 |
    166 |
  2. 167 |
168 |
169 | 170 |

Newsflash!

171 | ... 172 |

A subheading

173 | ... 174 |

More news

175 | ... 176 |

And even more!

177 | ``` 178 | 179 | 180 | ## Element interactions through events 181 | 182 | TODO: A component that listens for content, and a series of subcomponents that load external data 183 | and trigger DOM events when it arrives. 184 | 185 | ```html 186 | 187 | 188 | 189 | 190 | 191 | ``` 192 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server-components", 3 | "version": "0.2.1", 4 | "description": "An ultra-dumb component framework for server-side rendering, inspired by web components", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "lint": "jshint . --exclude='node_modules' || exit 1", 8 | "test": "npm run lint && mocha test/ --recursive", 9 | "dev": "watch 'npm run test -s' src/ test/", 10 | "release:patch": "np patch", 11 | "release:minor": "np minor", 12 | "release:major": "np major" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/pimterry/server-components.git" 17 | }, 18 | "keywords": [ 19 | "webcomponents", 20 | "web-components", 21 | "components", 22 | "templating", 23 | "rendering", 24 | "server", 25 | "server-side" 26 | ], 27 | "author": "Tim Perry", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/pimterry/server-components/issues" 31 | }, 32 | "homepage": "https://github.com/pimterry/server-components#readme", 33 | "devDependencies": { 34 | "chai": "^3.5.0", 35 | "jshint": "^2.9.2", 36 | "linkifyjs": "^2.0.0", 37 | "mocha": "^2.4.5", 38 | "np": "pimterry/np", 39 | "watch": "^0.18.0" 40 | }, 41 | "dependencies": { 42 | "domino": "^1.0.23", 43 | "validate-element-name": "^1.0.0" 44 | }, 45 | "jshintConfig": { 46 | "esversion": 6, 47 | "node": true 48 | }, 49 | "engines": { 50 | "node": ">= 4.0.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var domino = require("domino"); 4 | var validateElementName = require("validate-element-name"); 5 | 6 | /** 7 | * The DOM object (components.dom) exposes tradition DOM objects (normally globally available 8 | * in browsers) such as the CustomEvent and various HTMLElement classes, for your component 9 | * implementations. 10 | */ 11 | exports.dom = domino.impl; 12 | 13 | /** 14 | * Creates a returns a new custom HTML element prototype, extending the HTMLElement prototype. 15 | * 16 | * Note that this does *not* register the element. To do that, call components.registerElement 17 | * with an element name, and options (typically including the prototype returned here as your 18 | * 'prototype' value). 19 | */ 20 | exports.newElement = function newElement() { 21 | return Object.create(domino.impl.HTMLElement.prototype); 22 | }; 23 | 24 | var registeredElements = {}; 25 | 26 | /** 27 | * Registers an element, so that it will be used when the given element name is found during parsing. 28 | * 29 | * Element names are required to contain a hyphen (to disambiguate them from existing element names), 30 | * be entirely lower-case, and not start with a hyphen. 31 | * 32 | * The only option currently supported is 'prototype', which sets the prototype of the given element. 33 | * This prototype will have its various callbacks called when it is found during document parsing, 34 | * and properties of the prototype will be exposed within the DOM to other elements there in turn. 35 | */ 36 | exports.registerElement = function registerElement(name, options) { 37 | var nameValidationResult = validateElementName(name); 38 | if (!nameValidationResult.isValid) { 39 | throw new Error(`Registration failed for '${name}'. ${nameValidationResult.message}`); 40 | } 41 | 42 | if (options && options.prototype) { 43 | registeredElements[name] = options.prototype; 44 | } else { 45 | registeredElements[name] = exports.newElement(); 46 | } 47 | 48 | return registeredElements[name].constructor; 49 | }; 50 | 51 | function recurseTree(rootNode, callback) { 52 | for (let node of rootNode.childNodes) { 53 | callback(node); 54 | recurseTree(node, callback); 55 | } 56 | } 57 | 58 | /** 59 | * Take a string of HTML input, and render it into a full page, handling any custom elements found 60 | * within, and returning a promise for the resulting string of HTML. 61 | */ 62 | exports.renderPage = function renderPage(input) { 63 | let document = domino.createDocument(input); 64 | return renderNode(document).then((renderedDocument) => renderedDocument.outerHTML); 65 | }; 66 | 67 | /** 68 | * Take a string of HTML input, and render as a page fragment, handling any custom elements found 69 | * within, and returning a promise for the resulting string of HTML. Any full page content ( 70 | * and tags) will be stripped. 71 | */ 72 | exports.renderFragment = function render(input) { 73 | let document = domino.createDocument(); 74 | var template = document.createElement("template"); 75 | // Id added for clarity, as this template is potentially visible 76 | // from JS running within, if it attempts to search its parent. 77 | template.id = "server-components-fragment-wrapper"; 78 | template.innerHTML = input; 79 | 80 | return renderNode(template.content).then((template) => template.innerHTML); 81 | }; 82 | 83 | /** 84 | * Takes a full Domino node object. Traverses within it and renders all the custom elements found. 85 | * Returns a promise for the document object itself, resolved when every custom element has 86 | * resolved, and rejected if any of them are rejected. 87 | */ 88 | function renderNode(rootNode) { 89 | let createdPromises = []; 90 | 91 | var document = getDocument(rootNode); 92 | 93 | recurseTree(rootNode, (foundNode) => { 94 | if (foundNode.tagName) { 95 | let nodeType = foundNode.tagName.toLowerCase(); 96 | let customElement = registeredElements[nodeType]; 97 | if (customElement) { 98 | // TODO: Should probably clone node, not change prototype, for performance 99 | Object.setPrototypeOf(foundNode, customElement); 100 | if (customElement.createdCallback) { 101 | createdPromises.push(new Promise((resolve) => { 102 | resolve(customElement.createdCallback.call(foundNode, document)); 103 | })); 104 | } 105 | } 106 | } 107 | }); 108 | 109 | return Promise.all(createdPromises).then(() => rootNode); 110 | } 111 | 112 | /** 113 | * If rootNode is not a real document (e.g. while rendering a fragment), then some methods such as 114 | * createElement are not available. This method ensures you have a document equivalent object: if 115 | * you call normal document methods on it (createElement, querySelector, etc) you'll get what you 116 | * expect. 117 | * 118 | * That means methods independent of page hierarchy, especially those that are only present on 119 | * the true document object (createElement), should be called on the real document, and methods that 120 | * care about document hierarchy (querySelectorAll, getElementById) should be scope to the given node. 121 | */ 122 | function getDocument(rootNode) { 123 | // Only real documents have a null ownerDocument 124 | if (rootNode.ownerDocument === null) return rootNode; 125 | 126 | else { 127 | let document = rootNode.ownerDocument; 128 | 129 | var documentMethods = [ 130 | 'compatMode', 131 | 'createTextNode', 132 | 'createComment', 133 | 'createDocumentFragment', 134 | 'createProcessingInstruction', 135 | 'createElement', 136 | 'createElementNS', 137 | 'createEvent', 138 | 'createTreeWalker', 139 | 'createNodeIterator', 140 | 'location', 141 | 'title', 142 | 'onabort', 143 | 'onreadystatechange', 144 | 'onerror', 145 | 'onload', 146 | ]; 147 | 148 | documentMethods.forEach((propertyName) => { 149 | var property = document[propertyName]; 150 | if (typeof(property) === 'function') property = property.bind(document); 151 | rootNode[propertyName] = property; 152 | }); 153 | 154 | return rootNode; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /test/asynchrony-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | var components = require("../src/index.js"); 4 | 5 | describe("An asynchronous element", () => { 6 | it("blocks rendering until they complete", () => { 7 | var SlowElement = components.newElement(); 8 | SlowElement.createdCallback = function () { 9 | return new Promise((resolve, reject) => { 10 | setTimeout(() => { 11 | this.textContent = "loaded!"; 12 | resolve(); 13 | }, 1); 14 | }); 15 | }; 16 | components.registerElement("slow-element", { prototype: SlowElement }); 17 | 18 | return components.renderFragment("").then((output) => { 19 | expect(output).to.equal("loaded!"); 20 | }); 21 | }); 22 | 23 | it("throw an async error if a component fails to render synchronously", () => { 24 | var FailingElement = components.newElement(); 25 | FailingElement.createdCallback = () => { throw new Error(); }; 26 | components.registerElement("failing-element", { prototype: FailingElement }); 27 | 28 | return components.renderFragment( 29 | "" 30 | ).then((output) => { 31 | throw new Error("Should not successfully render"); 32 | }).catch(() => { /* All good. */ }); 33 | }); 34 | 35 | it("throw an async error if a component fails to render asynchronously", () => { 36 | var FailingElement = components.newElement(); 37 | FailingElement.createdCallback = () => Promise.reject(new Error()); 38 | components.registerElement("failing-element", { prototype: FailingElement }); 39 | 40 | return components.renderFragment( 41 | "" 42 | ).then((output) => { 43 | throw new Error("Should not successfully render"); 44 | }).catch(() => { /* All good */ }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/basics-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | var components = require("../src/index.js"); 4 | 5 | describe("Basic component functionality", () => { 6 | it("does nothing with vanilla HTML", () => { 7 | var input = "
"; 8 | 9 | return components.renderFragment(input).then((output) => { 10 | expect(output).to.equal(input); 11 | }); 12 | }); 13 | 14 | it("replaces components with their rendered result", () => { 15 | var NewElement = components.newElement(); 16 | NewElement.createdCallback = function () { this.textContent = "hi there"; }; 17 | components.registerElement("my-element", { prototype: NewElement }); 18 | 19 | return components.renderFragment("").then((output) => { 20 | expect(output).to.equal("hi there"); 21 | }); 22 | }); 23 | 24 | it("can wrap existing content", () => { 25 | var PrefixedElement = components.newElement(); 26 | PrefixedElement.createdCallback = function () { 27 | this.innerHTML = "prefix:" + this.innerHTML; 28 | }; 29 | components.registerElement("prefixed-element", { 30 | prototype: PrefixedElement 31 | }); 32 | 33 | return components.renderFragment( 34 | "existing-content" 35 | ).then((output) => expect(output).to.equal( 36 | "prefix:existing-content" 37 | )); 38 | }); 39 | 40 | it("allows attribute access", () => { 41 | var BadgeElement = components.newElement(); 42 | BadgeElement.createdCallback = function () { 43 | var name = this.getAttribute("name"); 44 | this.innerHTML = "My name is:
" + name + "
"; 45 | }; 46 | components.registerElement("name-badge", { prototype: BadgeElement }); 47 | 48 | return components.renderFragment( 49 | '' 50 | ).then((output) => expect(output).to.equal( 51 | 'My name is:
Tim Perry
' 52 | )); 53 | }); 54 | 55 | it("can use normal document methods like QuerySelector", () => { 56 | var SelfFindingElement = components.newElement(); 57 | SelfFindingElement.createdCallback = function (document) { 58 | var hopefullyThis = document.querySelector("self-finding-element"); 59 | if (hopefullyThis === this) this.innerHTML = "Found!"; 60 | else this.innerHTML = "Not found, found " + hopefullyThis; 61 | }; 62 | components.registerElement("self-finding-element", { prototype: SelfFindingElement }); 63 | 64 | return components.renderFragment( 65 | '' 66 | ).then((output) => expect(output).to.equal( 67 | 'Found!' 68 | )); 69 | }); 70 | 71 | it("wraps content in valid page content, if rendering a page", () => { 72 | return components.renderPage("").then((output) => { 73 | expect(output).to.equal( 74 | "" 75 | ); 76 | }); 77 | }); 78 | 79 | it("strips , and tags, if only rendering a fragment", () => { 80 | return components.renderFragment("").then((output) => { 81 | expect(output).to.equal( 82 | "" 83 | ); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/element-validation-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | var components = require("../src/index.js"); 4 | 5 | describe("Custom element validation", () => { 6 | it("allows elements without options", () => { 7 | components.registerElement("my-element"); 8 | 9 | return components.renderFragment(" { 13 | var InvalidElement = components.newElement(); 14 | expect(() => { 15 | components.registerElement("", { prototype: InvalidElement }); 16 | }).to.throw( 17 | /Registration failed for ''. Missing element name./ 18 | ); 19 | }); 20 | 21 | it("requires a hyphen in the element name", () => { 22 | var InvalidElement = components.newElement(); 23 | expect(() => { 24 | components.registerElement("invalidname", { prototype: InvalidElement }); 25 | }).to.throw( 26 | /Registration failed for 'invalidname'. Custom element names must contain a hyphen./ 27 | ); 28 | }); 29 | 30 | it("doesn't allow elements to start with a hyphen", () => { 31 | var InvalidElement = components.newElement(); 32 | expect(() => { 33 | components.registerElement("-invalid-name", { prototype: InvalidElement }); 34 | }).to.throw( 35 | /Registration failed for '-invalid-name'. Custom element names must not start with a hyphen./ 36 | ); 37 | }); 38 | 39 | it("requires element names to be lower case", () => { 40 | var InvalidElement = components.newElement(); 41 | expect(() => { 42 | components.registerElement("INVALID-NAME", { prototype: InvalidElement }); 43 | }).to.throw( 44 | /Registration failed for 'INVALID-NAME'. Custom element names must not contain uppercase ASCII characters./ 45 | ); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/example-components.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | var components = require("../src/index.js"); 3 | 4 | var linkify = require("linkifyjs/element"); 5 | 6 | describe("An example component:", () => { 7 | describe("using static rendering", () => { 8 | before(() => { 9 | var StaticElement = components.newElement(); 10 | StaticElement.createdCallback = function () { 11 | this.innerHTML = "Hi there"; 12 | }; 13 | 14 | components.registerElement("my-greeting", { prototype: StaticElement }); 15 | }); 16 | 17 | it("replaces its content with the given text", () => { 18 | return components.renderFragment("").then((output) => { 19 | expect(output).to.equal("Hi there"); 20 | }); 21 | }); 22 | }); 23 | 24 | describe("using dynamic logic for rendering", () => { 25 | before(() => { 26 | var CounterElement = components.newElement(); 27 | var currentCount = 0; 28 | 29 | CounterElement.createdCallback = function () { 30 | currentCount += 1; 31 | this.innerHTML = "There have been " + currentCount + " visitors."; 32 | }; 33 | 34 | components.registerElement("visitor-counter", { prototype: CounterElement }); 35 | }); 36 | 37 | it("dynamically changes its content", () => { 38 | components.renderFragment(""); 39 | components.renderFragment(""); 40 | components.renderFragment(""); 41 | 42 | return components.renderFragment("").then((output) => { 43 | expect(output).to.equal( 44 | "There have been 4 visitors." 45 | ); 46 | }); 47 | }); 48 | }); 49 | 50 | describe("parameterised by HTML content", () => { 51 | before(() => { 52 | var LinkifyElement = components.newElement(); 53 | 54 | LinkifyElement.createdCallback = function (document) { 55 | // Delegate the whole thing to a real normal front-end library! 56 | linkify(this, { target: () => null, linkClass: "autolinked" }, document); 57 | }; 58 | 59 | components.registerElement("linkify-urls", { prototype: LinkifyElement }); 60 | }); 61 | 62 | it("should be able to parse and manipulate it's content", () => { 63 | return components.renderFragment( 64 | "Have you heard of www.facebook.com?" 65 | ).then((output) => expect(output).to.equal( 66 | 'Have you heard of www.facebook.com?' 67 | )); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/multiple-element-interactions-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | var components = require("../src/index.js"); 4 | 5 | describe("When multiple DOM elements are present", () => { 6 | describe("nested elements", () => { 7 | it("are rendered correctly", () => { 8 | var PrefixedElement = components.newElement(); 9 | PrefixedElement.createdCallback = function () { 10 | this.innerHTML = "prefix:" + this.innerHTML; 11 | }; 12 | components.registerElement("prefixed-element", { 13 | prototype: PrefixedElement 14 | }); 15 | 16 | return components.renderFragment( 17 | "existing-content" 18 | ).then((output) => { 19 | expect(output).to.equal( 20 | "prefix:prefix:existing-content" 21 | ); 22 | }); 23 | }); 24 | }); 25 | 26 | describe("parent elements", () => { 27 | it("can see child elements", () => { 28 | var ChildCountElement = components.newElement(); 29 | ChildCountElement.createdCallback = function () { 30 | var newNode = this.doc.createElement("div"); 31 | newNode.textContent = this.childNodes.length + " children"; 32 | this.insertBefore(newNode, this.firstChild); 33 | }; 34 | components.registerElement("child-count", { prototype: ChildCountElement }); 35 | 36 | return components.renderFragment( 37 | "
A child
Another child
" 38 | ).then((output) => { 39 | expect(output).to.equal( 40 | "
2 children
A child
Another child
" 41 | ); 42 | }); 43 | }); 44 | 45 | it("can read attributes from custom child element's prototypes", () => { 46 | var DataSource = components.newElement(); 47 | DataSource.data = [1, 2, 3]; 48 | components.registerElement("data-source", { prototype: DataSource }); 49 | 50 | var DataDisplayer = components.newElement(); 51 | DataDisplayer.createdCallback = function () { 52 | return new Promise((resolve) => { 53 | // Has to be async, as child node prototypes aren't set: http://stackoverflow.com/questions/36187227/ 54 | // This is a web components limitation generally. TODO: Find a nicer pattern for handle this. 55 | setTimeout(() => { 56 | var data = this.childNodes[0].data; 57 | this.textContent = "Data: " + JSON.stringify(data); 58 | resolve(); 59 | }, 0); 60 | }); 61 | }; 62 | components.registerElement("data-displayer", { prototype: DataDisplayer }); 63 | 64 | return components.renderFragment( 65 | "" 66 | ).then((output) => { 67 | expect(output).to.equal( 68 | "Data: [1,2,3]" 69 | ); 70 | }); 71 | }); 72 | 73 | it("receive bubbling events from child elements", () => { 74 | var EventRecorder = components.newElement(); 75 | EventRecorder.createdCallback = function (document) { 76 | var resultsNode = document.createElement("p"); 77 | this.appendChild(resultsNode); 78 | 79 | this.addEventListener("my-event", (event) => { 80 | resultsNode.innerHTML = "Event received"; 81 | }); 82 | }; 83 | components.registerElement("event-recorder", { prototype: EventRecorder }); 84 | 85 | var EventElement = components.newElement(); 86 | EventElement.createdCallback = function () { 87 | this.dispatchEvent(new components.dom.CustomEvent('my-event', { 88 | bubbles: true 89 | })); 90 | }; 91 | components.registerElement("event-source", { prototype: EventElement }); 92 | 93 | return components.renderFragment( 94 | "" 95 | ).then((output) => { 96 | expect(output).to.equal( 97 | "

Event received

" 98 | ); 99 | }); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/programmatic-usage-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | var components = require("../src/index.js"); 4 | 5 | describe("Programmatic usage", () => { 6 | it("returns the element constructor from the registration call", () => { 7 | var NewElement = components.newElement(); 8 | var registrationResult = components.registerElement("my-element", { prototype: NewElement }); 9 | expect(NewElement.constructor).to.equal(registrationResult); 10 | }); 11 | }); 12 | --------------------------------------------------------------------------------