UNPKG

16.1 kBJavaScriptView Raw
1/* global CSSRule */
2
3/**
4 * Split URL
5 * @param {string} url
6 * @return {object}
7 */
8function splitUrl (url) {
9 let hash = '';
10 let params = '';
11 let index = url.indexOf('#');
12
13 if (index >= 0) {
14 hash = url.slice(index);
15 url = url.slice(0, index);
16 }
17
18 // http://your.domain.com/path/to/combo/??file1.css,file2,css
19 const comboSign = url.indexOf('??');
20
21 if (comboSign >= 0) {
22 if ((comboSign + 1) !== url.lastIndexOf('?')) {
23 index = url.lastIndexOf('?');
24 }
25 } else {
26 index = url.indexOf('?');
27 }
28
29 if (index >= 0) {
30 params = url.slice(index);
31 url = url.slice(0, index);
32 }
33
34 return { url, params, hash };
35};
36
37/**
38 * Get path from URL (remove protocol, host, port)
39 * @param {string} url
40 * @return {string}
41 */
42function pathFromUrl (url) {
43 if (!url) {
44 return '';
45 }
46
47 let path;
48
49 ({ url } = splitUrl(url));
50
51 if (url.indexOf('file://') === 0) {
52 path = url.replace(new RegExp('^file://(localhost)?'), '');
53 } else {
54 // http : // hostname :8080 /
55 path = url.replace(new RegExp('^([^:]+:)?//([^:/]+)(:\\d*)?/'), '/');
56 }
57
58 // decodeURI has special handling of stuff like semicolons, so use decodeURIComponent
59 return decodeURIComponent(path);
60}
61
62/**
63 * Get number of matching path segments
64 * @param {string} left
65 * @param {string} right
66 * @return {int}
67 */
68function numberOfMatchingSegments (left, right) {
69 // get rid of leading slashes and normalize to lower case
70 left = left.replace(/^\/+/, '').toLowerCase();
71 right = right.replace(/^\/+/, '').toLowerCase();
72
73 if (left === right) {
74 return 10000;
75 }
76
77 const comps1 = left.split(/\/|\\/).reverse();
78 const comps2 = right.split(/\/|\\/).reverse();
79 const len = Math.min(comps1.length, comps2.length);
80
81 let eqCount = 0;
82
83 while ((eqCount < len) && (comps1[eqCount] === comps2[eqCount])) {
84 ++eqCount;
85 }
86
87 return eqCount;
88}
89
90/**
91 * Pick best matching path from a collection
92 * @param {string} path Path to match
93 * @param {array} objects Collection of paths
94 * @param {function} [pathFunc] Transform applied to each item in collection
95 * @return {object}
96 */
97function pickBestMatch (path, objects, pathFunc = s => s) {
98 let score;
99 let bestMatch = { score: 0 };
100
101 for (const object of objects) {
102 score = numberOfMatchingSegments(path, pathFunc(object));
103
104 if (score > bestMatch.score) {
105 bestMatch = { object, score };
106 }
107 }
108
109 if (bestMatch.score === 0) {
110 return null;
111 }
112
113 return bestMatch;
114}
115
116/**
117 * Test if paths match
118 * @param {string} left
119 * @param {string} right
120 * @return {bool}
121 */
122function pathsMatch (left, right) {
123 return numberOfMatchingSegments(left, right) > 0;
124}
125
126const IMAGE_STYLES = [
127 { selector: 'background', styleNames: ['backgroundImage'] },
128 { selector: 'border', styleNames: ['borderImage', 'webkitBorderImage', 'MozBorderImage'] }
129];
130
131const DEFAULT_OPTIONS = {
132 stylesheetReloadTimeout: 15000
133};
134
135class Reloader {
136 constructor (window, console, Timer) {
137 this.window = window;
138 this.console = console;
139 this.Timer = Timer;
140 this.document = this.window.document;
141 this.importCacheWaitPeriod = 200;
142 this.plugins = [];
143 }
144
145 addPlugin (plugin) {
146 return this.plugins.push(plugin);
147 }
148
149 analyze (callback) {
150 }
151
152 reload (path, options = {}) {
153 this.options = {
154 ...DEFAULT_OPTIONS,
155 ...options
156 }; // avoid passing it through all the funcs
157
158 if (this.options.pluginOrder && this.options.pluginOrder.length) {
159 this.runPluginsByOrder(path, options);
160 return;
161 }
162
163 for (const plugin of Array.from(this.plugins)) {
164 if (plugin.reload && plugin.reload(path, options)) {
165 return;
166 }
167 }
168
169 if (options.liveCSS && path.match(/\.css(?:\.map)?$/i)) {
170 if (this.reloadStylesheet(path)) {
171 return;
172 }
173 }
174
175 if (options.liveImg && path.match(/\.(jpe?g|png|gif)$/i)) {
176 this.reloadImages(path);
177 return;
178 }
179
180 if (options.isChromeExtension) {
181 this.reloadChromeExtension();
182 return;
183 }
184
185 return this.reloadPage();
186 }
187
188 runPluginsByOrder (path, options) {
189 options.pluginOrder.some(pluginId => {
190 if (pluginId === 'css') {
191 if (options.liveCSS && path.match(/\.css(?:\.map)?$/i)) {
192 if (this.reloadStylesheet(path)) {
193 return true;
194 }
195 }
196 }
197
198 if (pluginId === 'img') {
199 if (options.liveImg && path.match(/\.(jpe?g|png|gif)$/i)) {
200 this.reloadImages(path);
201 return true;
202 }
203 }
204
205 if (pluginId === 'extension') {
206 if (options.isChromeExtension) {
207 this.reloadChromeExtension();
208 return true;
209 }
210 }
211
212 if (pluginId === 'others') {
213 this.reloadPage();
214 return true;
215 }
216
217 if (pluginId === 'external') {
218 return this.plugins.some(plugin => {
219 if (plugin.reload && plugin.reload(path, options)) {
220 return true;
221 }
222 });
223 }
224
225 return this.plugins.filter(
226 plugin => plugin.constructor.identifier === pluginId
227 )
228 .some(plugin => {
229 if (plugin.reload && plugin.reload(path, options)) {
230 return true;
231 }
232 });
233 });
234 }
235
236 reloadPage () {
237 return this.window.document.location.reload();
238 }
239
240 reloadChromeExtension () {
241 return this.window.chrome.runtime.reload();
242 }
243
244 reloadImages (path) {
245 let img;
246 const expando = this.generateUniqueString();
247
248 for (img of Array.from(this.document.images)) {
249 if (pathsMatch(path, pathFromUrl(img.src))) {
250 img.src = this.generateCacheBustUrl(img.src, expando);
251 }
252 }
253
254 if (this.document.querySelectorAll) {
255 for (const { selector, styleNames } of IMAGE_STYLES) {
256 for (img of Array.from(this.document.querySelectorAll(`[style*=${selector}]`))) {
257 this.reloadStyleImages(img.style, styleNames, path, expando);
258 }
259 }
260 }
261
262 if (this.document.styleSheets) {
263 return Array.from(this.document.styleSheets).map(styleSheet =>
264 this.reloadStylesheetImages(styleSheet, path, expando)
265 );
266 }
267 }
268
269 reloadStylesheetImages (styleSheet, path, expando) {
270 let rules;
271
272 try {
273 rules = (styleSheet || {}).cssRules;
274 } catch (e) {}
275
276 if (!rules) {
277 return;
278 }
279
280 for (const rule of Array.from(rules)) {
281 switch (rule.type) {
282 case CSSRule.IMPORT_RULE:
283 this.reloadStylesheetImages(rule.styleSheet, path, expando);
284 break;
285 case CSSRule.STYLE_RULE:
286 for (const { styleNames } of IMAGE_STYLES) {
287 this.reloadStyleImages(rule.style, styleNames, path, expando);
288 }
289 break;
290 case CSSRule.MEDIA_RULE:
291 this.reloadStylesheetImages(rule, path, expando);
292 break;
293 }
294 }
295 }
296
297 reloadStyleImages (style, styleNames, path, expando) {
298 for (const styleName of styleNames) {
299 const value = style[styleName];
300
301 if (typeof value === 'string') {
302 const newValue = value.replace(new RegExp('\\burl\\s*\\(([^)]*)\\)'), (match, src) => {
303 if (pathsMatch(path, pathFromUrl(src))) {
304 return `url(${this.generateCacheBustUrl(src, expando)})`;
305 }
306
307 return match;
308 });
309
310 if (newValue !== value) {
311 style[styleName] = newValue;
312 }
313 }
314 }
315 }
316
317 reloadStylesheet (path) {
318 const options = this.options || DEFAULT_OPTIONS;
319
320 // has to be a real array, because DOMNodeList will be modified
321 let style;
322 let link;
323
324 const links = ((() => {
325 const result = [];
326
327 for (link of Array.from(this.document.getElementsByTagName('link'))) {
328 if (link.rel.match(/^stylesheet$/i) && !link.__LiveReload_pendingRemoval) {
329 result.push(link);
330 }
331 }
332
333 return result;
334 })());
335
336 // find all imported stylesheets
337 const imported = [];
338
339 for (style of Array.from(this.document.getElementsByTagName('style'))) {
340 if (style.sheet) {
341 this.collectImportedStylesheets(style, style.sheet, imported);
342 }
343 }
344
345 for (link of Array.from(links)) {
346 this.collectImportedStylesheets(link, link.sheet, imported);
347 }
348
349 // handle prefixfree
350 if (this.window.StyleFix && this.document.querySelectorAll) {
351 for (style of Array.from(this.document.querySelectorAll('style[data-href]'))) {
352 links.push(style);
353 }
354 }
355
356 this.console.log(`LiveReload found ${links.length} LINKed stylesheets, ${imported.length} @imported stylesheets`);
357
358 const match = pickBestMatch(
359 path,
360 links.concat(imported),
361 link => pathFromUrl(this.linkHref(link))
362 );
363
364 if (match) {
365 if (match.object.rule) {
366 this.console.log(`LiveReload is reloading imported stylesheet: ${match.object.href}`);
367 this.reattachImportedRule(match.object);
368 } else {
369 this.console.log(`LiveReload is reloading stylesheet: ${this.linkHref(match.object)}`);
370 this.reattachStylesheetLink(match.object);
371 }
372 } else {
373 if (options.reloadMissingCSS) {
374 this.console.log(`LiveReload will reload all stylesheets because path '${path}' did not match any specific one. \
375To disable this behavior, set 'options.reloadMissingCSS' to 'false'.`
376 );
377
378 for (link of Array.from(links)) {
379 this.reattachStylesheetLink(link);
380 }
381 } else {
382 this.console.log(`LiveReload will not reload path '${path}' because the stylesheet was not found on the page \
383and 'options.reloadMissingCSS' was set to 'false'.`
384 );
385 }
386 }
387
388 return true;
389 }
390
391 collectImportedStylesheets (link, styleSheet, result) {
392 // in WebKit, styleSheet.cssRules is null for inaccessible stylesheets;
393 // Firefox/Opera may throw exceptions
394 let rules;
395
396 try {
397 rules = (styleSheet || {}).cssRules;
398 } catch (e) {}
399
400 if (rules && rules.length) {
401 for (let index = 0; index < rules.length; index++) {
402 const rule = rules[index];
403
404 switch (rule.type) {
405 case CSSRule.CHARSET_RULE:
406 continue; // do nothing
407 case CSSRule.IMPORT_RULE:
408 result.push({ link, rule, index, href: rule.href });
409 this.collectImportedStylesheets(link, rule.styleSheet, result);
410 break;
411 default:
412 break; // import rules can only be preceded by charset rules
413 }
414 }
415 }
416 }
417
418 waitUntilCssLoads (clone, func) {
419 const options = this.options || DEFAULT_OPTIONS;
420 let callbackExecuted = false;
421
422 const executeCallback = () => {
423 if (callbackExecuted) {
424 return;
425 }
426
427 callbackExecuted = true;
428
429 return func();
430 };
431
432 // supported by Chrome 19+, Safari 5.2+, Firefox 9+, Opera 9+, IE6+
433 // http://www.zachleat.com/web/load-css-dynamically/
434 // http://pieisgood.org/test/script-link-events/
435 clone.onload = () => {
436 this.console.log('LiveReload: the new stylesheet has finished loading');
437 this.knownToSupportCssOnLoad = true;
438
439 return executeCallback();
440 };
441
442 if (!this.knownToSupportCssOnLoad) {
443 // polling
444 let poll;
445 (poll = () => {
446 if (clone.sheet) {
447 this.console.log('LiveReload is polling until the new CSS finishes loading...');
448
449 return executeCallback();
450 }
451
452 return this.Timer.start(50, poll);
453 })();
454 }
455
456 // fail safe
457 return this.Timer.start(options.stylesheetReloadTimeout, executeCallback);
458 }
459
460 linkHref (link) {
461 // prefixfree uses data-href when it turns LINK into STYLE
462 return link.href || (link.getAttribute && link.getAttribute('data-href'));
463 }
464
465 reattachStylesheetLink (link) {
466 // ignore LINKs that will be removed by LR soon
467 let clone;
468
469 if (link.__LiveReload_pendingRemoval) {
470 return;
471 }
472
473 link.__LiveReload_pendingRemoval = true;
474
475 if (link.tagName === 'STYLE') {
476 // prefixfree
477 clone = this.document.createElement('link');
478 clone.rel = 'stylesheet';
479 clone.media = link.media;
480 clone.disabled = link.disabled;
481 } else {
482 clone = link.cloneNode(false);
483 }
484
485 clone.href = this.generateCacheBustUrl(this.linkHref(link));
486
487 // insert the new LINK before the old one
488 const parent = link.parentNode;
489
490 if (parent.lastChild === link) {
491 parent.appendChild(clone);
492 } else {
493 parent.insertBefore(clone, link.nextSibling);
494 }
495
496 return this.waitUntilCssLoads(clone, () => {
497 let additionalWaitingTime;
498
499 if (/AppleWebKit/.test(this.window.navigator.userAgent)) {
500 additionalWaitingTime = 5;
501 } else {
502 additionalWaitingTime = 200;
503 }
504
505 return this.Timer.start(additionalWaitingTime, () => {
506 if (!link.parentNode) {
507 return;
508 }
509
510 link.parentNode.removeChild(link);
511 clone.onreadystatechange = null;
512
513 return (this.window.StyleFix ? this.window.StyleFix.link(clone) : undefined);
514 });
515 }); // prefixfree
516 }
517
518 reattachImportedRule ({ rule, index, link }) {
519 const parent = rule.parentStyleSheet;
520 const href = this.generateCacheBustUrl(rule.href);
521 const media = rule.media.length ? [].join.call(rule.media, ', ') : '';
522 const newRule = `@import url("${href}") ${media};`;
523
524 // used to detect if reattachImportedRule has been called again on the same rule
525 rule.__LiveReload_newHref = href;
526
527 // WORKAROUND FOR WEBKIT BUG: WebKit resets all styles if we add @import'ed
528 // stylesheet that hasn't been cached yet. Workaround is to pre-cache the
529 // stylesheet by temporarily adding it as a LINK tag.
530 const tempLink = this.document.createElement('link');
531 tempLink.rel = 'stylesheet';
532 tempLink.href = href;
533 tempLink.__LiveReload_pendingRemoval = true; // exclude from path matching
534
535 if (link.parentNode) {
536 link.parentNode.insertBefore(tempLink, link);
537 }
538
539 // wait for it to load
540 return this.Timer.start(this.importCacheWaitPeriod, () => {
541 if (tempLink.parentNode) {
542 tempLink.parentNode.removeChild(tempLink);
543 }
544
545 // if another reattachImportedRule call is in progress, abandon this one
546 if (rule.__LiveReload_newHref !== href) {
547 return;
548 }
549
550 parent.insertRule(newRule, index);
551 parent.deleteRule(index + 1);
552
553 // save the new rule, so that we can detect another reattachImportedRule call
554 rule = parent.cssRules[index];
555 rule.__LiveReload_newHref = href;
556
557 // repeat again for good measure
558 return this.Timer.start(this.importCacheWaitPeriod, () => {
559 // if another reattachImportedRule call is in progress, abandon this one
560 if (rule.__LiveReload_newHref !== href) {
561 return;
562 }
563
564 parent.insertRule(newRule, index);
565
566 return parent.deleteRule(index + 1);
567 });
568 });
569 }
570
571 generateUniqueString () {
572 return `livereload=${Date.now()}`;
573 }
574
575 generateCacheBustUrl (url, expando) {
576 const options = this.options || DEFAULT_OPTIONS;
577 let hash, oldParams;
578
579 if (!expando) {
580 expando = this.generateUniqueString();
581 }
582
583 ({ url, hash, params: oldParams } = splitUrl(url));
584
585 if (options.overrideURL) {
586 if (url.indexOf(options.serverURL) < 0) {
587 const originalUrl = url;
588
589 url = options.serverURL + options.overrideURL + '?url=' + encodeURIComponent(url);
590
591 this.console.log(`LiveReload is overriding source URL ${originalUrl} with ${url}`);
592 }
593 }
594
595 let params = oldParams.replace(/(\?|&)livereload=(\d+)/, (match, sep) => `${sep}${expando}`);
596
597 if (params === oldParams) {
598 if (oldParams.length === 0) {
599 params = `?${expando}`;
600 } else {
601 params = `${oldParams}&${expando}`;
602 }
603 }
604
605 return url + params + hash;
606 }
607};
608
609exports.splitUrl = splitUrl;
610exports.pathFromUrl = pathFromUrl;
611exports.numberOfMatchingSegments = numberOfMatchingSegments;
612exports.pickBestMatch = pickBestMatch;
613exports.pathsMatch = pathsMatch;
614exports.Reloader = Reloader;