├── .gitignore ├── .travis.yml ├── bs.js ├── client.js ├── diffs └── 1.html ├── example.js ├── examples ├── inline.js ├── inline.restrictions.js ├── manual.js └── use.js ├── gulpfile.example.js ├── index.js ├── lib ├── config.js ├── default-config.js ├── html-injector.js ├── injector.js └── utils.js ├── lodash.custom.js ├── package.json ├── readme.md ├── test ├── compare.doms.js ├── compare.restrict.js ├── fixtures │ ├── css │ │ └── blog.css │ ├── dom1.html │ ├── dom2.html │ ├── index.html │ ├── store-product-alt.html │ ├── store-product.html │ ├── store-product.php │ ├── web-starter-kit-styleguide-modified.html │ └── web-starter-kit-styleguide.html └── init.js └── ui ├── client.js ├── html-injector.directive.html └── html-injector.html /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | /lodash.custom.min.js 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | -------------------------------------------------------------------------------- /bs.js: -------------------------------------------------------------------------------- 1 | var browserSync = require("browser-sync"); 2 | var htmlInjector = require("./index"); 3 | 4 | browserSync.use(htmlInjector, { 5 | files: "test/fixtures/*.html", 6 | excludedTags: ["BODY"] 7 | }); 8 | browserSync({ 9 | logLevel: "debug", 10 | server: "test/fixtures", 11 | open: false 12 | }); 13 | 14 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | 2 | ;(function (window, bs) { 3 | 4 | var socket = bs.socket; 5 | 6 | socket.on("connection", function () { 7 | 8 | socket.emit("client:url", { 9 | url: getUrl() 10 | }); 11 | }); 12 | 13 | socket.on("html:inject", function (data) { 14 | 15 | var elem, parent; 16 | 17 | if (data.url !== getUrl()) { 18 | return; 19 | } 20 | 21 | if (data.restrictions === "html") { 22 | var elems = document.getElementsByTagName(data.tagName); 23 | elem = elems[data.index]; 24 | updateElement(elem); 25 | } else { 26 | if (data.restrictions.match(/^#/)) { 27 | parent = document.getElementById(data.restrictions.slice(1)); 28 | if (parent) { 29 | if (data.tagName === "BODY") { 30 | updateElement(parent); 31 | } else { 32 | updateElement(parent.getElementsByTagName(data.tagName)[data.index]); 33 | } 34 | } 35 | } else { 36 | parent = document.querySelectorAll(data.restrictions); 37 | if (parent.length) { 38 | if (data.tagName === "BODY") { 39 | updateElement(parent[0]); 40 | } else { 41 | updateElement(parent[0].getElementsByTagName(data.tagName)[data.index]); 42 | } 43 | } 44 | } 45 | } 46 | 47 | function updateElement(elem) { 48 | 49 | if (elem) { 50 | 51 | switch (data.diff.type) { 52 | 53 | case "attribute": 54 | updateAttrs(elem, data); 55 | break; 56 | default: 57 | updateElemHtml(elem, data.html); 58 | break; 59 | 60 | } 61 | } 62 | } 63 | }); 64 | 65 | function updateElemHtml (elem, html) { 66 | elem.innerHTML = html; 67 | } 68 | 69 | function updateText (elem, text) { 70 | elem.innerText = text; 71 | } 72 | 73 | function updateAttrs (elem, data) { 74 | 75 | var oldAttrs = elem.attributes; 76 | var name; 77 | var index; 78 | 79 | // Remove any ol attrs that don't exist on new element 80 | for (index = oldAttrs.length - 1; index >= 0; --index) { 81 | name = oldAttrs[index].nodeName; 82 | if (!data.attrs[name]) { 83 | elem.removeAttribute(name); 84 | } 85 | } 86 | 87 | /** 88 | * Compare 89 | */ 90 | for (var key in data.attrs) { 91 | 92 | if (oldAttrs[key]) { // existing attr 93 | 94 | if (oldAttrs[key] !== data.attrs[key]) { 95 | elem.setAttribute(key, data.attrs[key]); 96 | } 97 | } 98 | 99 | if (!oldAttrs[key]) { 100 | elem.setAttribute(key, data.attrs[key]); 101 | } 102 | } 103 | } 104 | 105 | function getUrl () { 106 | return [location.protocol, "//", location.host, location.pathname, location.search].join(""); 107 | } 108 | 109 | })(window, window.___browserSync___); 110 | -------------------------------------------------------------------------------- /diffs/1.html: -------------------------------------------------------------------------------- 1 | undefined -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | var browserSync = require("browser-sync").create(); 2 | 3 | browserSync.init({ 4 | server: ["test/fixtures"], 5 | files: ["test/fixtures/css/**"], 6 | plugins: [ 7 | { 8 | module: __dirname, 9 | options: { 10 | files: 'test/fixtures/*.html' 11 | } 12 | } 13 | ] 14 | }); 15 | -------------------------------------------------------------------------------- /examples/inline.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Install: 4 | * npm install browser-sync bs-html-injector 5 | * 6 | * Run: 7 | * node 8 | * 9 | * This example will create a server & use the `app` directory as the root 10 | * 11 | * 1. Watch all css files and inject when they change 12 | * 2. Watch HTML files and inject the difference when they change 13 | * 14 | */ 15 | 16 | var browserSync = require("browser-sync").create(); 17 | 18 | browserSync.init({ 19 | server: ["app"], 20 | files: ["app/css/**"], 21 | plugins: [ 22 | { 23 | module: "bs-html-injector", 24 | options: { 25 | files: ["app/*.html"] 26 | } 27 | } 28 | ] 29 | }); 30 | -------------------------------------------------------------------------------- /examples/inline.restrictions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Install: 4 | * npm install browser-sync bs-html-injector 5 | * 6 | * Run: 7 | * node 8 | * 9 | * This example will create a server & use the `app` directory as the root 10 | * 11 | * 1. Watch all css files and inject when they change 12 | * 2. Watch HTML files and inject the differences within your restrictions 13 | * 14 | */ 15 | 16 | var browserSync = require("browser-sync").create(); 17 | 18 | browserSync.init({ 19 | server: ["app"], 20 | files: ["app/css/**"], 21 | plugins: [ 22 | { 23 | module: "bs-html-injector", 24 | options: { 25 | files: ["app/*.html"], 26 | restrictions: ["#blog-header"] 27 | } 28 | } 29 | ] 30 | }); 31 | -------------------------------------------------------------------------------- /examples/manual.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Install: 4 | * npm install browser-sync bs-html-injector 5 | * 6 | * Run: 7 | * node 8 | * 9 | * This example will create a server & use the `app` directory as the root 10 | * 11 | * 1. Watch all css files and inject when they change 12 | * 2. Watch HTML files and inject the difference when they change 13 | * 14 | */ 15 | 16 | var browserSync = require("browser-sync").create(); 17 | var htmlInjector = require("bs-html-injector"); 18 | 19 | /** 20 | * Register the plugin 21 | */ 22 | browserSync.use(htmlInjector); 23 | 24 | /** 25 | * Watch *.html files and trigger injection when they change 26 | */ 27 | browserSync.watch("app/*.html").on("change", function () { 28 | htmlInjector(); 29 | }); 30 | 31 | /** 32 | * Run the Server 33 | */ 34 | browserSync.init({ 35 | server: ["app"], 36 | files: ["app/css/**"] 37 | }); 38 | 39 | 40 | -------------------------------------------------------------------------------- /examples/use.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Install: 4 | * npm install browser-sync bs-html-injector 5 | * 6 | * Run: 7 | * node 8 | * 9 | * This example will create a server & use the `app` directory as the root 10 | * 11 | * 1. Watch all css files and inject when they change 12 | * 2. Watch HTML files and inject the difference when they change 13 | * 14 | */ 15 | 16 | var browserSync = require("browser-sync").create(); 17 | var htmlInjector = require("bs-html-injector"); 18 | 19 | /** 20 | * Register the plugin 21 | */ 22 | browserSync.use(htmlInjector, { 23 | files: "app/*.html" 24 | }); 25 | 26 | /** 27 | * Run the Server 28 | */ 29 | browserSync.init({ 30 | server: ["app"], 31 | files: ["app/css/**"] 32 | }); 33 | -------------------------------------------------------------------------------- /gulpfile.example.js: -------------------------------------------------------------------------------- 1 | var gulp = require("gulp"); 2 | var browserSync = require("/Users/shakyshane/Sites/os-browser-sync"); 3 | var htmlInjector = require("./index"); 4 | 5 | /** 6 | * Start BrowserSync 7 | */ 8 | gulp.task("browser-sync", function () { 9 | browserSync.use(htmlInjector); 10 | browserSync({ 11 | logLevel: "silent", 12 | server: "test/fixtures", 13 | open: false 14 | }); 15 | }); 16 | 17 | /** 18 | * Default task, inject HTML when the files change 19 | */ 20 | gulp.task("default", ["browser-sync"], function () { 21 | gulp.watch("test/fixtures/*.html", htmlInjector); 22 | }); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * HTML Injector 4 | * - a BrowserSync.io plugin. 5 | * 6 | */ 7 | 8 | var events = require('events'); 9 | var emitter = new events.EventEmitter(); 10 | var request = require('request'); 11 | var debug = require('debug')('bs-html-injector'); 12 | var createDom = require("./lib/injector").createDom; 13 | 14 | var HtmlInjector = require("./lib/html-injector"); 15 | var config = require("./lib/config"); 16 | var _ = require("./lodash.custom"); 17 | 18 | /** 19 | * ON/OFF flag 20 | * @type {boolean} 21 | */ 22 | var enabled = true; 23 | 24 | /** 25 | * Instance of HTML Injector 26 | */ 27 | var instance; 28 | 29 | /** 30 | * Main export, can be called when BrowserSync is running. 31 | * @returns {*} 32 | */ 33 | module.exports = function () { 34 | if (instance) { 35 | return emitter.emit(config.PLUGIN_EVENT); 36 | } 37 | }; 38 | 39 | /** 40 | * @param {Object} opts 41 | * @param {BrowserSync} bs 42 | */ 43 | module.exports["plugin"] = function (opts, bs) { 44 | 45 | opts = opts || {}; 46 | 47 | var logger = bs.getLogger(config.PLUGIN_NAME).info("Running..."); 48 | 49 | if (typeof opts.logLevel !== "undefined") { 50 | logger.setLevel(opts.logLevel); 51 | } 52 | 53 | var htmlInjector = instance = new HtmlInjector(opts, logger, bs); 54 | var opts = htmlInjector.opts; 55 | var clients = bs.io.of(bs.options.getIn(["socket", "namespace"])); 56 | 57 | if (bs.ui) { 58 | addUiEvents(); 59 | } 60 | 61 | /** 62 | * Add UI events if running 63 | */ 64 | function addUiEvents () { 65 | 66 | var ui = bs.io.of(bs.ui.config.getIn(["socket", "namespace"])); 67 | 68 | bs.ui.listen(config.PLUGIN_NAME, { 69 | "restriction:add": function (data) { 70 | opts.restrictions = _.uniq(opts.restrictions.concat([data])); 71 | updateOptions(opts); 72 | }, 73 | "restriction:remove": function (data) { 74 | opts.restrictions = _.without(opts.restrictions, data); 75 | updateOptions(opts); 76 | } 77 | }); 78 | 79 | function updateOptions (opts) { 80 | bs.events.emit("plugins:opts", { 81 | name: config.PLUGIN_NAME, 82 | opts: opts 83 | }); 84 | ui.emit("options:update", { 85 | name: config.PLUGIN_NAME, 86 | opts: bs.getUserPlugin(config.PLUGIN_NAME).opts 87 | }); 88 | } 89 | } 90 | 91 | enabled = htmlInjector.opts.enabled; 92 | 93 | /** 94 | * Configure event 95 | */ 96 | bs.events.on("plugins:configure", function (data) { 97 | 98 | if (data.name !== config.PLUGIN_NAME) { 99 | return; 100 | } 101 | 102 | var msg = "{cyan:Enabled"; 103 | 104 | if (!data.active) { 105 | msg = "{yellow:Disabled"; 106 | } else { 107 | clients.emit("browser:reload"); 108 | } 109 | 110 | logger.info(msg); 111 | 112 | enabled = data.active; 113 | }); 114 | 115 | /** 116 | * File changed event 117 | */ 118 | bs.events.on("file:changed", fileChangedEvent); 119 | 120 | /** 121 | * Internal event 122 | */ 123 | emitter.on(config.PLUGIN_EVENT, pluginEvent); 124 | 125 | /** 126 | * Socket Connection event 127 | */ 128 | clients.on("connection", handleSocketConnection); 129 | 130 | /** 131 | * Catch the above ^ 132 | */ 133 | function handleSocketConnection (client) { 134 | client.on("client:url", handleUrlEvent); 135 | } 136 | 137 | function getRequestOptions(url) { 138 | return { 139 | url: url, 140 | headers: { 141 | "Accept": "text/html" 142 | } 143 | } 144 | } 145 | 146 | /** 147 | * @param data 148 | */ 149 | function handleUrlEvent (data) { 150 | 151 | if (!enabled) { 152 | 153 | return; 154 | } 155 | 156 | request(getRequestOptions(data.url), function (error, response, body) { 157 | 158 | logger.debug("Stashing: {magenta:%s", data.url); 159 | 160 | if (!error && response.statusCode == 200) { 161 | htmlInjector.cache[data.url] = createDom(body); 162 | } 163 | }); 164 | } 165 | 166 | function fileChangedEvent (data) { 167 | 168 | if (!_.isUndefined(data.event) && data.event !== "change") { 169 | return; 170 | } 171 | 172 | if (!enabled) { 173 | 174 | if (opts.handoff && data._origin !== config.PLUGIN_NAME) { 175 | data.namespace = "core"; 176 | data._origin = config.PLUGIN_NAME; 177 | bs.events.emit("file:changed", data); 178 | } 179 | 180 | return; 181 | } 182 | 183 | if (data.namespace !== config.PLUGIN_NAME) { 184 | debug('Ignoring file change to ', data.path); 185 | return; 186 | } 187 | 188 | debug('Responding to file change event', data.namespace); 189 | 190 | requestNew(opts); 191 | } 192 | 193 | function pluginEvent () { 194 | 195 | if (!htmlInjector.hasCached()) { 196 | return; 197 | } 198 | 199 | doNewRequest(); 200 | } 201 | 202 | function doNewRequest() { 203 | 204 | if (!enabled || !htmlInjector.hasCached()) { 205 | return; 206 | } 207 | 208 | logger.debug("Getting new HTML from: {magenta:%s} urls", Object.keys(htmlInjector.cache).length); 209 | 210 | requestNew(opts); 211 | } 212 | /** 213 | * Request new version of Dom 214 | * @param {String} url 215 | * @param {Object} opts - plugin options 216 | */ 217 | function requestNew (opts) { 218 | 219 | // Remove any 220 | var sockets = bs.io.of(bs.options.getIn(["socket", "namespace"])).sockets; 221 | var valid = Object.keys(sockets).map(function (key) { 222 | return sockets[key].handshake.headers.referer; 223 | }); 224 | 225 | logger.debug("Cache items: {yellow:%s", Object.keys(htmlInjector.cache).length); 226 | 227 | Object.keys(htmlInjector.cache).forEach(function (url) { 228 | 229 | if (valid.indexOf(url) === -1) { 230 | delete htmlInjector.cache[url]; 231 | return; 232 | } 233 | 234 | debug("requesting %s", url); 235 | 236 | request(getRequestOptions(url), function (error, response, body) { 237 | 238 | if (!error && response.statusCode == 200) { 239 | 240 | var tasks = htmlInjector.process(body, htmlInjector.cache[url], url, opts); 241 | 242 | if (tasks.length) { 243 | debug("%s tasks returned", tasks.length); 244 | tasks.forEach(function (task) { 245 | debug("Task: TAG: %s, INDEX: %s", task.tagName, task.index); 246 | clients.emit(config.CLIENT_EVENT, task); 247 | }); 248 | } else { 249 | debug("0 tasks returned, reloading instead"); 250 | clients.emit("browser:reload"); 251 | } 252 | } 253 | }); 254 | }); 255 | } 256 | }; 257 | 258 | /** 259 | * Client JS hook 260 | * @returns {String} 261 | */ 262 | module.exports.hooks = { 263 | "client:js": require("fs").readFileSync(__dirname + "/client.js", "utf-8") 264 | }; 265 | 266 | /** 267 | * Plugin name. 268 | * @type {string} 269 | */ 270 | module.exports["plugin:name"] = config.PLUGIN_NAME; 271 | 272 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | PLUGIN_NAME: "HTML Injector", 3 | PLUGIN_EVENT: "plugin:html:inject", 4 | CLIENT_EVENT: "html:inject" 5 | } -------------------------------------------------------------------------------- /lib/default-config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module bs-html-injector.options 3 | * Default configuration. Everything here can be overridden 4 | */ 5 | module.exports = { 6 | /** 7 | * 8 | * Define which tags are ignored by default. 9 | * 10 | * @property excludedTags 11 | * @type Array 12 | * @default ["HTML", "HEAD"] 13 | */ 14 | excludedTags: ["HTML", "HEAD"], 15 | /** 16 | * Log Level (inherits from browserSync initially, but can be overridden) 17 | */ 18 | logLevel: undefined, 19 | /** 20 | * Handoff - when plugin is disabled, should the file-watching be handed 21 | * off to core? 22 | */ 23 | handoff: true, 24 | /** 25 | * Narrow down the working target 26 | */ 27 | restrictions: [] 28 | }; -------------------------------------------------------------------------------- /lib/html-injector.js: -------------------------------------------------------------------------------- 1 | var defaults = require("./default-config"); 2 | var _ = require("../lodash.custom"); 3 | var compareDoms = require("./injector").compareDoms; 4 | var createDom = require("./injector").createDom; 5 | var utils = require("./utils"); 6 | 7 | /** 8 | * @param opts 9 | * @constructor 10 | */ 11 | var HtmlInjector = function (opts) { 12 | 13 | var html = this; 14 | 15 | if (!(html instanceof HtmlInjector)) { 16 | return new HtmlInjector(opts); 17 | } 18 | 19 | html.opts = _.assign({}, defaults, opts); 20 | html.cache = {}; 21 | html.emitCount = 0; 22 | 23 | if (_.isUndefined(html.opts.enabled)) { 24 | html.opts.enabled = true; 25 | } 26 | 27 | /** 28 | * @returns {Number} 29 | */ 30 | html.hasCached = function () { 31 | return Object.keys(html.cache).length; 32 | }; 33 | 34 | return html; 35 | }; 36 | 37 | module.exports = HtmlInjector; 38 | 39 | /** 40 | * Collate tasks 41 | * @param parent 42 | * @param diffs 43 | * @param restrictions 44 | * @param currentUrl 45 | */ 46 | HtmlInjector.prototype.getTasks = function (parent, diffs, restrictions, currentUrl) { 47 | 48 | var tasks = []; 49 | 50 | diffs.forEach(function (item) { 51 | 52 | item.diff.type = item.diff.type || "node"; 53 | 54 | var element = getElement(parent, item); 55 | 56 | if (element && element.domNode) { 57 | 58 | if (item.diff.type) { 59 | 60 | var obj = { 61 | tagName: item.tagName, 62 | index: item.index, 63 | cssText: element.domNode.style.cssText, 64 | attrs: element.attrs, 65 | diff: item.diff, 66 | url: currentUrl, 67 | restrictions: restrictions 68 | }; 69 | 70 | switch (item.diff.type) { 71 | 72 | case 'attribute': 73 | // no-op, use default obj 74 | break; 75 | 76 | default: 77 | obj.html = element.domNode.innerHTML; 78 | break; 79 | } 80 | 81 | tasks.push(obj); 82 | } 83 | } 84 | }); 85 | 86 | if (tasks.length) { 87 | return tasks; 88 | } 89 | 90 | return []; 91 | }; 92 | 93 | /** 94 | * @param item1 95 | * @param item2 96 | * @param url 97 | * @param opts 98 | */ 99 | HtmlInjector.prototype.process = function (item1, item2, url, opts) { 100 | 101 | var html = this; 102 | var results = html.getDiffs(item1, item2, opts); 103 | var out = []; 104 | 105 | if (results.length) { 106 | results.forEach(function (result) { 107 | out = out.concat(html.getTasks(result.parent, result.diffs, result.restriction, url)); 108 | }); 109 | html.cache[url] = createDom(item1); 110 | } 111 | 112 | return out; 113 | }; 114 | 115 | /** 116 | * @param {string|object} item1 117 | * @param {string|object} item2 118 | * @param opts 119 | * @returns {*} 120 | */ 121 | HtmlInjector.prototype.getDiffs = function (item1, item2, opts) { 122 | /** 123 | * @param newDom 124 | * @param item2 125 | * @param [opts] 126 | * @returns {*} 127 | */ 128 | 129 | opts = opts || {}; 130 | 131 | if (_.isString(item2)) { 132 | item2 = createDom(item2); 133 | } 134 | 135 | var newDom = createDom(item1); 136 | var results = compareDoms(item2, newDom, opts); 137 | 138 | if (results.length) { 139 | results = results.map(function (result) { 140 | result.diffs = utils.removeDupes(result.diffs); 141 | result.diffs = utils.removeExcluded(result.diffs, opts.excludedTags); 142 | return result; 143 | }); 144 | } 145 | 146 | return results; 147 | }; 148 | 149 | module.exports.getDiffs = HtmlInjector.prototype.getDiffs; 150 | 151 | /** 152 | * Get a dom node + attrs 153 | * @param parent 154 | * @param item 155 | * @returns {{domNode: *, attrs: {}}} 156 | */ 157 | function getElement(parent, item) { 158 | 159 | var element = parent.getElementsByTagName(item.tagName)[item.index]; 160 | var elemAttrs = {}; 161 | 162 | for (var attr, i = 0, attrs = element.attributes, l = attrs.length; i < l; i++) { 163 | attr = attrs.item(i); 164 | elemAttrs[attr.nodeName] = attr.nodeValue; 165 | } 166 | 167 | return { 168 | domNode: element, 169 | attrs: elemAttrs 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /lib/injector.js: -------------------------------------------------------------------------------- 1 | require("jsdom").defaultDocumentFeatures = { 2 | FetchExternalResources: false, 3 | ProcessExternalResources: false 4 | }; 5 | 6 | var jsdom = require("jsdom").jsdom; 7 | var compare = require('dom-compare-temp').compare; 8 | var debug = require('debug')('bs-html-injector'); 9 | 10 | /** 11 | * Compare two DOMS & return diffs 12 | * @param {Object} newDom 13 | * @param {Object} oldDom 14 | * @param opts 15 | * @returns {Object} 16 | */ 17 | function compareDoms(oldDom, newDom, opts) { 18 | 19 | opts = opts || {}; 20 | opts.restrictions = opts.restrictions || []; 21 | 22 | var diffs = []; 23 | 24 | if (!oldDom || !newDom) { 25 | return diffs; 26 | } 27 | 28 | if (opts.restrictions.length) { 29 | opts.restrictions.forEach(function (restriction) { 30 | getResults(restriction); 31 | }); 32 | } else { 33 | getResults("html"); 34 | } 35 | 36 | function getResults (restriction) { 37 | var dom1, dom2, node; 38 | 39 | if (restriction !== "html") { 40 | var match1 = oldDom.querySelectorAll(restriction); 41 | var match2 = newDom.querySelectorAll(restriction); 42 | if (match1.length && match2.length) { 43 | 44 | dom1 = createDom(match1[0].innerHTML); 45 | dom2 = createDom(match2[0].innerHTML); 46 | } else { 47 | debug("Selector %s not found", restriction); 48 | } 49 | } else { 50 | dom1 = oldDom; 51 | dom2 = newDom; 52 | } 53 | 54 | if (!dom1 || !dom2) { 55 | debug("2 doms not found"); 56 | return; 57 | } 58 | 59 | var result = compare(dom1, dom2, { 60 | formatFailure: function (failure, domNode) { 61 | node = domNode; 62 | var allElems = domNode.ownerDocument.getElementsByTagName(domNode.nodeName); 63 | failure.index = Array.prototype.indexOf.call(allElems, domNode); 64 | failure.tagName = domNode.nodeName; 65 | return failure; 66 | } 67 | }); 68 | 69 | var same = result.getResult(); // false cause' trees are different 70 | 71 | if (!same) { 72 | diffs.push({ 73 | restriction: restriction, 74 | diffs: result.getDifferences(), 75 | parent: dom2 76 | }); 77 | } 78 | } 79 | 80 | return diffs; 81 | } 82 | 83 | module.exports.compareDoms = compareDoms; 84 | 85 | /** 86 | * @param string 87 | * @returns {*} 88 | */ 89 | function createDom(string) { 90 | return jsdom(string); 91 | } 92 | 93 | module.exports.createDom = createDom; -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | var _ = require("../lodash.custom"); 2 | 3 | module.exports = { 4 | /** 5 | * @param {Array} differences 6 | * @returns {Array} 7 | */ 8 | removeDupes: function(differences) { 9 | return _.uniqBy(differences, "node"); 10 | }, 11 | 12 | /** 13 | * @param diffs 14 | * @param excludeList 15 | * @returns {*} 16 | */ 17 | removeExcluded: function (diffs, excludeList) { 18 | return _.filter(diffs, function (item) { 19 | return !_.includes(excludeList, item.tagName); 20 | }); 21 | } 22 | }; 23 | 24 | /** 25 | * Not currently used... needs work. 26 | * @param {Array} differences 27 | * @returns {Array} 28 | */ 29 | function removeChildren(differences) { 30 | 31 | differences.reverse(); 32 | 33 | var parents = []; 34 | 35 | differences.forEach(function (item, index) { 36 | 37 | var path = item.node; 38 | 39 | if (index === 0) { 40 | return parents.push(item); 41 | } 42 | 43 | parents.forEach(function (parentItem) { 44 | if (!_.includes(path, parentItem.node)) { 45 | return parents.push(item); 46 | } 47 | }); 48 | }); 49 | 50 | return parents; 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bs-html-injector", 3 | "version": "3.0.3", 4 | "description": "Inject only HTML that has changed.", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/shakyshane/html-injector.git" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/shakyshane/html-injector/issues" 12 | }, 13 | "scripts": { 14 | "test": "mocha --reporter spec", 15 | "lodash": "lodash include=isUndefined,isString,isArray,filter,assign,includes,uniqBy,uniq,without exports=node" 16 | }, 17 | "files": [ 18 | "lib", 19 | "lodash.custom.js", 20 | "ui", 21 | "index.js", 22 | "client.js" 23 | ], 24 | "author": "", 25 | "license": "MIT", 26 | "devDependencies": { 27 | "browser-sync": "2.12.12", 28 | "chai": "^3.3.0", 29 | "lodash-cli": "4.13.1", 30 | "mocha": "^2.3.3", 31 | "multiline": "^1.0.2", 32 | "socket.io-client": "^1.1.0" 33 | }, 34 | "dependencies": { 35 | "debug": "^2.1.3", 36 | "dom-compare-temp": "^0.1.0", 37 | "jsdom": "^6.5.1", 38 | "request": "^2.40.0" 39 | }, 40 | "keywords": [ 41 | "browser sync plugin", 42 | "html injection" 43 | ], 44 | "browser-sync:ui": { 45 | "hooks": { 46 | "markup": "ui/html-injector.html", 47 | "templates": [ 48 | "ui/html-injector.directive.html" 49 | ], 50 | "client:js": [ 51 | "ui/client.js" 52 | ] 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ### HTML Injector [![Build Status](https://travis-ci.org/shakyShane/html-injector.svg?branch=master)](https://travis-ci.org/shakyShane/html-injector) 2 | [Browsersync](http://www.browsersync.io/) plugin for injecting HTML changes without reloading the browser. Requires an existing page with a `` tag. 3 | 4 | ## Install (Node V4.0.0 & above) 5 | 6 | ```bash 7 | $ npm i browser-sync bs-html-injector 8 | ``` 9 | 10 | ## Install (Node V0.10.x 0.12.x) 11 | 12 | ```bash 13 | $ npm i browser-sync bs-html-injector@2 14 | ``` 15 | 16 | ## Examples & Recipes including html-injection 17 | * [Examples folder](https://github.com/shakyShane/html-injector/tree/master/examples) 18 | * [HTML/CSS injection example](https://github.com/BrowserSync/recipes/tree/master/recipes/html.injection) 19 | * [Grunt, SASS, HTML/CSS injection example](https://github.com/BrowserSync/recipes/tree/master/recipes/grunt.html.injection) 20 | 21 | ## Options 22 | 23 | **files** - String|Array 24 | File watching patterns that will trigger the injection. NOTE: Ensure you are 25 | not also watching the same file through the regular Browsersync 26 | config - this will cause a full reload and the inject will not happen 27 | 28 | ```js 29 | browserSync.use(require("bs-html-injector"), { 30 | files: ["app/*.html", "app/templates/**"] 31 | }); 32 | ``` 33 | 34 | **restrictions** - Array 35 | Limit the comparisons to a certain elements. 36 | 37 | ```js 38 | browserSync.use(require("bs-html-injector"), { 39 | files: "app/*.html", 40 | restrictions: ['#header', '#footer'] 41 | }); 42 | ``` 43 | 44 | **excludedTags** - Array 45 | When working from scratch within the `body` tag, the plugin will work just fine. But when you start 46 | working with nested elements, you might want to add the following configuration to improve the 47 | injecting. 48 | 49 | ```js 50 | browserSync.use(require("bs-html-injector"), { 51 | files: "app/*.html", 52 | excludedTags: ["BODY"] 53 | }); 54 | ``` 55 | 56 | 57 | ### Example 58 | Create a file called `bs.js` and enter the following: (update the paths to match yours) 59 | 60 | ```js 61 | // requires version 2.0 of Browsersync or higher. 62 | var browserSync = require("browser-sync").create(); 63 | var htmlInjector = require("bs-html-injector"); 64 | 65 | // register the plugin 66 | browserSync.use(htmlInjector, { 67 | // Files to watch that will trigger the injection 68 | files: "app/*.html" 69 | }); 70 | 71 | // now run Browsersync, watching CSS files as normal 72 | browserSync.init({ 73 | files: "app/styles/*.css" 74 | }); 75 | ``` 76 | 77 | ### Gulp example 78 | 79 | ```js 80 | var gulp = require("gulp"); 81 | var browserSync = require("browser-sync").create(); 82 | var htmlInjector = require("bs-html-injector"); 83 | 84 | /** 85 | * Start Browsersync 86 | */ 87 | gulp.task("browser-sync", function () { 88 | browserSync.use(htmlInjector, { 89 | files: "app/*.html" 90 | }); 91 | browserSync.init({ 92 | server: "test/fixtures" 93 | }); 94 | }); 95 | 96 | /** 97 | * Default task 98 | */ 99 | gulp.task("default", ["browser-sync"], function () { 100 | gulp.watch("test/fixtures/*.html", htmlInjector); 101 | }); 102 | ``` 103 | 104 | ### Grunt example 105 | ```js 106 | // This shows a full config file! 107 | module.exports = function (grunt) { 108 | grunt.initConfig({ 109 | watch: { 110 | files: 'app/scss/**/*.scss', 111 | tasks: ['bsReload:css'] 112 | }, 113 | sass: { 114 | dev: { 115 | files: { 116 | 'app/css/main.css': 'app/scss/main.scss' 117 | } 118 | } 119 | }, 120 | browserSync: { 121 | dev: { 122 | options: { 123 | watchTask: true, 124 | server: './app', 125 | plugins: [ 126 | { 127 | module: "bs-html-injector", 128 | options: { 129 | files: "./app/*.html" 130 | } 131 | } 132 | ] 133 | } 134 | } 135 | }, 136 | bsReload: { 137 | css: "main.css" 138 | } 139 | }); 140 | 141 | // load npm tasks 142 | grunt.loadNpmTasks('grunt-contrib-sass'); 143 | grunt.loadNpmTasks('grunt-contrib-watch'); 144 | grunt.loadNpmTasks('grunt-browser-sync'); 145 | 146 | // define default task 147 | grunt.registerTask('default', ['browserSync', 'watch']); 148 | }; 149 | ``` 150 | -------------------------------------------------------------------------------- /test/compare.doms.js: -------------------------------------------------------------------------------- 1 | var createDom = require("../lib/injector").createDom; 2 | var assert = require("chai").assert; 3 | var HtmlInjector = require("../lib/html-injector"); 4 | var multiline = require("multiline"); 5 | 6 | describe("Comparing Simple doms", function(){ 7 | 8 | it("returns element index when it has different children", function(){ 9 | 10 | var str1 = multiline.stripIndent(function(){/* 11 | 12 | 13 | 14 |

❤ unicorns

15 | 16 | 17 | */}); 18 | var str2 = multiline.stripIndent(function(){/* 19 | 20 | 21 | 22 |

unicorns

23 | 24 | 25 | */}); 26 | 27 | var oldDom = createDom(str1); 28 | var results = HtmlInjector().process(str2, oldDom); 29 | 30 | assert.equal(results.length, 1); 31 | assert.equal(results[0].restrictions, "html"); 32 | assert.equal(results[0].tagName, "H1"); 33 | assert.equal(results[0].index, "0"); 34 | }); 35 | 36 | it("returns element index when it has missing children", function(){ 37 | 38 | var str1 = multiline.stripIndent(function(){/* 39 | 40 | 41 | 42 | 43 | 44 | */}); 45 | var str2 = multiline.stripIndent(function(){/* 46 | 47 | 48 | 49 |

unicorns

50 | 51 | 52 | */}); 53 | 54 | var oldDom = createDom(str1); 55 | var results = HtmlInjector().process(str2, oldDom); 56 | 57 | assert.equal(results.length, 1); 58 | assert.equal(results[0].restrictions, "html"); 59 | assert.equal(results[0].tagName, "BODY"); 60 | assert.equal(results[0].index, "0"); 61 | }); 62 | 63 | it("Removes duplicate diffs if on same element", function(){ 64 | 65 | var str1 = multiline.stripIndent(function(){/* 66 | 67 | 68 | 69 |

❤s unicorns What you saying?

70 | 71 | 72 | */}); 73 | var str2 = multiline.stripIndent(function(){/* 74 | 75 | 76 | 77 |

unicorns

78 | 79 | 80 | */}); 81 | 82 | var oldDom = createDom(str1); 83 | var results = HtmlInjector().process(str2, oldDom); 84 | 85 | assert.equal(results.length, 2); 86 | assert.equal(results[0].tagName, "SPAN"); 87 | assert.equal(results[1].tagName, "H1"); 88 | }); 89 | 90 | it("Removes duplicate diffs if on same element (2)", function(){ 91 | 92 | var str1 = multiline.stripIndent(function(){/* 93 | 94 | 95 | 96 | 97 | This has a Title 98 | 99 | 100 |

HTML injector

101 |

HTML injector is prettty cool

102 | 103 | 104 | */}); 105 | var str2 = multiline.stripIndent(function(){/* 106 | 107 | 108 | 109 | 110 | This has a Title 111 | 114 | 115 | 116 | 117 |

HTML injector

118 |

HTML injector is prettty cool

119 | 120 | 121 | */}); 122 | 123 | var oldDom = createDom(str1); 124 | var results = HtmlInjector().process(str2, oldDom); 125 | 126 | assert.equal(results.length, 1); 127 | assert.equal(results[0].tagName, "HEAD"); 128 | }); 129 | it("Returns diffs from mulitple elements", function(){ 130 | 131 | var str1 = multiline.stripIndent(function(){/* 132 | 133 | 134 | 135 | 136 | This has a Title 137 | 138 | 139 |

HTML injector

140 |

HTML injector is prettty cool

141 | 142 | 143 | */}); 144 | var str2 = multiline.stripIndent(function(){/* 145 | 146 | 147 | 148 | 149 | This has a Title 150 | 153 | 154 | 155 | 156 |

HTML injector

157 |

HTML injector is prettty coolsss

158 | 159 | 160 | */}); 161 | 162 | var oldDom = createDom(str1); 163 | var results = HtmlInjector().process(str2, oldDom); 164 | 165 | assert.equal(results.length, 2); 166 | assert.equal(results[0].tagName, "HEAD"); 167 | assert.equal(results[1].tagName, "H1"); 168 | }); 169 | }); 170 | 171 | describe("Removing excluded", function(){ 172 | 173 | it("returns a filtered list", function(){ 174 | 175 | var str1 = multiline.stripIndent(function(){/* 176 | 177 | 178 | 179 |

❤ unicornsss

180 |

HATE unicornsss

181 |

Hi there

182 | 183 | 184 | */}); 185 | var str2 = multiline.stripIndent(function(){/* 186 | 187 | 188 | 189 |

❤ unicornsss

190 |

HATE unicornsssas

191 | 192 | 193 | 194 | */}); 195 | 196 | var oldDom = createDom(str1); 197 | var results = HtmlInjector().process(str2, oldDom, null, {excludedTags: ["H1", "H3"]}); 198 | 199 | 200 | assert.equal(results.length, 1); 201 | 202 | assert.equal(results[0].restrictions, "html"); 203 | assert.equal(results[0].index, 0); // should ignore outer H3 204 | assert.equal(results[0].tagName, "H2"); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /test/compare.restrict.js: -------------------------------------------------------------------------------- 1 | var createDom = require("../lib/injector").createDom; 2 | var HtmlInjector = require("../lib/html-injector"); 3 | var assert = require("chai").assert; 4 | var multiline = require("multiline"); 5 | 6 | describe("Comparing doms with restricted selector", function () { 7 | 8 | var html1, html2; 9 | 10 | before(function () { 11 | html1 = multiline.stripIndent(function(){/* 12 |

NO

13 |
14 |

❤ unicornsss

15 |

HATE unicornsssas

16 |

Hi there

17 |
18 | */}); 19 | 20 | html2 = multiline.stripIndent(function(){/* 21 |

NO

22 |
23 |

❤ unicornsss

24 |

HATE unicornsssas

25 | 26 |
27 | */}); 28 | }); 29 | 30 | it("should compare correctly when no restriction given", function (done) { 31 | 32 | var oldDom = createDom(html1); 33 | var results = HtmlInjector().process(html2, oldDom); 34 | assert.equal(results.length, 1); 35 | assert.equal(results[0].restrictions, "html"); 36 | assert.equal(results[0].index, 1); 37 | assert.equal(results[0].tagName, "H3"); 38 | done(); 39 | }); 40 | 41 | it("should compare correctly when `id` selector given", function (done) { 42 | 43 | var oldDom = createDom(html1); 44 | var results = HtmlInjector().process(html2, oldDom, null, {restrictions: ["#shane"]}); 45 | 46 | assert.equal(results.length, 1); 47 | assert.equal(results[0].restrictions, "#shane"); 48 | assert.equal(results[0].index, 0); // should ignore outer H3 49 | assert.equal(results[0].tagName, "H3"); 50 | 51 | done(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/fixtures/css/blog.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Globals 3 | */ 4 | 5 | body { 6 | font-family: Georgia, "Times New Roman", Times, serif; 7 | color: #555; 8 | } 9 | 10 | h1, .h1, 11 | h2, .h2, 12 | h3, .h3, 13 | h4, .h4, 14 | h5, .h5, 15 | h6, .h6 { 16 | margin-top: 0; 17 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 18 | font-weight: normal; 19 | color: #333; 20 | } 21 | 22 | 23 | /* 24 | * Override Bootstrap's default container. 25 | */ 26 | 27 | @media (min-width: 1200px) { 28 | .container { 29 | width: 970px; 30 | } 31 | } 32 | 33 | /* 34 | * Masthead for nav 35 | */ 36 | 37 | .blog-masthead { 38 | background-color: #428bca; 39 | -webkit-box-shadow: inset 0 -2px 5px rgba(0,0,0,.1); 40 | box-shadow: inset 0 -2px 5px rgba(0,0,0,.1); 41 | } 42 | 43 | /* Nav links */ 44 | .blog-nav-item { 45 | position: relative; 46 | display: inline-block; 47 | padding: 10px; 48 | font-weight: 500; 49 | color: #cdddeb; 50 | } 51 | .blog-nav-item:hover, 52 | .blog-nav-item:focus { 53 | color: #fff; 54 | text-decoration: none; 55 | } 56 | 57 | /* Active state gets a caret at the bottom */ 58 | .blog-nav .active { 59 | color: #fff; 60 | } 61 | .blog-nav .active:after { 62 | position: absolute; 63 | bottom: 0; 64 | left: 50%; 65 | width: 0; 66 | height: 0; 67 | margin-left: -5px; 68 | vertical-align: middle; 69 | content: " "; 70 | border-right: 5px solid transparent; 71 | border-bottom: 5px solid; 72 | border-left: 5px solid transparent; 73 | } 74 | 75 | 76 | /* 77 | * Blog name and description 78 | */ 79 | 80 | .blog-header { 81 | padding-top: 20px; 82 | padding-bottom: 20px; 83 | } 84 | .blog-title { 85 | margin-top: 30px; 86 | margin-bottom: 0; 87 | font-size: 30px; 88 | font-weight: normal; 89 | } 90 | .blog-description { 91 | font-size: 20px; 92 | color: #999; 93 | } 94 | 95 | 96 | /* 97 | * Main column and sidebar layout 98 | */ 99 | 100 | .blog-main { 101 | font-size: 18px; 102 | line-height: 1.5; 103 | } 104 | 105 | /* Sidebar modules for boxing content */ 106 | .sidebar-module { 107 | padding: 15px; 108 | margin: 0 -15px 15px; 109 | } 110 | .sidebar-module-inset { 111 | padding: 15px; 112 | background-color: #f5f5f5; 113 | border-radius: 4px; 114 | } 115 | .sidebar-module-inset p:last-child, 116 | .sidebar-module-inset ul:last-child, 117 | .sidebar-module-inset ol:last-child { 118 | margin-bottom: 0; 119 | } 120 | 121 | 122 | /* Pagination */ 123 | .pager { 124 | margin-bottom: 60px; 125 | text-align: left; 126 | } 127 | .pager > li > a { 128 | width: 140px; 129 | padding: 10px 20px; 130 | text-align: center; 131 | border-radius: 30px; 132 | } 133 | 134 | 135 | /* 136 | * Blog posts 137 | */ 138 | 139 | .blog-post { 140 | margin-bottom: 60px; 141 | } 142 | .blog-post-title { 143 | margin-bottom: 5px; 144 | font-size: 40px; 145 | } 146 | .blog-post-meta { 147 | margin-bottom: 20px; 148 | color: #999; 149 | } 150 | 151 | 152 | /* 153 | * Footer 154 | */ 155 | 156 | .blog-footer { 157 | padding: 40px 0; 158 | color: #999; 159 | text-align: center; 160 | background-color: #f9f9f9; 161 | border-top: 1px solid #e5e5e5; 162 | } 163 | .blog-footer p:last-child { 164 | margin-bottom: 0; 165 | } 166 | -------------------------------------------------------------------------------- /test/fixtures/dom1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
shane
9 | 10 | -------------------------------------------------------------------------------- /test/fixtures/dom2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | shane 9 | 10 | -------------------------------------------------------------------------------- /test/fixtures/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Blog Template for Bootstrap 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 | 33 |
34 |
35 | 36 |
37 | 38 |
39 |

The BrowserSync

40 |

The official example template of creating a blog with BrowserSync + Bootstrap

41 |
42 | 43 |
44 | 45 |
46 | 47 |
48 |

Sample blog post

49 | 50 |

This blog post shows a few different types of casontent that's supported and styled with Bootstrap. Basic typography, images, and code are all supported.

51 |
52 |

Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Sed posuere consectetur est at lobortis. Cras mattis consectetur purus sit amet fermentum.

53 |
54 |

Curabitur blandit tempus porttitor. Nullam quis risus eget urna mollis ornare vel eu leo. Nullam id dolor id nibh ultricies vehicula ut id elit.

55 |
56 |

Etiam porta sem malesuada magna mollis euismod. Cras mattis consectetur purus sit amet fermentum. Aenean lacinia bibendum nulla sed consectetur.

57 |

Heading

58 |

Vivamasus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.

59 |

Sub-heading

60 |

Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.

61 |
Example code block
62 |

Aenean lacinia bibendum nulla sed consectetur. Etiam porta sem malesuada magna mollis euismod. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa.

63 |

Sub-heading

64 |

Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean lacinia bibendum nulla sed consectetur. Etiam porta sem malesuada magna mollis euismod. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus.

65 |
    66 |
  • Praesent commodo cursus magna, vel scelerisque nisl consectetur et.
  • 67 |
  • Donec id elit non mi porta gravida at eget metus.
  • 68 |
  • Nulla vitae elit libero, a pharetra augue.
  • 69 |
70 |

Donec ullamcorper nulla non metus auctor fringilla. Nulla vitae elit libero, a pharetra augue.

71 |
    72 |
  1. Vestibulum id ligula porta felis euismod semper.
  2. 73 |
  3. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
  4. 74 |
  5. Maecenas sed diam eget risus varius blandit sit amet non magna.
  6. 75 |
76 |

Cras mattis consectetur purus sit amet fermentum. Sed posuere consectetur est at lobortis.

77 |
78 | 79 |
80 |

Another blog post

81 | 82 | 83 |

Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Sed posuere consectetur est at lobortis. Cras mattis consectetur purus sit amet fermentum.

84 |
85 |

Curabitur blandit tempus porttitor. Nullam quis risus eget urna mollis ornare vel eu leo. Nullam id dolor id nibh ultricies vehicula ut id elit.

86 |
87 |

Etiam porta sem malesuada magna mollis euismod. Cras mattis consectetur purus sit amet fermentum. Aenean lacinia bibendum nulla sed consectetur.

88 |

Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.

89 |
90 | 91 |
92 |

New feature

93 | 94 | 95 |

Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean lacinia bibendum nulla sed consectetur. Etiam porta sem malesuada magna mollis euismod. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus.

96 |
    97 |
  • Praesent commodo cursus magna, vel scelerisque nisl consectetur et.
  • 98 |
  • Donec id elit non mi porta gravida at eget metus.
  • 99 |
  • Nulla vitae elit libero, a pharetra augue.
  • 100 |
101 |

Etiam porta sem malesuada magna mollis euismod. Cras mattis consectetur purus sit amet fermentum. Aenean lacinia bibendum nulla sed consectetur.

102 |

Donec ullamcorper nulla non metus auctor fringilla. Nulla vitae elit libero, a pharetra augue.

103 |
104 | 105 | 111 | 112 |
113 | 114 |
115 | 119 | 136 | 144 |
145 | 146 |
147 | 148 |
149 | 150 | 156 | 157 | 158 | 159 | -------------------------------------------------------------------------------- /test/fixtures/store-product-alt.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 8 | 10 | 11 | 12 | 13 | Product | Swoon Editions 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 34 | 35 | 36 | 37 | 38 | 119 | 120 | 121 | 301 | 302 | 303 |
304 | 305 | 306 | 307 |
308 |
309 |
310 |
311 | 312 | 313 | 314 |
315 |
316 | 317 |
318 |
319 |

Lille 320 | Armchair, Blue 321 |

322 |
323 |
324 |
325 |
326 | 327 | 333 | 334 | 337 |
338 |
339 | 340 | 346 | 347 | 350 |
351 |
352 | 353 | 359 | 360 | 363 |
364 |
365 | 366 | 372 | 373 | 376 |
377 |
378 | 379 | 385 | 386 | 389 |
390 |
391 | 392 | 398 | 399 | 402 |
403 |
404 | 405 | 411 | 412 | 415 |
416 |
417 |
418 |
419 |
420 |
421 |

Lille 422 | Armchair, Blue 423 |

424 |
425 |

The perfect French-style desk for a stylish home office.

426 | 427 |
428 |
429 |
430 | 431 | Insider
Price 432 |
433 | £640 434 |
435 |
436 |
437 |

438 | £699 in high-end retailers 439 | — save 72% 440 |

441 |
442 |
443 |
444 |
445 |
446 | 459 |
460 |
461 | Add to trolley 462 |
463 |
464 |
465 |
466 |

Edition limited to 12 • only 5 left

467 |
468 |
469 |
470 |
471 |
472 |
473 | 474 | 475 |
476 |
477 | 478 | 479 |
480 | 481 | 484 |
485 | 486 | 487 | 512 | 513 | 514 |
515 |
516 |
517 | Also in: 518 | 519 | 520 |
521 |
522 |
523 |
524 | 525 |
526 |
527 |
528 |
529 |
530 |

The Details

531 | 532 |
533 |
    534 |
  • W: 130 cm D: 70 cm H: 76 cm
  • 535 |
  • Drawer depth: 40 cm
  • 536 |
  • Handcarved kiln-dried mango wood
  • 537 |
  • Mortise and tenon joinery
  • 538 |
  • Aged limewashed effect finish
  • 539 |
  • Deep 40cm drawers that fit a 15'' laptop
  • 540 |
  • Plate-and-ring antiqued brass handles
  • 541 |
542 |
543 |
544 |
545 |
546 |

This Edition

547 | 548 |
549 |

550 | The Adele strikes just the right balance between French elegance and a more 551 | rustic, pared-back style. So… cabriole legs, check; carving on all sides, check; 552 | brass plate-and-ring handles, check. But the limewashed finish gives those 553 | signature French elements a clean feel, keeping it very real and very 554 | contemporary. The Adele is ideal for working from home, and it can also work 555 | as a super-deep dressing table. 556 |

557 |
558 |
559 |
560 |
561 |
562 |

The Maker

563 | 564 |
565 |

566 | The workshop is located in the beautiful region of Rajashthan, India. Matthew, 567 | our Quality Guardian, relocated to India and visits the artisans several times a 568 | week. The owner has been in the furniture business for over 40 years, initially 569 | in antique restoration. Impressed with the operation we decided to consolidate 570 | the majority of our French-style production here. 571 |

572 |
573 |
574 | 575 |

Delivery & Returns

576 | 577 |
578 |
    579 |
  • In production – estimated delivery in late May
  • 580 |
  • Two-man delivery service to room of choice
  • 581 |
  • £38 per item delivery charge, non-refundable
  • 582 |
  • 7 day no quibble returns with free collection
  • 583 |
584 |
585 |
586 |
587 |
588 | 589 | 590 | 634 | 635 | 648 |
649 | 652 |
653 |
654 | 655 |
656 | 657 |
658 | Only 10 Left 659 |
660 | 661 |
662 | 663 | 664 | 665 |
666 | Lille 667 | Lille 668 |
669 |
670 | 671 |
672 | 673 |
674 |
675 |

Lille 676 | Armchair, Blue 677 |

678 |
679 |
680 |
681 |
682 | 683 | Insider
Price 684 |
685 | £640 686 |
687 |
688 |
689 |

690 | £699 in high-end retailers 691 | — save 72% 692 |

693 |
694 |
695 | 696 |
697 |
698 |
699 |
700 | 701 |
702 | 703 | 704 |
705 | 706 | 707 | 708 |
709 | Lille 710 | Lille 711 |
712 |
713 | 714 |
715 | 716 |
717 |
718 |

Lille 719 | Armchair, Blue 720 |

721 |
722 |
723 |
724 |
725 | 726 | Insider
Price 727 |
728 | £640 729 |
730 |
731 |
732 |

733 | £699 in high-end retailers 734 | — save 72% 735 |

736 |
737 |
738 | 739 |
740 |
741 |
742 |
743 | 744 |
745 | 746 | 747 |
748 | 749 | 750 | 751 |
752 | Lille 753 | Lille 754 |
755 |
756 | 757 |
758 | 759 |
760 |
761 |

Lille 762 | Armchair, Blue 763 |

764 |
765 |
766 |
767 |
768 | 769 | Insider
Price 770 |
771 | £640 772 |
773 |
774 |
775 |

776 | £699 in high-end retailers 777 | — save 72% 778 |

779 |
780 |
781 | 782 |
783 |
784 |
785 |
786 | 787 |
788 | 789 | 790 |
791 | 792 | 793 | 794 |
795 | Lille 796 | Lille 797 |
798 |
799 | 800 |
801 | 802 |
803 |
804 |

Lille 805 | Armchair, Blue 806 |

807 |
808 |
809 |
810 |
811 | 812 | Insider
Price 813 |
814 | £640 815 |
816 |
817 |
818 |

819 | £699 in high-end retailers 820 | — save 72% 821 |

822 |
823 |
824 | 825 |
826 |
827 |
828 |
829 |
830 | 831 | 872 | 873 |
874 |
875 | 876 | 877 |
878 | 879 | 880 |
881 |
882 | 892 |
893 |
894 | 895 | 943 | 944 | 945 | 946 | 1032 | 1033 | 1034 | 1035 | 1036 | 1037 | 1038 | -------------------------------------------------------------------------------- /test/fixtures/store-product.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 8 | 10 | 11 | 12 | 13 | Product | Swoon Editions 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 34 | 35 | 36 | 37 | 38 | 119 | 120 | 121 | 301 | 302 | 303 |
304 | 305 | 306 | 307 |
308 |
309 |
310 |
311 | 312 | 313 | 314 |
315 |
316 | 317 |
318 |
319 |

Lille 320 | Armchair, Blue 321 |

322 |
323 |
324 |
325 |
326 | 327 | 333 | 334 | 337 |
338 |
339 | 340 | 346 | 347 | 350 |
351 |
352 | 353 | 359 | 360 | 363 |
364 |
365 | 366 | 372 | 373 | 376 |
377 |
378 | 379 | 385 | 386 | 389 |
390 |
391 | 392 | 398 | 399 | 402 |
403 |
404 | 405 | 411 | 412 | 415 |
416 |
417 |
418 |
419 |
420 |
421 |

Lille 422 | Armchair, Blue 423 |

424 |
425 |

The perfect French-style desk for a stylish home office.

426 | 427 |
428 |
429 |
430 | 431 | Insider
Price 432 |
433 | £640 434 |
435 |
436 |
437 |

438 | £699 in high-end retailers 439 | — save 72% 440 |

441 |
442 |
443 |
444 |
445 |
446 | 459 |
460 |
461 | Add to trolley 462 |
463 |
464 |
465 |
466 |

Edition limited to 12 • only 5 left

467 |
468 |
469 |
470 |
471 |
472 |
473 | 474 | 475 |
476 |
477 | 478 | 479 |
480 | 481 | 484 |
485 | 486 | 487 | 512 | 513 | 514 |
515 |
516 |
517 | Also in: 518 | 519 | 520 |
521 |
522 |
523 |
524 | 525 |
526 |
527 |
528 |
529 |
530 |

The Details

531 | 532 |
533 |
    534 |
  • W: 130 cm D: 70 cm H: 76 cm
  • 535 |
  • Drawer depth: 40 cm
  • 536 |
  • Handcarved kiln-dried mango wood
  • 537 |
  • Mortise and tenon joinery
  • 538 |
  • Aged limewashed effect finish
  • 539 |
  • Deep 40cm drawers that fit a 15'' laptop
  • 540 |
  • Plate-and-ring antiqued brass handles
  • 541 |
542 |
543 |
544 |
545 |
546 |

This Edition

547 | 548 |
549 |

550 | The Adele strikes just the right balance between French elegance and a more 551 | rustic, pared-back style. So… cabriole legs, check; carving on all sides, check; 552 | brass plate-and-ring handles, check. But the limewashed finish gives those 553 | signature French elements a clean feel, keeping it very real and very 554 | contemporary. The Adele is ideal for working from home, and it can also work 555 | as a super-deep dressing table. 556 |

557 |
558 |
559 |
560 |
561 |
562 |

The Maker

563 | 564 |
565 |

566 | The workshop is located in the beautiful region of Rajashthan, India. Matthew, 567 | our Quality Guardian, relocated to India and visits the artisans several times a 568 | week. The owner has been in the furniture business for over 40 years, initially 569 | in antique restoration. Impressed with the operation we decided to consolidate 570 | the majority of our French-style production here. 571 |

572 |
573 |
574 | 575 |

Delivery & Returns

576 | 577 |
578 |
    579 |
  • In production – estimated delivery in late May
  • 580 |
  • Two-man delivery service to room of choice
  • 581 |
  • £38 per item delivery charge, non-refundable
  • 582 |
  • 7 day no quibble returns with free collection
  • 583 |
584 |
585 |
586 |
587 |
588 | 589 | 590 | 634 | 635 | 648 |
649 | 652 |
653 |
654 | 655 |
656 | 657 |
658 | Only 10 Left 659 |
660 | 661 |
662 | 663 | 664 | 665 |
666 | Lille 667 | Lille 668 |
669 |
670 | 671 |
672 | 673 |
674 |
675 |

Lille 676 | Armchair, Blue 677 |

678 |
679 |
680 |
681 |
682 | 683 | Insider
Price 684 |
685 | £640 686 |
687 |
688 |
689 |

690 | £699 in high-end retailers 691 | — save 72% 692 |

693 |
694 |
695 | 696 |
697 |
698 |
699 |
700 | 701 |
702 | 703 | 704 |
705 | 706 | 707 | 708 |
709 | Lille 710 | Lille 711 |
712 |
713 | 714 |
715 | 716 |
717 |
718 |

Lille 719 | Armchair, Blue 720 |

721 |
722 |
723 |
724 |
725 | 726 | Insider
Price 727 |
728 | £640 729 |
730 |
731 |
732 |

733 | £699 in high-end retailers 734 | — save 72% 735 |

736 |
737 |
738 | 739 |
740 |
741 |
742 |
743 | 744 |
745 | 746 | 747 |
748 | 749 | 750 | 751 |
752 | Lille 753 | Lille 754 |
755 |
756 | 757 |
758 | 759 |
760 |
761 |

Lille 762 | Armchair, Blue 763 |

764 |
765 |
766 |
767 |
768 | 769 | Insider
Price 770 |
771 | £640 772 |
773 |
774 |
775 |

776 | £699 in high-end retailers 777 | — save 72% 778 |

779 |
780 |
781 | 782 |
783 |
784 |
785 |
786 | 787 |
788 | 789 | 790 |
791 | 792 | 793 | 794 |
795 | Lille 796 | Lille 797 |
798 |
799 | 800 |
801 | 802 |
803 |
804 |

Lille 805 | Armchair, Blue 806 |

807 |
808 |
809 |
810 |
811 | 812 | Insider
Price 813 |
814 | £640 815 |
816 |
817 |
818 |

819 | £699 in high-end retailers 820 | — save 72% 821 |

822 |
823 |
824 | 825 |
826 |
827 |
828 |
829 |
830 | 831 | 872 | 873 |
874 |
875 | 876 | 877 |
878 | 879 | 880 |
881 |
882 | 892 |
893 |
894 | 895 | 943 | 944 | 945 | 946 | 1032 | 1033 | 1034 | 1035 | 1036 | 1037 | 1038 | -------------------------------------------------------------------------------- /test/fixtures/store-product.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Product | Swoon Editions 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 31 | 32 | 33 | 34 | 35 | 107 | 108 | 109 | 276 | 277 |
278 | 279 | 280 | 281 |
282 |
283 |
284 |
285 | 286 | 287 | 288 |
289 |
290 | 291 |
292 |
293 |

Lille Armchair, Blue

294 |
295 |
296 |
297 |
298 | 299 | 305 | 306 | 309 |
310 |
311 | 312 | 318 | 319 | 322 |
323 |
324 | 325 | 331 | 332 | 335 |
336 |
337 | 338 | 344 | 345 | 348 |
349 |
350 | 351 | 357 | 358 | 361 |
362 |
363 | 364 | 370 | 371 | 374 |
375 |
376 | 377 | 383 | 384 | 387 |
388 |
389 |
390 |
391 |
392 |
393 |

Lille Armchair, Blue

394 |
395 |

The perfect French-style desk for a stylish home office.

396 |
397 |
398 |
399 | 400 | Insider
Price 401 |
402 | £640 403 |
404 |
405 |
406 |

407 | £699 in high-end retailers 408 | — save 72% 409 |

410 |
411 |
412 |
413 |
414 |
415 | 428 |
429 |
430 | Add to trolley 431 |
432 |
433 |
434 |
435 |

Edition limited to 12 • only 5 left

436 |
437 |
438 |
439 |
440 |
441 | 442 | 443 |
444 |
445 | 446 | 447 |
448 | 449 | 452 |
453 | 454 | 455 | 480 | 481 | 482 |
483 |
484 |
485 | Also in: 486 | 487 | 488 |
489 |
490 |
491 |
492 | 493 |
494 |
495 |
496 |
497 |
498 |

The Details

499 |
500 |
    501 |
  • W: 130 cm D: 70 cm H: 76 cm
  • 502 |
  • Drawer depth: 40 cm
  • 503 |
  • Handcarved kiln-dried mango wood
  • 504 |
  • Mortise and tenon joinery
  • 505 |
  • Aged limewashed effect finish
  • 506 |
  • Deep 40cm drawers that fit a 15'' laptop
  • 507 |
  • Plate-and-ring antiqued brass handles
  • 508 |
509 |
510 |
511 |
512 |
513 |

This Edition

514 |
515 |

516 | The Adele strikes just the right balance between French elegance and a more 517 | rustic, pared-back style. So… cabriole legs, check; carving on all sides, check; 518 | brass plate-and-ring handles, check. But the limewashed finish gives those 519 | signature French elements a clean feel, keeping it very real and very 520 | contemporary. The Adele is ideal for working from home, and it can also work 521 | as a super-deep dressing table. 522 |

523 |
524 |
525 |
526 |
527 |
528 |

The Maker

529 |
530 |

531 | The workshop is located in the beautiful region of Rajashthan, India. Matthew, 532 | our Quality Guardian, relocated to India and visits the artisans several times a 533 | week. The owner has been in the furniture business for over 40 years, initially 534 | in antique restoration. Impressed with the operation we decided to consolidate 535 | the majority of our French-style production here. 536 |

537 |
538 |
539 | 540 |

Delivery & Returns

541 |
542 |
    543 |
  • In production – estimated delivery in late May
  • 544 |
  • Two-man delivery service to room of choice
  • 545 |
  • £38 per item delivery charge, non-refundable
  • 546 |
  • 7 day no quibble returns with free collection
  • 547 |
548 |
549 |
550 |
551 |
552 | 553 | 554 | 555 | 599 | 600 | 610 |
611 | 614 |
615 |
616 | 617 |
618 | 619 |
620 | Only 10 Left 621 |
622 | 623 |
624 | 625 | 626 | 627 |
628 | Lille 629 | Lille 630 |
631 |
632 | 633 |
634 | 635 |
636 |
637 |

Lille Armchair, Blue

638 |
639 |
640 |
641 |
642 | 643 | Insider
Price 644 |
645 | £640 646 |
647 |
648 |
649 |

650 | £699 in high-end retailers 651 | — save 72% 652 |

653 |
654 |
655 | 656 |
657 |
658 |
659 |
660 | 661 |
662 | 663 | 664 |
665 | 666 | 667 | 668 |
669 | Lille 670 | Lille 671 |
672 |
673 | 674 |
675 | 676 |
677 |
678 |

Lille Armchair, Blue

679 |
680 |
681 |
682 |
683 | 684 | Insider
Price 685 |
686 | £640 687 |
688 |
689 |
690 |

691 | £699 in high-end retailers 692 | — save 72% 693 |

694 |
695 |
696 | 697 |
698 |
699 |
700 |
701 | 702 |
703 | 704 | 705 |
706 | 707 | 708 | 709 |
710 | Lille 711 | Lille 712 |
713 |
714 | 715 |
716 | 717 |
718 |
719 |

Lille Armchair, Blue

720 |
721 |
722 |
723 |
724 | 725 | Insider
Price 726 |
727 | £640 728 |
729 |
730 |
731 |

732 | £699 in high-end retailers 733 | — save 72% 734 |

735 |
736 |
737 | 738 |
739 |
740 |
741 |
742 | 743 |
744 | 745 | 746 |
747 | 748 | 749 | 750 |
751 | Lille 752 | Lille 753 |
754 |
755 | 756 |
757 | 758 |
759 |
760 |

Lille Armchair, Blue

761 |
762 |
763 |
764 |
765 | 766 | Insider
Price 767 |
768 | £640 769 |
770 |
771 |
772 |

773 | £699 in high-end retailers 774 | — save 72% 775 |

776 |
777 |
778 | 779 |
780 |
781 |
782 |
783 |
784 | 785 | 820 | 821 |
822 |
823 | 824 | 825 |
826 | 827 |
828 |
829 |
839 |
840 | 841 | 889 | 890 | 891 | 892 | 975 | 976 | 977 | 978 | 979 | 980 | 981 | 982 | -------------------------------------------------------------------------------- /test/init.js: -------------------------------------------------------------------------------- 1 | var browserSync = require("browser-sync"); 2 | var htmlInjector = require("../index"); 3 | var path = require("path"); 4 | var assert = require("chai").assert; 5 | 6 | describe(".plugin()", function () { 7 | it("should run with BrowserSync `.use()`", function (done) { 8 | browserSync.reset(); 9 | browserSync.use(htmlInjector); 10 | browserSync({logLevel: "silent"}, function (err, bs) { 11 | bs.cleanup(); 12 | done(); 13 | }); 14 | }); 15 | it("should run with BrowserSync as inline plugin", function (done) { 16 | browserSync.reset(); 17 | var modulepath = path.dirname(require.resolve("../index")); 18 | browserSync({plugins: [modulepath], logLevel: "silent"}, function (err, bs) { 19 | assert.equal(bs.getUserPlugins()[0].name, "HTML Injector"); 20 | assert.isTrue(bs.getUserPlugins()[0].active); 21 | bs.cleanup(); 22 | done(); 23 | }); 24 | }); 25 | it("should run with BrowserSync as inline plugin with options", function (done) { 26 | browserSync.reset(); 27 | var modulepath = path.dirname(require.resolve("../index")); 28 | var plugin = { 29 | module: modulepath 30 | }; 31 | browserSync({plugins: [plugin], logLevel: "silent"}, function (err, bs) { 32 | assert.equal(bs.getUserPlugins()[0].name, "HTML Injector"); 33 | assert.isTrue(bs.getUserPlugins()[0].active); 34 | bs.cleanup(); 35 | done(); 36 | }); 37 | }); 38 | it("should run when UI is disabled", function (done) { 39 | browserSync.reset(); 40 | var modulepath = path.dirname(require.resolve("../index")); 41 | var plugin = { 42 | module: modulepath, 43 | options: { 44 | excludedTags: ['H1'] 45 | } 46 | }; 47 | browserSync({ 48 | plugins: [plugin], 49 | logLevel: "silent", 50 | ui: false 51 | }, function (err, bs) { 52 | assert.equal(bs.getUserPlugins()[0].name, "HTML Injector"); 53 | assert.isTrue(bs.getUserPlugins()[0].active); 54 | bs.cleanup(); 55 | done(); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /ui/client.js: -------------------------------------------------------------------------------- 1 | (function (angular) { 2 | 3 | const PLUGIN_NAME = "HTML Injector"; 4 | 5 | angular 6 | .module("BrowserSync") 7 | .directive("htmlInjector", function () { 8 | return { 9 | restrict: "E", 10 | replace: true, 11 | scope: { 12 | "options": "=", 13 | "pluginOpts": "=" 14 | }, 15 | templateUrl: "html-injector/html-injector.directive.html", 16 | controller: ["$scope", "Socket", function ($scope, Socket) { 17 | 18 | var ctrl = this; 19 | 20 | ctrl.restriction = ""; 21 | 22 | ctrl.state = { 23 | classname: "ready" 24 | }; 25 | 26 | ctrl.plugin = $scope.options.userPlugins.filter(function (item) { 27 | return item.name === PLUGIN_NAME; 28 | })[0]; 29 | 30 | ctrl.addRestriction = function (selector) { 31 | 32 | if (selector.length < 3) { 33 | return; 34 | } 35 | 36 | ctrl.restriction = ""; 37 | ctrl.state.classname = "waiting"; 38 | 39 | setTimeout(function () { 40 | 41 | ctrl.state.classname = "success"; 42 | 43 | $scope.$digest(); 44 | 45 | setTimeout(function () { 46 | ctrl.state.classname = "ready"; 47 | $scope.$digest(); 48 | }, 1000); 49 | 50 | Socket.uiEvent({ 51 | namespace: PLUGIN_NAME, 52 | event: "restriction:add", 53 | data: selector 54 | }); 55 | }, 300); 56 | 57 | }; 58 | 59 | ctrl.removeRestriction = function (selector) { 60 | Socket.uiEvent({ 61 | namespace: PLUGIN_NAME, 62 | event: "restriction:remove", 63 | data: selector 64 | }); 65 | }; 66 | 67 | ctrl.update = function (data) { 68 | ctrl.plugin.opts = data.opts; 69 | $scope.$digest(); 70 | }; 71 | 72 | Socket.on("options:update", ctrl.update); 73 | 74 | $scope.$on("$destory", function () { 75 | Socket.off("options:update", ctrl.update); 76 | }); 77 | }], 78 | controllerAs: "ctrl" 79 | }; 80 | }); 81 | 82 | })(angular); 83 | 84 | -------------------------------------------------------------------------------- /ui/html-injector.directive.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | 9 | 15 |
16 | 24 |
25 | 26 | 27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |

No Restrictions.

36 |

This means the entire page will be used for comparison. If this is slow, or causes 37 | errors, narrow down the comparison by using a CSS selectors.

38 |
39 |

Current Restrictions:

40 |
    41 |
  • 42 |

    {{item}}

    43 |
    44 | 49 |
    50 |
  • 51 |
52 |
53 |
54 |
55 |
56 | -------------------------------------------------------------------------------- /ui/html-injector.html: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------