├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── RenderWhitespaceOnGithub.user.js ├── firefox-manifest.sed ├── icon128.png ├── manifest.json ├── options.html ├── options.js └── store-assets ├── icon128-16pad.png ├── icon128.xcf ├── icon64.png ├── icon96.xcf └── screenshot-render.png /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | tmp/ 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | v1.3.12 2 | 3 | * Update handling of the first space in diffs. 4 | * Highlight code in discussions. 5 | 6 | v1.3.11 7 | 8 | * Fixes horizontal scroll in file view (bug introduced in v1.3.10). 9 | * Fixes "Copyable whitespace indicators" setting. 10 | 11 | v1.3.10 12 | 13 | * Performance improvements. 14 | [#8](https://github.com/glebm/render-whitespace-on-github/issues/8) 15 | 16 | v1.3.9 17 | 18 | * Compatibility with "Refined GitHub" extension's "remove-diff-signs" feature. 19 | 20 | v1.3.8 21 | 22 | * Compatibility with extensions that override tab size in Firefox. 23 | [#2](https://github.com/glebm/render-whitespace-on-github/issues/2) 24 | 25 | v1.3.7 26 | 27 | * Compatibility with extensions that override tab size. 28 | [#2](https://github.com/glebm/render-whitespace-on-github/issues/2) 29 | 30 | v1.3.6 31 | 32 | * Fixes mobile rendering issues. 33 | [#1](https://github.com/glebm/render-whitespace-on-github/issues/1) 34 | 35 | v1.3.5 36 | 37 | * Support mobile version. 38 | * Support diff expanders. 39 | 40 | v1.3.4 41 | 42 | An options screen. 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Gleb Mazovetskiy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | FILES=manifest.json RenderWhitespaceOnGithub.user.js options.html options.js icon128.png 2 | .PHONY: dist 3 | dist: dist/RenderWhitespaceOnGithub-chrome.zip dist/RenderWhitespaceOnGithub-firefox.zip 4 | 5 | dist/RenderWhitespaceOnGithub-chrome.zip: $(FILES) 6 | @mkdir -p dist 7 | zip dist/RenderWhitespaceOnGithub-chrome.zip \ 8 | --filesync --latest-time -- $(FILES) 9 | 10 | dist/RenderWhitespaceOnGithub-firefox.zip: $(addprefix tmp/firefox/, $(FILES)) 11 | @mkdir -p dist 12 | cd tmp/firefox/ && zip ../../dist/RenderWhitespaceOnGithub-firefox.zip \ 13 | --filesync --latest-time -- $(FILES) 14 | 15 | tmp/firefox/%: % 16 | @mkdir -p tmp/firefox 17 | cp -p $* tmp/firefox/ 18 | 19 | tmp/firefox/manifest.json: manifest.json firefox-manifest.sed 20 | @mkdir -p tmp/firefox 21 | sed -f firefox-manifest.sed manifest.json > tmp/firefox/manifest.json 22 | touch -r manifest.json tmp/firefox/manifest.json 23 | 24 | .PHONY: clean 25 | clean: 26 | rm -rf dist/ tmp/ 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Render Whitespace on GitHub - a browser extension 2 | 3 | Are they tabs? Are they spaces? How many? 4 | Never wonder again! 5 | 6 | ![A screenshot of a code snippet with the whitespace rendered](store-assets/screenshot-render.png) 7 | 8 | ## Installation 9 | 10 | * Chrome WebStore: https://chrome.google.com/webstore/detail/ifdbipohclgnokjgpejhnbjdlgjkkhom 11 | * Firefox Add-on: https://addons.mozilla.org/en-GB/firefox/addon/render-whitespace-on-github 12 | 13 | Alternatively, you can install this as a userscript. 14 | 15 | To install as a userscript in Chrome, save [the JavaScript file][RenderWhitespaceOnGithub.user.js] and drag it onto the chrome://extensions page. 16 | 17 | If you use ViolentMonkey, TamperMonkey, or GreaseMonkey, install from one of these links: 18 | 19 | * https://openuserjs.org/scripts/glebm/Render_Whitespace_on_GitHub 20 | * https://greasyfork.org/en/scripts/32986-render-whitespace-on-github 21 | 22 | [RenderWhitespaceOnGithub.user.js]: https://github.com/glebm/render-whitespace-on-github/raw/master/RenderWhitespaceOnGithub.user.js 23 | -------------------------------------------------------------------------------- /RenderWhitespaceOnGithub.user.js: -------------------------------------------------------------------------------- 1 | /** 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2017-2018 Gleb Mazovetskiy 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | **/ 23 | // ==UserScript== 24 | // @id RenderWhitespace 25 | // @name Render Whitespace on GitHub 26 | // @description Renders spaces as · and tabs as → in all the code on GitHub. 27 | // @namespace https://github.com/glebm 28 | // @version 1.3.12 29 | // @author Gleb Mazovetskiy 30 | // @license MIT 31 | // @domain github.com 32 | // @domain gist.github.com 33 | // @match https://gist.github.com/* 34 | // @match https://github.com/* 35 | // @homepageUrl https://github.com/glebm/render-whitespace-on-github 36 | // @run-at document-end 37 | // @contributionURL https://etherchain.org/account/0x962644db6d8735446c1af84a2c1f16143f780184 38 | // ==/UserScript== 39 | 40 | // Settings 41 | let settings; 42 | const DEFAULTS = { 43 | whitespaceOpacity: 0.4, 44 | copyableWhitespace: false, 45 | space: '·', 46 | tab: '→', 47 | }; 48 | 49 | // Constants 50 | const WS_CLASS = 'glebm-ws'; 51 | const ROOT_SELECTOR = 'table[data-tab-size],div[data-tab-size],table.diff-table'; 52 | const NODE_FILTER = { 53 | acceptNode(node) { 54 | let parent = node.parentNode; 55 | if (parent.classList.contains(WS_CLASS)) return NodeFilter.FILTER_SKIP; 56 | while (!parent.matches(ROOT_SELECTOR)) { 57 | if ( /* mobile code */ 58 | parent.classList.contains('js-file-line') || 59 | /* desktop code, diff; mobile diff */ 60 | parent.classList.contains('blob-code-inner')) { 61 | return !(parent.firstChild === node && node.nodeValue === ' ') ? 62 | NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; 63 | } 64 | parent = parent.parentNode; 65 | } 66 | return NodeFilter.FILTER_SKIP; 67 | } 68 | }; 69 | 70 | function main() { 71 | const styleNode = document.createElement('style'); 72 | styleNode.textContent = settings.copyableWhitespaceIndicators ? 73 | `.${WS_CLASS} { opacity: ${settings.whitespaceOpacity}; }` : 74 | `.${WS_CLASS}::before { 75 | opacity: ${settings.whitespaceOpacity}; 76 | position: absolute; 77 | text-indent: 0; 78 | } 79 | /* desktop non-diff, mobile diff */ 80 | .blob-code .${WS_CLASS}::before { 81 | line-height: 20px; 82 | } 83 | /* horizontal scroll */ 84 | .blob-file-content pre, 85 | .diff-view .file .highlight, 86 | .blob-wrapper { 87 | position: relative; 88 | }`; 89 | document.head.appendChild(styleNode); 90 | 91 | // github/legacy/pages/diffs/expander 92 | const diffTableObserver = new MutationObserver((records) => { 93 | for (const record of records) { 94 | showWhitespaceIn(record.target.parentElement); 95 | } 96 | }); 97 | const initDiffExpanders = () => { 98 | for (const node of document.querySelectorAll('.diff-table > tbody')) { 99 | diffTableObserver.observe(node, { childList: true }); 100 | } 101 | }; 102 | document.addEventListener('pjax:success', () => { 103 | diffTableObserver.disconnect(); 104 | }); 105 | 106 | // https://github.com/github/include-fragment-element 107 | const registeredFragments = new WeakSet(); 108 | const onFragmentLoadEnd = (node) => { 109 | return () => { 110 | setTimeout(() => { 111 | for (const root of node.querySelectorAll(ROOT_SELECTOR)) { 112 | showWhitespaceIn(root); 113 | } 114 | }, 0); 115 | }; 116 | } 117 | const initFragments = () => { 118 | for (const node of document.querySelectorAll('include-fragment')) { 119 | if (registeredFragments.has(node)) continue; 120 | registeredFragments.add(node); 121 | node.addEventListener('loadend', onFragmentLoadEnd(node.parentElement)); 122 | } 123 | } 124 | 125 | const initDOM = () => { 126 | for (const root of document.querySelectorAll(ROOT_SELECTOR)) { 127 | showWhitespaceIn(root); 128 | } 129 | initDiffExpanders(); 130 | initFragments(); 131 | }; 132 | document.addEventListener('pjax:success', initDOM); 133 | initDOM(); 134 | } 135 | 136 | function showWhitespaceIn(root) { 137 | const rootStyle = window.getComputedStyle(root); 138 | const tab = settings.tab.padEnd( 139 | +(rootStyle['tab-size'] || rootStyle['-moz-tab-size'] || root.dataset.tabSize)); 140 | const treeWalker = 141 | document.createTreeWalker(root, NodeFilter.SHOW_TEXT, NODE_FILTER); 142 | const nodes = []; 143 | while (treeWalker.nextNode()) nodes.push(treeWalker.currentNode); 144 | 145 | const isDiff = /* desktop */ root.classList.contains('diff-table') || 146 | /* mobile */ root.classList.contains('file-diff'); 147 | for (const node of nodes) replaceWhitespace(node, tab, settings.space, isDiff); 148 | } 149 | 150 | function isSpace(char) { 151 | return /* desktop */ char === ' ' || 152 | /* mobile */ char === '\xa0' /*   */; 153 | } 154 | 155 | function replaceWhitespace(node, tab, space, isDiff) { 156 | let originalText = node.nodeValue; 157 | const parent = node.parentNode; 158 | const ignoreFirstSpace = isDiff && 159 | isSpace(originalText.charAt(0)) && 160 | parent.firstChild === node && 161 | parent.classList.contains('blob-code-inner') && 162 | parent.parentNode.classList.contains('blob-expanded') && 163 | // "Refined Github" extension removes the extra first space: 164 | // https://github.com/sindresorhus/refined-github/blob/34f713a331bf7dbf65c2082d3d2c667e06f22021/src/features/remove-diff-signs.js#L20 165 | !parent.matches('.refined-github-diff-signs *'); 166 | if (ignoreFirstSpace) { 167 | if (isSpace(originalText)) return; 168 | originalText = originalText.slice(1); 169 | parent.insertBefore(document.createTextNode(' '), node); 170 | } 171 | const tabParts = originalText.split('\t'); 172 | const tabSpaceParts = tabParts.map(s => s.split(/[ \xa0]/)); 173 | if (!ignoreFirstSpace && tabSpaceParts.length === 1 && 174 | tabSpaceParts[0].length === 1) return; 175 | const insert = (newNode) => { 176 | parent.insertBefore(newNode, node); 177 | }; 178 | insertParts(tabSpaceParts, 179 | spaceParts => spaceParts.length === 1 && spaceParts[0] === '', 180 | n => insert(createWhitespaceNode('t', '\t', tab, n)), 181 | spaceParts => 182 | insertParts(spaceParts, 183 | text => text === '', 184 | n => insert(createWhitespaceNode('s', ' ', space, n)), 185 | text => insert(document.createTextNode(text)))); 186 | parent.removeChild(node); 187 | } 188 | 189 | 190 | var WS_ADDED_STYLES = new Set(); 191 | function createWhitespaceNode(type, originalText, text, n) { 192 | const node = document.createElement('span'); 193 | node.classList.add(WS_CLASS); 194 | if (settings.copyableWhitespace) { 195 | node.textContent = text.repeat(n); 196 | } else { 197 | const className = `${type}-${n}`; 198 | if (!WS_ADDED_STYLES.has(className)) { 199 | const styleNode = document.createElement('style'); 200 | styleNode.textContent = 201 | `.${WS_CLASS}-${className}::before { content: '${text.repeat(n)}'; }`; 202 | document.head.appendChild(styleNode); 203 | WS_ADDED_STYLES.add(className); 204 | } 205 | node.classList.add(`${WS_CLASS}-${className}`); 206 | node.textContent = originalText.repeat(n); 207 | } 208 | return node; 209 | } 210 | 211 | function insertParts(parts, isConsecutiveFn, addInterFn, addPartFn) { 212 | const n = parts.length; 213 | parts.reduce((consecutive, part, i) => { 214 | const isConsecutive = isConsecutiveFn(part); 215 | if (isConsecutive && i !== n - 1) return consecutive + 1; 216 | if (consecutive > 0) addInterFn(consecutive); 217 | if (!isConsecutive) addPartFn(part); 218 | return 1; 219 | }, 0); 220 | } 221 | 222 | function onSettingsLoaded(result) { 223 | settings = result; 224 | main(); 225 | } 226 | 227 | if (typeof browser !== 'undefined' && typeof browser.storage !== 'undefined') { 228 | browser.storage.sync.get(DEFAULTS).then(onSettingsLoaded); 229 | } else if (typeof chrome !== 'undefined' && typeof chrome.storage !== 'undefined') { 230 | chrome.storage.sync.get(DEFAULTS, onSettingsLoaded); 231 | } else { 232 | onSettingsLoaded(DEFAULTS); 233 | } 234 | -------------------------------------------------------------------------------- /firefox-manifest.sed: -------------------------------------------------------------------------------- 1 | s/chrome_style/browser_style/ 2 | $ s/}$/,"applications":{"gecko":{"id":"{fe77d2f3-588a-4b1f-a279-5a87dc88eb2c}","strict_min_version":"48.0"}}}/ 3 | -------------------------------------------------------------------------------- /icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glebm/render-whitespace-on-github/c4985c7544f37e70c15e2400ee3b82445d2b61ed/icon128.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Render Whitespace on GitHub", 4 | "version": "1.3.12", 5 | "author": "Gleb Mazovetskiy", 6 | "content_scripts": [ 7 | { 8 | "js": [ 9 | "RenderWhitespaceOnGithub.user.js" 10 | ], 11 | "matches": [ 12 | "https://gist.github.com/*", 13 | "https://github.com/*" 14 | ], 15 | "run_at": "document_end" 16 | } 17 | ], 18 | "options_ui": { 19 | "page": "options.html", 20 | "chrome_style": true 21 | }, 22 | "permissions": [ 23 | "storage" 24 | ], 25 | "homepage_url": "https://github.com/glebm/render-whitespace-on-github", 26 | "description": "Renders spaces as · and tabs as → in all the code on GitHub.", 27 | "icons": { 28 | "128": "icon128.png" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Render Whitespace on GitHub Extension Options 6 | 40 | 41 | 42 |

Whitespace Indicators

43 |
44 |
45 |
46 |

47 | 48 |

49 |

50 | 51 | 52 |

53 |
54 |
55 |

Preview

56 |
text
57 |
58 |
59 |
60 | Advanced options 61 |

62 | 63 |

64 |
65 |

66 | 67 | 68 | 69 |

70 |
71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /options.js: -------------------------------------------------------------------------------- 1 | const DEFAULTS = { 2 | whitespaceOpacity: 0.4, 3 | copyableWhitespace: false, 4 | space: '·', 5 | tab: '→', 6 | }; 7 | const ui = {}; 8 | function init() { 9 | ui.form = document.querySelector('form'); 10 | ui.whitespaceOpacity = ui.form.querySelector('[name="whitespace-opacity"]'); 11 | ui.copyableWhitespace = ui.form.querySelector('[name="copyable-whitespace"]'); 12 | ui.space = ui.form.querySelector('[name="space"]'); 13 | ui.tab = ui.form.querySelector('[name="tab"]'); 14 | ui.submitButton = ui.form.querySelector('[type="submit"]'); 15 | ui.whitespaceIndicatorInputs = Array.from(ui.form.querySelectorAll('.whitespace-indicator-input')); 16 | ui.status = document.querySelector('#status'); 17 | ui.preview = document.querySelector('#preview'); 18 | ui.restoreDefaultsBtn = document.querySelector('#restore-defaults'); 19 | 20 | browser.storage.sync.get(DEFAULTS).then(restoreOptions, onError); 21 | ui.whitespaceOpacity.addEventListener('input', updatePreview); 22 | for (const input of ui.whitespaceIndicatorInputs) { 23 | input.addEventListener('focus', function(evt) { 24 | evt.target.select(); 25 | }); 26 | input.addEventListener('input', updatePreview); 27 | } 28 | ui.form.addEventListener('submit', function(e) { 29 | e.preventDefault(); 30 | saveOptions(); 31 | }); 32 | for (const eventName of ['input', 'change']) { 33 | ui.form.addEventListener(eventName, function() { 34 | ui.status.innerText = 'Unsaved changes'; 35 | ui.restoreDefaultsBtn.disabled = areSettingsEqualToDefault(); 36 | }); 37 | } 38 | ui.restoreDefaultsBtn.addEventListener('click', function() { 39 | restoreOptions(DEFAULTS); 40 | ui.status.innerText = 'Unsaved changes'; 41 | ui.restoreDefaultsBtn.disabled = true; 42 | }); 43 | } 44 | document.addEventListener('DOMContentLoaded', init); 45 | 46 | function saveOptions() { 47 | ui.submitButton.disabled = true; 48 | ui.status.innerText = 'Saving...'; 49 | browser.storage.sync.set(getFormValues()).then(function() { 50 | ui.submitButton.disabled = false; 51 | ui.status.innerText = 'Saved'; 52 | }, function(error) { 53 | ui.submitButton.disabled = false; 54 | ui.status.innerText = "Error: ${error}"; 55 | onError(error); 56 | }); 57 | } 58 | 59 | function restoreOptions({whitespaceOpacity, copyableWhitespace, space, tab}) { 60 | ui.whitespaceOpacity.value = whitespaceOpacity; 61 | ui.copyableWhitespace.checked = copyableWhitespace; 62 | ui.space.value = space; 63 | ui.tab.value = tab; 64 | ui.restoreDefaultsBtn.disabled = areSettingsEqualToDefault(); 65 | updatePreview(); 66 | } 67 | 68 | function getFormValues() { 69 | return { 70 | whitespaceOpacity: +ui.whitespaceOpacity.value, 71 | copyableWhitespace: ui.copyableWhitespace.checked, 72 | space: ui.space.value, 73 | tab: ui.tab.value 74 | }; 75 | } 76 | 77 | function updatePreview() { 78 | ui.preview.innerText = `${ui.tab.value.padEnd(4)}${ui.space.value.repeat(3)}`; 79 | ui.preview.style.opacity = ui.whitespaceOpacity.value; 80 | } 81 | 82 | function areSettingsEqualToDefault() { 83 | const formValues = getFormValues(); 84 | return Object.keys(DEFAULTS).every(k => DEFAULTS[k] === formValues[k]) 85 | } 86 | 87 | function onError(error) { 88 | console.log(error); 89 | } 90 | 91 | const browser = typeof window.browser !== 'undefined' ? window.browser : { 92 | storage: { 93 | sync: { 94 | get(...args) { 95 | return chromeCallbackToPromise(function(...xs) { 96 | chrome.storage.sync.get(...xs); 97 | }, ...args); 98 | }, 99 | set(...args) { 100 | return chromeCallbackToPromise(function(...xs) { 101 | chrome.storage.sync.set(...xs); 102 | }, ...args); 103 | } 104 | } 105 | } 106 | }; 107 | 108 | function chromeCallbackToPromise(fn, ...args) { 109 | return new Promise(function(resolve, reject) { 110 | fn(...args, function(...callbackArgs) { 111 | if (chrome.runtime.lastError) { 112 | reject(chrome.runtime.lastError); 113 | } else { 114 | resolve(...callbackArgs); 115 | } 116 | }); 117 | }); 118 | } 119 | -------------------------------------------------------------------------------- /store-assets/icon128-16pad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glebm/render-whitespace-on-github/c4985c7544f37e70c15e2400ee3b82445d2b61ed/store-assets/icon128-16pad.png -------------------------------------------------------------------------------- /store-assets/icon128.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glebm/render-whitespace-on-github/c4985c7544f37e70c15e2400ee3b82445d2b61ed/store-assets/icon128.xcf -------------------------------------------------------------------------------- /store-assets/icon64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glebm/render-whitespace-on-github/c4985c7544f37e70c15e2400ee3b82445d2b61ed/store-assets/icon64.png -------------------------------------------------------------------------------- /store-assets/icon96.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glebm/render-whitespace-on-github/c4985c7544f37e70c15e2400ee3b82445d2b61ed/store-assets/icon96.xcf -------------------------------------------------------------------------------- /store-assets/screenshot-render.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glebm/render-whitespace-on-github/c4985c7544f37e70c15e2400ee3b82445d2b61ed/store-assets/screenshot-render.png --------------------------------------------------------------------------------