/ src / style-manager.js
style-manager.js
  1  const { Emitter, Disposable } = require('event-kit');
  2  const crypto = require('crypto');
  3  const fs = require('fs-plus');
  4  const path = require('path');
  5  const postcss = require('postcss');
  6  const selectorParser = require('postcss-selector-parser');
  7  const StylesElement = require('./styles-element');
  8  const DEPRECATED_SYNTAX_SELECTORS = require('./deprecated-syntax-selectors');
  9  
 10  // Extended: A singleton instance of this class available via `atom.styles`,
 11  // which you can use to globally query and observe the set of active style
 12  // sheets. The `StyleManager` doesn't add any style elements to the DOM on its
 13  // own, but is instead subscribed to by individual `<atom-styles>` elements,
 14  // which clone and attach style elements in different contexts.
 15  module.exports = class StyleManager {
 16    constructor() {
 17      this.emitter = new Emitter();
 18      this.styleElements = [];
 19      this.styleElementsBySourcePath = {};
 20      this.deprecationsBySourcePath = {};
 21    }
 22  
 23    initialize({ configDirPath }) {
 24      this.configDirPath = configDirPath;
 25      if (this.configDirPath != null) {
 26        this.cacheDirPath = path.join(
 27          this.configDirPath,
 28          'compile-cache',
 29          'style-manager'
 30        );
 31      }
 32    }
 33  
 34    /*
 35    Section: Event Subscription
 36    */
 37  
 38    // Extended: Invoke `callback` for all current and future style elements.
 39    //
 40    // * `callback` {Function} that is called with style elements.
 41    //   * `styleElement` An `HTMLStyleElement` instance. The `.sheet` property
 42    //     will be null because this element isn't attached to the DOM. If you want
 43    //     to attach this element to the DOM, be sure to clone it first by calling
 44    //     `.cloneNode(true)` on it. The style element will also have the following
 45    //     non-standard properties:
 46    //     * `sourcePath` A {String} containing the path from which the style
 47    //       element was loaded.
 48    //     * `context` A {String} indicating the target context of the style
 49    //       element.
 50    //
 51    // Returns a {Disposable} on which `.dispose()` can be called to cancel the
 52    // subscription.
 53    observeStyleElements(callback) {
 54      for (let styleElement of this.getStyleElements()) {
 55        callback(styleElement);
 56      }
 57  
 58      return this.onDidAddStyleElement(callback);
 59    }
 60  
 61    // Extended: Invoke `callback` when a style element is added.
 62    //
 63    // * `callback` {Function} that is called with style elements.
 64    //   * `styleElement` An `HTMLStyleElement` instance. The `.sheet` property
 65    //     will be null because this element isn't attached to the DOM. If you want
 66    //     to attach this element to the DOM, be sure to clone it first by calling
 67    //     `.cloneNode(true)` on it. The style element will also have the following
 68    //     non-standard properties:
 69    //     * `sourcePath` A {String} containing the path from which the style
 70    //       element was loaded.
 71    //     * `context` A {String} indicating the target context of the style
 72    //       element.
 73    //
 74    // Returns a {Disposable} on which `.dispose()` can be called to cancel the
 75    // subscription.
 76    onDidAddStyleElement(callback) {
 77      return this.emitter.on('did-add-style-element', callback);
 78    }
 79  
 80    // Extended: Invoke `callback` when a style element is removed.
 81    //
 82    // * `callback` {Function} that is called with style elements.
 83    //   * `styleElement` An `HTMLStyleElement` instance.
 84    //
 85    // Returns a {Disposable} on which `.dispose()` can be called to cancel the
 86    // subscription.
 87    onDidRemoveStyleElement(callback) {
 88      return this.emitter.on('did-remove-style-element', callback);
 89    }
 90  
 91    // Extended: Invoke `callback` when an existing style element is updated.
 92    //
 93    // * `callback` {Function} that is called with style elements.
 94    //   * `styleElement` An `HTMLStyleElement` instance. The `.sheet` property
 95    //      will be null because this element isn't attached to the DOM. The style
 96    //      element will also have the following non-standard properties:
 97    //     * `sourcePath` A {String} containing the path from which the style
 98    //       element was loaded.
 99    //     * `context` A {String} indicating the target context of the style
100    //       element.
101    //
102    // Returns a {Disposable} on which `.dispose()` can be called to cancel the
103    // subscription.
104    onDidUpdateStyleElement(callback) {
105      return this.emitter.on('did-update-style-element', callback);
106    }
107  
108    onDidUpdateDeprecations(callback) {
109      return this.emitter.on('did-update-deprecations', callback);
110    }
111  
112    /*
113    Section: Reading Style Elements
114    */
115  
116    // Extended: Get all loaded style elements.
117    getStyleElements() {
118      return this.styleElements.slice();
119    }
120  
121    addStyleSheet(source, params = {}) {
122      let styleElement;
123      let updated;
124      if (
125        params.sourcePath != null &&
126        this.styleElementsBySourcePath[params.sourcePath] != null
127      ) {
128        updated = true;
129        styleElement = this.styleElementsBySourcePath[params.sourcePath];
130      } else {
131        updated = false;
132        styleElement = document.createElement('style');
133        if (params.sourcePath != null) {
134          styleElement.sourcePath = params.sourcePath;
135          styleElement.setAttribute('source-path', params.sourcePath);
136        }
137        if (params.context != null) {
138          styleElement.context = params.context;
139          styleElement.setAttribute('context', params.context);
140        }
141        if (params.priority != null) {
142          styleElement.priority = params.priority;
143          styleElement.setAttribute('priority', params.priority);
144        }
145      }
146  
147      if (params.skipDeprecatedSelectorsTransformation) {
148        styleElement.textContent = source;
149      } else {
150        const transformed = this.upgradeDeprecatedSelectorsForStyleSheet(
151          source,
152          params.context
153        );
154        styleElement.textContent = transformed.source;
155        if (transformed.deprecationMessage) {
156          this.deprecationsBySourcePath[params.sourcePath] = {
157            message: transformed.deprecationMessage
158          };
159          this.emitter.emit('did-update-deprecations');
160        }
161      }
162  
163      if (updated) {
164        this.emitter.emit('did-update-style-element', styleElement);
165      } else {
166        this.addStyleElement(styleElement);
167      }
168      return new Disposable(() => {
169        this.removeStyleElement(styleElement);
170      });
171    }
172  
173    addStyleElement(styleElement) {
174      let insertIndex = this.styleElements.length;
175      if (styleElement.priority != null) {
176        for (let i = 0; i < this.styleElements.length; i++) {
177          const existingElement = this.styleElements[i];
178          if (existingElement.priority > styleElement.priority) {
179            insertIndex = i;
180            break;
181          }
182        }
183      }
184  
185      this.styleElements.splice(insertIndex, 0, styleElement);
186      if (
187        styleElement.sourcePath != null &&
188        this.styleElementsBySourcePath[styleElement.sourcePath] == null
189      ) {
190        this.styleElementsBySourcePath[styleElement.sourcePath] = styleElement;
191      }
192      this.emitter.emit('did-add-style-element', styleElement);
193    }
194  
195    removeStyleElement(styleElement) {
196      const index = this.styleElements.indexOf(styleElement);
197      if (index !== -1) {
198        this.styleElements.splice(index, 1);
199        if (styleElement.sourcePath != null) {
200          delete this.styleElementsBySourcePath[styleElement.sourcePath];
201        }
202        this.emitter.emit('did-remove-style-element', styleElement);
203      }
204    }
205  
206    upgradeDeprecatedSelectorsForStyleSheet(styleSheet, context) {
207      if (this.cacheDirPath != null) {
208        const hash = crypto.createHash('sha1');
209        if (context != null) {
210          hash.update(context);
211        }
212        hash.update(styleSheet);
213        const cacheFilePath = path.join(this.cacheDirPath, hash.digest('hex'));
214        try {
215          return JSON.parse(fs.readFileSync(cacheFilePath));
216        } catch (e) {
217          const transformed = transformDeprecatedShadowDOMSelectors(
218            styleSheet,
219            context
220          );
221          fs.writeFileSync(cacheFilePath, JSON.stringify(transformed));
222          return transformed;
223        }
224      } else {
225        return transformDeprecatedShadowDOMSelectors(styleSheet, context);
226      }
227    }
228  
229    getDeprecations() {
230      return this.deprecationsBySourcePath;
231    }
232  
233    clearDeprecations() {
234      this.deprecationsBySourcePath = {};
235    }
236  
237    getSnapshot() {
238      return this.styleElements.slice();
239    }
240  
241    restoreSnapshot(styleElementsToRestore) {
242      for (let styleElement of this.getStyleElements()) {
243        if (!styleElementsToRestore.includes(styleElement)) {
244          this.removeStyleElement(styleElement);
245        }
246      }
247  
248      const existingStyleElements = this.getStyleElements();
249      for (let styleElement of styleElementsToRestore) {
250        if (!existingStyleElements.includes(styleElement)) {
251          this.addStyleElement(styleElement);
252        }
253      }
254    }
255  
256    buildStylesElement() {
257      var stylesElement = new StylesElement();
258      stylesElement.initialize(this);
259      return stylesElement;
260    }
261  
262    /*
263    Section: Paths
264    */
265  
266    // Extended: Get the path of the user style sheet in `~/.atom`.
267    //
268    // Returns a {String}.
269    getUserStyleSheetPath() {
270      if (this.configDirPath == null) {
271        return '';
272      } else {
273        const stylesheetPath = fs.resolve(
274          path.join(this.configDirPath, 'styles'),
275          ['css', 'less']
276        );
277        if (fs.isFileSync(stylesheetPath)) {
278          return stylesheetPath;
279        } else {
280          return path.join(this.configDirPath, 'styles.less');
281        }
282      }
283    }
284  };
285  
286  function transformDeprecatedShadowDOMSelectors(css, context) {
287    const transformedSelectors = [];
288    let transformedSource;
289    try {
290      transformedSource = postcss.parse(css);
291    } catch (e) {
292      transformedSource = null;
293    }
294  
295    if (transformedSource) {
296      transformedSource.walkRules(rule => {
297        const transformedSelector = selectorParser(selectors => {
298          selectors.each(selector => {
299            const firstNode = selector.nodes[0];
300            if (
301              context === 'atom-text-editor' &&
302              firstNode.type === 'pseudo' &&
303              firstNode.value === ':host'
304            ) {
305              const atomTextEditorElementNode = selectorParser.tag({
306                value: 'atom-text-editor'
307              });
308              firstNode.replaceWith(atomTextEditorElementNode);
309            }
310  
311            let previousNodeIsAtomTextEditor = false;
312            let targetsAtomTextEditorShadow = context === 'atom-text-editor';
313            let previousNode;
314            selector.each(node => {
315              if (targetsAtomTextEditorShadow && node.type === 'class') {
316                if (DEPRECATED_SYNTAX_SELECTORS.has(node.value)) {
317                  node.value = `syntax--${node.value}`;
318                }
319              } else {
320                if (
321                  previousNodeIsAtomTextEditor &&
322                  node.type === 'pseudo' &&
323                  node.value === '::shadow'
324                ) {
325                  node.type = 'className';
326                  node.value = '.editor';
327                  targetsAtomTextEditorShadow = true;
328                }
329              }
330  
331              previousNode = node;
332              if (node.type === 'combinator') {
333                previousNodeIsAtomTextEditor = false;
334              } else if (
335                previousNode.type === 'tag' &&
336                previousNode.value === 'atom-text-editor'
337              ) {
338                previousNodeIsAtomTextEditor = true;
339              }
340            });
341          });
342        }).process(rule.selector, { lossless: true }).result;
343        if (transformedSelector !== rule.selector) {
344          transformedSelectors.push({
345            before: rule.selector,
346            after: transformedSelector
347          });
348          rule.selector = transformedSelector;
349        }
350      });
351      let deprecationMessage;
352      if (transformedSelectors.length > 0) {
353        deprecationMessage =
354          'Starting from Atom v1.13.0, the contents of `atom-text-editor` elements ';
355        deprecationMessage +=
356          'are no longer encapsulated within a shadow DOM boundary. ';
357        deprecationMessage +=
358          'This means you should stop using `:host` and `::shadow` ';
359        deprecationMessage +=
360          'pseudo-selectors, and prepend all your syntax selectors with `syntax--`. ';
361        deprecationMessage +=
362          'To prevent breakage with existing style sheets, Atom will automatically ';
363        deprecationMessage += 'upgrade the following selectors:\n\n';
364        deprecationMessage +=
365          transformedSelectors
366            .map(selector => `* \`${selector.before}\` => \`${selector.after}\``)
367            .join('\n\n') + '\n\n';
368        deprecationMessage +=
369          'Automatic translation of selectors will be removed in a few release cycles to minimize startup time. ';
370        deprecationMessage +=
371          'Please, make sure to upgrade the above selectors as soon as possible.';
372      }
373      return { source: transformedSource.toString(), deprecationMessage };
374    } else {
375      // CSS was malformed so we don't transform it.
376      return { source: css };
377    }
378  }