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 }