diff --git a/README.md b/README.md index 8a5f916b05..80f0ac27e2 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ const browserless = require('browserless')() browserless .screenshot('http://example.com', { device: 'iPhone 6' }) .then(buffer => { - console.log(`your screenshot is here!`) + console.log('your screenshot is here!') }) ``` @@ -160,7 +160,7 @@ const browserless = require('browserless') ;(async () => { const url = 'https://example.com' const buffer = await browserless.pdf(url) - console.log(`PDF generated!`) + console.log('PDF generated!') })() ``` @@ -229,7 +229,7 @@ const browserless = require('browserless') ;(async () => { const url = 'https://example.com' const buffer = await browserless.screenshot(url) - console.log(`Screenshot taken!`) + console.log('Screenshot taken!') })() ``` @@ -249,6 +249,20 @@ Also, any [page.screenshot](https://github.com/puppeteer/puppeteer/blob/master/d Additionally, you can setup: +##### codeScheme + +type: `string`
+default: `'atom-dark'` + +When this value is present and the response `'Content-Type'` header is `'json'`, it beautifies HTML markup using [Prism](https://prismjs.com). + +![](https://i.imgur.com/uFfviX7.png) + +The syntax highlight theme can be customized, being possible to setup: + +- A [prism-themes](https://github.com/PrismJS/prism-themes/tree/master/themes) identifier (e.g., `'dracula'`). +- A remote URL (e.g., `'https://unpkg.com/prism-theme-night-owl'`). + ##### element type: `string`
@@ -543,7 +557,7 @@ It can accept: scripts: [ 'https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.min.js', 'local-file.js', - `document.body.style.backgroundColor = 'red` + "document.body.style.backgroundColor = 'red'" ] }) })() @@ -575,7 +589,7 @@ It can accept: styles: [ 'https://cdn.jsdelivr.net/npm/hack@0.8.1/dist/dark.css', 'local-file.css', - `body { background: red; }` + 'body { background: red; }' ] }) })() @@ -656,7 +670,7 @@ After that, the API is the same than **browserless**: browserlessPool .screenshot('http://example.com', { device: 'iPhone 6' }) .then(buffer => { - console.log(`your screenshot is here!`) + console.log('your screenshot is here!') }) ``` diff --git a/package.json b/package.json index 2a47fb104d..74583aa39a 100644 --- a/package.json +++ b/package.json @@ -97,5 +97,10 @@ "package.json": [ "finepack" ] + }, + "standard": { + "ignore": [ + "packages/screenshot/src/pretty/prism.js" + ] } } diff --git a/packages/goto/package.json b/packages/goto/package.json index 5b8ea82401..aa16013cb9 100644 --- a/packages/goto/package.json +++ b/packages/goto/package.json @@ -32,6 +32,7 @@ "@cliqz/adblocker-puppeteer": "~1.7.2", "debug-logfmt": "~1.0.4", "got": "~10.5.5", + "is-url-http": "~2.1.1", "p-reflect": "~2.1.0", "p-timeout": "~3.2.0", "tldts": "~5.6.3" diff --git a/packages/goto/src/index.js b/packages/goto/src/index.js index fb9c977ee5..6fd3081106 100644 --- a/packages/goto/src/index.js +++ b/packages/goto/src/index.js @@ -6,6 +6,7 @@ const createDevices = require('@browserless/devices') const { getDomain } = require('tldts') const pReflect = require('p-reflect') const pTimeout = require('p-timeout') +const isUrl = require('is-url-http') const path = require('path') const fs = require('fs') @@ -20,8 +21,6 @@ const isEmpty = val => val == null || !(Object.keys(val) || val).length const toArray = value => [].concat(value) -const isUrl = string => /^(https?|file):\/\/|^data:/.test(string) - const getInjectKey = (ext, value) => isUrl(value) ? 'url' : value.endsWith(`.${ext}`) ? 'path' : 'content' @@ -138,6 +137,32 @@ const getMediaFeatures = ({ animations, colorScheme }) => { return prefers } +const injectScripts = (page, values, attributes) => + Promise.all( + toArray(values).map(value => + pReflect( + page.addScriptTag({ + [getInjectKey('js', value)]: value, + ...attributes + }) + ) + ) + ) + +const injectStyles = (page, styles) => + Promise.all( + toArray(styles).map(style => + pReflect( + page.addStyleTag({ + [getInjectKey('css', style)]: style + }) + ) + ) + ) + +const forEachSelector = (page, selectors, fn) => + toArray(selectors).map(selector => pReflect(page.$$eval(selector, fn))) + module.exports = ({ timeout, ...deviceOpts }) => { const gotoTimeout = timeout * (1 / 4) const getDevice = createDevices(deviceOpts) @@ -219,19 +244,14 @@ module.exports = ({ timeout, ...deviceOpts }) => { await page.evaluate(disableAnimations) } - if (hide) { - debug({ hide }) - await Promise.all( - toArray(hide).map(selector => pReflect(page.$$eval(selector, hideElements))) - ) - } + debug({ hide, remove }) - if (remove) { - debug({ remove }) - await Promise.all( - toArray(remove).map(selector => pReflect(page.$$eval(selector, removeElements))) - ) - } + await Promise.all( + [ + hide && forEachSelector(page, hide, hideElements), + remove && forEachSelector(page, hide, removeElements) + ].filter(Boolean) + ) if (click) { for (const selector of toArray(click)) { @@ -240,42 +260,15 @@ module.exports = ({ timeout, ...deviceOpts }) => { } } - if (modules) { - await Promise.all( - toArray(modules).map(m => - pReflect( - page.addScriptTag({ - [getInjectKey('js', m)]: m, - type: 'module' - }) - ) - ) - ) - } + debug({ modules, scripts, styles }) - if (scripts) { - await Promise.all( - toArray(scripts).map(script => - pReflect( - page.addScriptTag({ - [getInjectKey('js', script)]: script - }) - ) - ) - ) - } - - if (styles) { - await Promise.all( - toArray(styles).map(style => - pReflect( - page.addStyleTag({ - [getInjectKey('css', style)]: style - }) - ) - ) - ) - } + await Promise.all( + [ + modules && injectScripts(page, modules, { type: 'modules' }), + scripts && injectScripts(page, scripts), + styles && injectStyles(page, styles) + ].filter(Boolean) + ) if (scrollTo) { debug({ scrollTo }) @@ -296,3 +289,5 @@ module.exports = ({ timeout, ...deviceOpts }) => { } module.exports.parseCookies = parseCookies +module.exports.injectScripts = injectScripts +module.exports.injectStyles = injectStyles diff --git a/packages/screenshot/media/browser.sketch b/packages/screenshot/media/browser.sketch index 4bb202e3ed..901e9dd559 100644 Binary files a/packages/screenshot/media/browser.sketch and b/packages/screenshot/media/browser.sketch differ diff --git a/packages/screenshot/package.json b/packages/screenshot/package.json index d35cb3a2a0..9d7c98ec5c 100644 --- a/packages/screenshot/package.json +++ b/packages/screenshot/package.json @@ -30,6 +30,8 @@ "@browserless/goto": "^6.4.2", "got": "~10.5.5", "is-url-http": "~2.1.1", + "mime-types": "~2.1.26", + "prism-themes": "~1.3.0", "sharp": "~0.24.0", "svg-gradient": "~1.0.2" }, diff --git a/packages/screenshot/src/browser/dark.png b/packages/screenshot/src/browser/dark.png deleted file mode 100644 index cab284eee0..0000000000 Binary files a/packages/screenshot/src/browser/dark.png and /dev/null differ diff --git a/packages/screenshot/src/browser/light.png b/packages/screenshot/src/browser/light.png deleted file mode 100644 index 13ad4b4f57..0000000000 Binary files a/packages/screenshot/src/browser/light.png and /dev/null differ diff --git a/packages/screenshot/src/prepare.js b/packages/screenshot/src/goto.js similarity index 71% rename from packages/screenshot/src/prepare.js rename to packages/screenshot/src/goto.js index 13b786c388..49ef6d0295 100644 --- a/packages/screenshot/src/prepare.js +++ b/packages/screenshot/src/goto.js @@ -10,14 +10,12 @@ const getBoundingClientRect = element => { module.exports = ({ goto, ...gotoOpts } = {}) => { goto = goto || createGoto(gotoOpts) - return async (page, url, opts = {}) => { - const { device: deviceId = 'macbook pro 13', overlay, element, fullPage, ...args } = opts - + return async (page, url, { device = 'macbook pro 13', element, ...opts } = {}) => { page.on('dialog', async dialog => { await dialog.dismiss() }) - const { device } = await goto(page, { url, device: deviceId, ...args }) + const { response } = await goto(page, { url, device, ...opts }) const screenshotOptions = {} @@ -27,6 +25,6 @@ module.exports = ({ goto, ...gotoOpts } = {}) => { screenshotOptions.fullPage = false } - return { device, ...screenshotOptions } + return [screenshotOptions, response] } } diff --git a/packages/screenshot/src/index.js b/packages/screenshot/src/index.js index 961d3fbc41..187821e3df 100644 --- a/packages/screenshot/src/index.js +++ b/packages/screenshot/src/index.js @@ -1,53 +1,33 @@ 'use strict' -const svgGradient = require('svg-gradient') -const isHttpUrl = require('is-url-http') -const sharp = require('sharp') -const path = require('path') -const got = require('got') +const { extension } = require('mime-types') -const createPreparePage = require('./prepare') +const createGoto = require('./goto') +const overlay = require('./overlay') +const pretty = require('./pretty') -const BROWSER_THEMES = { - dark: path.resolve(__dirname, 'browser/dark.png'), - light: path.resolve(__dirname, 'browser/light.png') -} - -const getBackground = async (bg = 'transparent') => { - if (isHttpUrl(bg)) return got(bg).buffer() - - if (!bg.includes('gradient')) { - bg = `linear-gradient(45deg, ${bg} 0%, ${bg} 100%)` - } - - return Buffer.from(createSvgBackground(bg)) -} - -const createSvgBackground = css => svgGradient(css, { width: '2776px', height: '1910px' }) +const isJSON = headers => extension(headers['content-type']) === 'json' module.exports = gotoOpts => { - const preparePage = createPreparePage(gotoOpts) + const goto = createGoto(gotoOpts) - return page => async (url, { type = 'png', overlay = {}, ...opts } = {}) => { - const screenshotOpts = { - ...opts, - ...(await preparePage(page, url, { overlay, ...opts })), - type - } - - const screenshot = await page.screenshot(screenshotOpts) - - if (Object.keys(overlay).length === 0) return screenshot + return page => async ( + url, + { codeScheme = 'atom-dark', overlay: overlayOpts = {}, ...opts } = {} + ) => { + const [screenshotOpts, response] = await goto(page, url, opts) - const { browser: theme, background } = overlay - const browserOverlay = BROWSER_THEMES[theme] - - const inputs = browserOverlay - ? [{ input: browserOverlay }, { input: screenshot }] - : [{ input: screenshot }] + if (codeScheme && isJSON(response.headers())) { + await pretty(page, response, { codeScheme, ...opts }) + } - const image = sharp(await getBackground(background)).composite(inputs) + const screenshot = await page.screenshot({ + ...opts, + ...screenshotOpts + }) - return opts.path ? image.toFile(opts.path) : image.toBuffer() + return Object.keys(overlayOpts).length === 0 + ? screenshot + : overlay(screenshot, { ...opts, ...overlayOpts }) } } diff --git a/packages/screenshot/src/overlay/dark.png b/packages/screenshot/src/overlay/dark.png new file mode 100644 index 0000000000..afc0df7e7d Binary files /dev/null and b/packages/screenshot/src/overlay/dark.png differ diff --git a/packages/screenshot/src/overlay/index.js b/packages/screenshot/src/overlay/index.js new file mode 100644 index 0000000000..f1318ea6c6 --- /dev/null +++ b/packages/screenshot/src/overlay/index.js @@ -0,0 +1,36 @@ +'use strict' + +const svgGradient = require('svg-gradient') +const isHttpUrl = require('is-url-http') +const sharp = require('sharp') +const path = require('path') +const got = require('got') + +const createSvgBackground = css => svgGradient(css, { width: '2776px', height: '1910px' }) + +const getBackground = async (bg = 'transparent') => { + if (isHttpUrl(bg)) return got(bg).buffer() + + if (!bg.includes('gradient')) { + bg = `linear-gradient(45deg, ${bg} 0%, ${bg} 100%)` + } + + return Buffer.from(createSvgBackground(bg)) +} + +const BROWSER_THEMES = { + dark: path.resolve(__dirname, 'dark.png'), + light: path.resolve(__dirname, 'light.png') +} + +module.exports = async (screenshot, { browser: theme, background, path }) => { + const browserOverlay = BROWSER_THEMES[theme] + + const inputs = browserOverlay + ? [{ input: browserOverlay }, { input: screenshot }] + : [{ input: screenshot }] + + const image = sharp(await getBackground(background)).composite(inputs) + + return path ? image.toFile(path) : image.toBuffer() +} diff --git a/packages/screenshot/src/overlay/light.png b/packages/screenshot/src/overlay/light.png new file mode 100644 index 0000000000..3e9c71c8c9 Binary files /dev/null and b/packages/screenshot/src/overlay/light.png differ diff --git a/packages/screenshot/src/pretty/html.js b/packages/screenshot/src/pretty/html.js new file mode 100644 index 0000000000..bc1dd13fe9 --- /dev/null +++ b/packages/screenshot/src/pretty/html.js @@ -0,0 +1,42 @@ +module.exports = (payload, { prism, theme }) => ` + + + ${theme} + + + +
+      
+${JSON.stringify(payload, null, 2)}
+    
+ + + +` diff --git a/packages/screenshot/src/pretty/index.js b/packages/screenshot/src/pretty/index.js new file mode 100644 index 0000000000..d79847b412 --- /dev/null +++ b/packages/screenshot/src/pretty/index.js @@ -0,0 +1,29 @@ +'use strict' + +const { readFile } = require('fs').promises +const path = require('path') + +const getPrism = readFile(path.resolve(__dirname, 'prism.js')) +const getTheme = require('./theme') +const getHtml = require('./html') + +const { injectScripts, injectStyles } = require('@browserless/goto') + +module.exports = async (page, response, { codeScheme, styles, scripts, modules }) => { + const [theme, payload, prism] = await Promise.all([ + getTheme(codeScheme), + response.json(), + getPrism + ]) + + const html = getHtml(payload, { prism, theme }) + await page.setContent(html) + + await Promise.all( + [ + modules && injectScripts(page, modules, { type: 'modules' }), + scripts && injectScripts(page, scripts), + styles && injectStyles(page, styles) + ].filter(Boolean) + ) +} diff --git a/packages/screenshot/src/pretty/prism.js b/packages/screenshot/src/pretty/prism.js new file mode 100644 index 0000000000..c386ede3cc --- /dev/null +++ b/packages/screenshot/src/pretty/prism.js @@ -0,0 +1,5 @@ +/* PrismJS 1.19.0 +https://prismjs.com/download.html#themes=prism&languages=clike+javascript */ +var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(u){var c=/\blang(?:uage)?-([\w-]+)\b/i,n=0,C={manual:u.Prism&&u.Prism.manual,disableWorkerMessageHandler:u.Prism&&u.Prism.disableWorkerMessageHandler,util:{encode:function(e){return e instanceof _?new _(e.type,C.util.encode(e.content),e.alias):Array.isArray(e)?e.map(C.util.encode):e.replace(/&/g,"&").replace(/e.length)return;if(!(k instanceof _)){if(h&&y!=n.length-1){if(c.lastIndex=v,!(O=c.exec(e)))break;for(var b=O.index+(f&&O[1]?O[1].length:0),w=O.index+O[0].length,A=y,P=v,x=n.length;A"+r.content+""},!u.document)return u.addEventListener&&(C.disableWorkerMessageHandler||u.addEventListener("message",function(e){var n=JSON.parse(e.data),r=n.language,t=n.code,a=n.immediateClose;u.postMessage(C.highlight(t,C.languages[r],r)),a&&u.close()},!1)),C;var e=C.util.currentScript();if(e&&(C.filename=e.src,e.hasAttribute("data-manual")&&(C.manual=!0)),!C.manual){function r(){C.manual||C.highlightAll()}var t=document.readyState;"loading"===t||"interactive"===t&&e&&e.defer?document.addEventListener("DOMContentLoaded",r):window.requestAnimationFrame?window.requestAnimationFrame(r):window.setTimeout(r,16)}return C}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); +Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|interface|extends|implements|trait|instanceof|new)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,boolean:/\b(?:true|false)\b/,function:/\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+\.?\d*|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/}; +Prism.languages.javascript=Prism.languages.extend("clike",{"class-name":[Prism.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])[_$A-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\.(?:prototype|constructor))/,lookbehind:!0}],keyword:[{pattern:/((?:^|})\s*)(?:catch|finally)\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],number:/\b(?:(?:0[xX](?:[\dA-Fa-f](?:_[\dA-Fa-f])?)+|0[bB](?:[01](?:_[01])?)+|0[oO](?:[0-7](?:_[0-7])?)+)n?|(?:\d(?:_\d)?)+n|NaN|Infinity)\b|(?:\b(?:\d(?:_\d)?)+\.?(?:\d(?:_\d)?)*|\B\.(?:\d(?:_\d)?)+)(?:[Ee][+-]?(?:\d(?:_\d)?)+)?/,function:/#?[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,operator:/--|\+\+|\*\*=?|=>|&&|\|\||[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?[.?]?|[~:]/}),Prism.languages.javascript["class-name"][0].pattern=/(\b(?:class|interface|extends|implements|instanceof|new)\s+)[\w.\\]+/,Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:/((?:^|[^$\w\xA0-\uFFFF."'\])\s])\s*)\/(?:\[(?:[^\]\\\r\n]|\\.)*]|\\.|[^/\\\[\r\n])+\/[gimyus]{0,6}(?=(?:\s|\/\*[\s\S]*?\*\/)*(?:$|[\r\n,.;:})\]]|\/\/))/,lookbehind:!0,greedy:!0},"function-variable":{pattern:/#?[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+[_$A-Za-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*)?\s*\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\))/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/[_$a-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*=>)/i,inside:Prism.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\)\s*=>)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:[_$A-Za-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*\s*)\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\)\s*\{)/,lookbehind:!0,inside:Prism.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),Prism.languages.insertBefore("javascript","string",{"template-string":{pattern:/`(?:\\[\s\S]|\${(?:[^{}]|{(?:[^{}]|{[^}]*})*})+}|(?!\${)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\${(?:[^{}]|{(?:[^{}]|{[^}]*})*})+}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\${|}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}}}),Prism.languages.markup&&Prism.languages.markup.tag.addInlined("script","javascript"),Prism.languages.js=Prism.languages.javascript; diff --git a/packages/screenshot/src/pretty/theme.js b/packages/screenshot/src/pretty/theme.js new file mode 100644 index 0000000000..8184dc59c0 --- /dev/null +++ b/packages/screenshot/src/pretty/theme.js @@ -0,0 +1,18 @@ +'use strict' + +const { readFile } = require('fs').promises +const isUrl = require('is-url-http') +const path = require('path') + +const THEME_CACHE = Object.create(null) +const THEME_PATHS = path.resolve(__dirname, '../../node_modules/prism-themes/themes') + +module.exports = async themeId => { + if (isUrl(themeId)) return `` + + const stylesheet = + THEME_CACHE[themeId] || + (THEME_CACHE[themeId] = await readFile(path.resolve(THEME_PATHS, `prism-${themeId}.css`))) + + return `` +}