diff --git a/examples/using-sqip/.eslintrc b/examples/using-sqip/.eslintrc new file mode 100644 index 0000000000000..d1e4cdd12970e --- /dev/null +++ b/examples/using-sqip/.eslintrc @@ -0,0 +1,8 @@ +{ + "env": { + "browser": true + }, + "globals": { + "graphql": false + } +} diff --git a/examples/using-sqip/.gitignore b/examples/using-sqip/.gitignore new file mode 100644 index 0000000000000..8bf2bcb39c3ff --- /dev/null +++ b/examples/using-sqip/.gitignore @@ -0,0 +1,8 @@ +# Project dependencies +.cache +node_modules +yarn-error.log + +# Build directory +/public +.DS_Store diff --git a/examples/using-sqip/.prettierrc b/examples/using-sqip/.prettierrc new file mode 100644 index 0000000000000..36301bc5cff6c --- /dev/null +++ b/examples/using-sqip/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "es5" +} diff --git a/examples/using-sqip/gatsby-config.js b/examples/using-sqip/gatsby-config.js new file mode 100644 index 0000000000000..3a59b08f9b688 --- /dev/null +++ b/examples/using-sqip/gatsby-config.js @@ -0,0 +1,25 @@ +module.exports = { + siteMetadata: { + title: `Gatsby SQIP Example`, + }, + plugins: [ + { + resolve: `gatsby-source-filesystem`, + options: { + name: `images`, + path: `${__dirname}/src/images/`, + }, + }, + { + resolve: `gatsby-source-filesystem`, + options: { + name: `background`, + path: `${__dirname}/src/background/`, + }, + }, + `gatsby-image`, + `gatsby-plugin-sharp`, + `gatsby-transformer-sharp`, + `gatsby-transformer-sqip`, + ], +} diff --git a/examples/using-sqip/package.json b/examples/using-sqip/package.json new file mode 100644 index 0000000000000..4f4caffd449e0 --- /dev/null +++ b/examples/using-sqip/package.json @@ -0,0 +1,25 @@ +{ + "name": "using-sqip", + "description": "Gatsby example site using the sqip transformer plugin", + "version": "1.0.0", + "author": "Benedikt Rötsch ", + "dependencies": { + "gatsby": "^1.9.247", + "gatsby-image": "^1.0.48", + "gatsby-plugin-sharp": "^1.6.43", + "gatsby-source-filesystem": "^1.5.34", + "gatsby-transformer-sharp": "^1.6.23", + "gatsby-transformer-sqip": "*" + }, + "keywords": ["gatsby"], + "license": "MIT", + "scripts": { + "build": "gatsby build", + "develop": "gatsby develop", + "format": "prettier --write 'src/**/*.js'", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "devDependencies": { + "prettier": "^1.12.0" + } +} diff --git a/examples/using-sqip/src/background/neven-krcmarek-264325-unsplash.jpg b/examples/using-sqip/src/background/neven-krcmarek-264325-unsplash.jpg new file mode 100644 index 0000000000000..cc80624439461 Binary files /dev/null and b/examples/using-sqip/src/background/neven-krcmarek-264325-unsplash.jpg differ diff --git a/examples/using-sqip/src/components/polaroid.js b/examples/using-sqip/src/components/polaroid.js new file mode 100644 index 0000000000000..01a8d0b73294b --- /dev/null +++ b/examples/using-sqip/src/components/polaroid.js @@ -0,0 +1,116 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import Image from 'gatsby-image' + +// Styling based on https://codepen.io/havardob/pen/ZOOmMe + +const data = { + 'alisa-anton-166247-unsplash-2000px': { + style: { left: `6vw`, bottom: `6vw` }, + author: `Alisa Anton`, + url: `https://unsplash.com/photos/ukxAK0c2FqM`, + }, + 'anthony-esau-173126-unsplash-2000px': { + style: { left: `30vw`, bottom: `12vw` }, + author: `Anthony Esau`, + url: `https://unsplash.com/photos/N2zk9yXjmLA`, + }, + 'beth-solano-313648-unsplash-2000px': { + style: { left: `39vw`, top: `3vw` }, + author: `Beth Solando`, + url: `https://unsplash.com/photos/VGkn9ENxLXM`, + }, + 'desmond-simon-412494-unsplash-2000px': { + style: { right: `5vw`, bottom: `5vw` }, + author: `Desmond Simon`, + url: `https://unsplash.com/photos/HhOo98Iygps`, + }, + 'igor-ovsyannykov-307432-unsplash-2000px': { + style: { right: `27vw`, bottom: `3vw` }, + author: `Igor Ovsyannykov`, + url: `https://unsplash.com/photos/uzd2UEDdQJ8`, + }, + 'quino-al-101314-unsplash-2000px': { + style: { right: `28vw`, bottom: `27vw` }, + author: `Quino Al`, + url: `https://unsplash.com/photos/vBxlL1xpSdc`, + }, + 'samuel-zeller-16570-unsplash-2000px': { + style: { right: `16vw`, top: `2vw` }, + author: `Samuel Zeller`, + url: `https://unsplash.com/photos/CwkiN6_qpDI`, + }, + 'tyler-lastovich-205631-unsplash-2000px': { + style: { right: `3vw`, top: `14vw` }, + author: `Tyler Lastovich`, + url: `https://unsplash.com/photos/DMJUIGRO_1M`, + }, +} + +function generateStyle(imageData) { + const rotation = Math.floor(Math.random() * 26) - 13 + return { + boxSizing: `content-box`, + + display: `block`, + position: `absolute`, + + width: `18vw`, + + padding: `0.8vw 0.4vw 0`, + background: `linear-gradient(120deg, #fff, #eee 60%)`, + + color: `inherit`, + + boxShadow: `2px 2px 7px 0px rgba(0,0,0,0.4), rgba(0, 0, 0, 0.1) 1px 1px 3px 0px inset`, + transform: `rotate(${rotation}deg)`, + + ...imageData.style, + } +} + +const Polaroid = ({ image }) => { + const imageData = data[image.name] + + return ( + +
+ +
+
+
{`📷 ${imageData.author}`}
+
+ ) +} + +Polaroid.propTypes = { + image: PropTypes.object, +} + +export default Polaroid diff --git a/examples/using-sqip/src/images/alisa-anton-166247-unsplash-2000px.jpg b/examples/using-sqip/src/images/alisa-anton-166247-unsplash-2000px.jpg new file mode 100644 index 0000000000000..be8b481845cdf Binary files /dev/null and b/examples/using-sqip/src/images/alisa-anton-166247-unsplash-2000px.jpg differ diff --git a/examples/using-sqip/src/images/anthony-esau-173126-unsplash-2000px.jpg b/examples/using-sqip/src/images/anthony-esau-173126-unsplash-2000px.jpg new file mode 100644 index 0000000000000..a6bad5969edef Binary files /dev/null and b/examples/using-sqip/src/images/anthony-esau-173126-unsplash-2000px.jpg differ diff --git a/examples/using-sqip/src/images/beth-solano-313648-unsplash-2000px.jpg b/examples/using-sqip/src/images/beth-solano-313648-unsplash-2000px.jpg new file mode 100644 index 0000000000000..f11bc969c10d8 Binary files /dev/null and b/examples/using-sqip/src/images/beth-solano-313648-unsplash-2000px.jpg differ diff --git a/examples/using-sqip/src/images/desmond-simon-412494-unsplash-2000px.jpg b/examples/using-sqip/src/images/desmond-simon-412494-unsplash-2000px.jpg new file mode 100644 index 0000000000000..22e5e66ff94da Binary files /dev/null and b/examples/using-sqip/src/images/desmond-simon-412494-unsplash-2000px.jpg differ diff --git a/examples/using-sqip/src/images/igor-ovsyannykov-307432-unsplash-2000px.jpg b/examples/using-sqip/src/images/igor-ovsyannykov-307432-unsplash-2000px.jpg new file mode 100644 index 0000000000000..fc111b45ad424 Binary files /dev/null and b/examples/using-sqip/src/images/igor-ovsyannykov-307432-unsplash-2000px.jpg differ diff --git a/examples/using-sqip/src/images/quino-al-101314-unsplash-2000px.jpg b/examples/using-sqip/src/images/quino-al-101314-unsplash-2000px.jpg new file mode 100644 index 0000000000000..55508ecf77034 Binary files /dev/null and b/examples/using-sqip/src/images/quino-al-101314-unsplash-2000px.jpg differ diff --git a/examples/using-sqip/src/images/samuel-zeller-16570-unsplash-2000px.jpg b/examples/using-sqip/src/images/samuel-zeller-16570-unsplash-2000px.jpg new file mode 100644 index 0000000000000..5dbc4af5d10b3 Binary files /dev/null and b/examples/using-sqip/src/images/samuel-zeller-16570-unsplash-2000px.jpg differ diff --git a/examples/using-sqip/src/images/tyler-lastovich-205631-unsplash-2000px.jpg b/examples/using-sqip/src/images/tyler-lastovich-205631-unsplash-2000px.jpg new file mode 100644 index 0000000000000..96f169257c948 Binary files /dev/null and b/examples/using-sqip/src/images/tyler-lastovich-205631-unsplash-2000px.jpg differ diff --git a/examples/using-sqip/src/layouts/index.css b/examples/using-sqip/src/layouts/index.css new file mode 100644 index 0000000000000..e485487be9b3e --- /dev/null +++ b/examples/using-sqip/src/layouts/index.css @@ -0,0 +1,624 @@ +html { + font-family: sans-serif; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; +} +body { + margin: 0; +} +article, +aside, +details, +figcaption, +figure, +footer, +header, +main, +menu, +nav, +section, +summary { + display: block; +} +audio, +canvas, +progress, +video { + display: inline-block; +} +audio:not([controls]) { + display: none; + height: 0; +} +progress { + vertical-align: baseline; +} +[hidden], +template { + display: none; +} +a { + background-color: transparent; + -webkit-text-decoration-skip: objects; +} +a:active, +a:hover { + outline-width: 0; +} +abbr[title] { + border-bottom: none; + text-decoration: underline; + text-decoration: underline dotted; +} +b, +strong { + font-weight: inherit; + font-weight: bolder; +} +dfn { + font-style: italic; +} +h1 { + font-size: 2em; + margin: .67em 0; +} +mark { + background-color: #ff0; + color: #000; +} +small { + font-size: 80%; +} +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} +sub { + bottom: -.25em; +} +sup { + top: -.5em; +} +img { + border-style: none; +} +svg:not(:root) { + overflow: hidden; +} +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em; +} +figure { + margin: 1em 40px; +} +hr { + box-sizing: content-box; + height: 0; + overflow: visible; +} +button, +input, +optgroup, +select, +textarea { + font: inherit; + margin: 0; +} +optgroup { + font-weight: 700; +} +button, +input { + overflow: visible; +} +button, +select { + text-transform: none; +} +[type=reset], +[type=submit], +button, +html [type=button] { + -webkit-appearance: button; +} +[type=button]::-moz-focus-inner, +[type=reset]::-moz-focus-inner, +[type=submit]::-moz-focus-inner, +button::-moz-focus-inner { + border-style: none; + padding: 0; +} +[type=button]:-moz-focusring, +[type=reset]:-moz-focusring, +[type=submit]:-moz-focusring, +button:-moz-focusring { + outline: 1px dotted ButtonText; +} +fieldset { + border: 1px solid silver; + margin: 0 2px; + padding: .35em .625em .75em; +} +legend { + box-sizing: border-box; + color: inherit; + display: table; + max-width: 100%; + padding: 0; + white-space: normal; +} +textarea { + overflow: auto; +} +[type=checkbox], +[type=radio] { + box-sizing: border-box; + padding: 0; +} +[type=number]::-webkit-inner-spin-button, +[type=number]::-webkit-outer-spin-button { + height: auto; +} +[type=search] { + -webkit-appearance: textfield; + outline-offset: -2px; +} +[type=search]::-webkit-search-cancel-button, +[type=search]::-webkit-search-decoration { + -webkit-appearance: none; +} +::-webkit-input-placeholder { + color: inherit; + opacity: .54; +} +::-webkit-file-upload-button { + -webkit-appearance: button; + font: inherit; +} +html { + font: 112.5%/1.45em georgia, serif; + box-sizing: border-box; + overflow-y: scroll; +} +* { + box-sizing: inherit; +} +*:before { + box-sizing: inherit; +} +*:after { + box-sizing: inherit; +} +body { + color: hsla(0, 0%, 0%, 0.8); + font-family: georgia, serif; + font-weight: normal; + word-wrap: break-word; + font-kerning: normal; + -moz-font-feature-settings: "kern", "liga", "clig", "calt"; + -ms-font-feature-settings: "kern", "liga", "clig", "calt"; + -webkit-font-feature-settings: "kern", "liga", "clig", "calt"; + font-feature-settings: "kern", "liga", "clig", "calt"; +} +img { + max-width: 100%; + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; +} +h1 { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; + color: inherit; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + font-weight: bold; + text-rendering: optimizeLegibility; + font-size: 2.25rem; + line-height: 1.1; +} +h2 { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; + color: inherit; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + font-weight: bold; + text-rendering: optimizeLegibility; + font-size: 1.62671rem; + line-height: 1.1; +} +h3 { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; + color: inherit; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + font-weight: bold; + text-rendering: optimizeLegibility; + font-size: 1.38316rem; + line-height: 1.1; +} +h4 { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; + color: inherit; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + font-weight: bold; + text-rendering: optimizeLegibility; + font-size: 1rem; + line-height: 1.1; +} +h5 { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; + color: inherit; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + font-weight: bold; + text-rendering: optimizeLegibility; + font-size: 0.85028rem; + line-height: 1.1; +} +h6 { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; + color: inherit; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, + Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + font-weight: bold; + text-rendering: optimizeLegibility; + font-size: 0.78405rem; + line-height: 1.1; +} +hgroup { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; +} +ul { + margin-left: 1.45rem; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; + list-style-position: outside; + list-style-image: none; +} +ol { + margin-left: 1.45rem; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; + list-style-position: outside; + list-style-image: none; +} +dl { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; +} +dd { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; +} +p { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; +} +figure { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; +} +pre { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; + font-size: 0.85rem; + line-height: 1.42; + background: hsla(0, 0%, 0%, 0.04); + border-radius: 3px; + overflow: auto; + word-wrap: normal; + padding: 1.45rem; +} +table { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; + font-size: 1rem; + line-height: 1.45rem; + border-collapse: collapse; + width: 100%; +} +fieldset { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; +} +blockquote { + margin-left: 1.45rem; + margin-right: 1.45rem; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; +} +form { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; +} +noscript { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; +} +iframe { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; +} +hr { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: calc(1.45rem - 1px); + background: hsla(0, 0%, 0%, 0.2); + border: none; + height: 1px; +} +address { + margin-left: 0; + margin-right: 0; + margin-top: 0; + padding-bottom: 0; + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-bottom: 1.45rem; +} +b { + font-weight: bold; +} +strong { + font-weight: bold; +} +dt { + font-weight: bold; +} +th { + font-weight: bold; +} +li { + margin-bottom: calc(1.45rem / 2); +} +ol li { + padding-left: 0; +} +ul li { + padding-left: 0; +} +li > ol { + margin-left: 1.45rem; + margin-bottom: calc(1.45rem / 2); + margin-top: calc(1.45rem / 2); +} +li > ul { + margin-left: 1.45rem; + margin-bottom: calc(1.45rem / 2); + margin-top: calc(1.45rem / 2); +} +blockquote *:last-child { + margin-bottom: 0; +} +li *:last-child { + margin-bottom: 0; +} +p *:last-child { + margin-bottom: 0; +} +li > p { + margin-bottom: calc(1.45rem / 2); +} +code { + font-size: 0.85rem; + line-height: 1.45rem; +} +kbd { + font-size: 0.85rem; + line-height: 1.45rem; +} +samp { + font-size: 0.85rem; + line-height: 1.45rem; +} +abbr { + border-bottom: 1px dotted hsla(0, 0%, 0%, 0.5); + cursor: help; +} +acronym { + border-bottom: 1px dotted hsla(0, 0%, 0%, 0.5); + cursor: help; +} +abbr[title] { + border-bottom: 1px dotted hsla(0, 0%, 0%, 0.5); + cursor: help; + text-decoration: none; +} +thead { + text-align: left; +} +td, +th { + text-align: left; + border-bottom: 1px solid hsla(0, 0%, 0%, 0.12); + font-feature-settings: "tnum"; + -moz-font-feature-settings: "tnum"; + -ms-font-feature-settings: "tnum"; + -webkit-font-feature-settings: "tnum"; + padding-left: 0.96667rem; + padding-right: 0.96667rem; + padding-top: 0.725rem; + padding-bottom: calc(0.725rem - 1px); +} +th:first-child, +td:first-child { + padding-left: 0; +} +th:last-child, +td:last-child { + padding-right: 0; +} +tt, +code { + background-color: hsla(0, 0%, 0%, 0.04); + border-radius: 3px; + font-family: "SFMono-Regular", Consolas, "Roboto Mono", "Droid Sans Mono", + "Liberation Mono", Menlo, Courier, monospace; + padding: 0; + padding-top: 0.2em; + padding-bottom: 0.2em; +} +pre code { + background: none; + line-height: 1.42; +} +code:before, +code:after, +tt:before, +tt:after { + letter-spacing: -0.2em; + content: " "; +} +pre code:before, +pre code:after, +pre tt:before, +pre tt:after { + content: ""; +} +@media only screen and (max-width: 480px) { + html { + font-size: 100%; + } +} diff --git a/examples/using-sqip/src/layouts/index.js b/examples/using-sqip/src/layouts/index.js new file mode 100644 index 0000000000000..4fc2fe848b95b --- /dev/null +++ b/examples/using-sqip/src/layouts/index.js @@ -0,0 +1,92 @@ +import React from 'react' +import PropTypes from 'prop-types' +import Image from 'gatsby-image' + +import Polaroid from '../components/polaroid' + +import './index.css' + +const Layout = ({ children, data }) => { + const images = data.images.edges.map(image => ( + + )) + const background = data.background.edges[0].node + return ( +
+
+ {background.name} +
{images}
+
+
{children()}
+
+ ) +} + +Layout.propTypes = { + children: PropTypes.object, + data: PropTypes.object, +} + +export default Layout + +export const query = graphql` + query SiteTitleQuery { + images: allFile( + filter: { sourceInstanceName: { eq: "images" }, ext: { eq: ".jpg" } } + ) { + edges { + node { + publicURL + name + childImageSharp { + sizes(maxWidth: 400, maxHeight: 400) { + ...GatsbyImageSharpSizes_noBase64 + } + sqip( + # Make sure to keep the same aspect ratio when cropping + # With 256px as maximum dimension is the perfect value to speed up the process + width: 256 + height: 256 + numberOfPrimitives: 15 + blur: 8 + mode: 1 + ) { + dataURI + } + } + } + } + } + background: allFile( + filter: { sourceInstanceName: { eq: "background" }, ext: { eq: ".jpg" } } + ) { + edges { + node { + publicURL + name + childImageSharp { + sizes(maxWidth: 4000) { + ...GatsbyImageSharpSizes_noBase64 + } + sqip(numberOfPrimitives: 160, blur: 0) { + dataURI + } + } + } + } + } + } +` diff --git a/examples/using-sqip/src/pages/index.js b/examples/using-sqip/src/pages/index.js new file mode 100644 index 0000000000000..e37342dba6959 --- /dev/null +++ b/examples/using-sqip/src/pages/index.js @@ -0,0 +1,10 @@ +import React from 'react' + +const IndexPage = () => ( +
+

Gatsby SQIP Example

+

@todo add description

+
+) + +export default IndexPage diff --git a/packages/gatsby-plugin-sharp/package.json b/packages/gatsby-plugin-sharp/package.json index 213264de0d734..6520075c423e4 100644 --- a/packages/gatsby-plugin-sharp/package.json +++ b/packages/gatsby-plugin-sharp/package.json @@ -14,6 +14,7 @@ "imagemin-pngquant": "5.0.1", "imagemin-webp": "^4.0.0", "lodash": "^4.17.4", + "mini-svg-data-uri": "^1.0.0", "potrace": "^2.1.1", "probe-image-size": "^4.0.0", "progress": "^1.1.8", diff --git a/packages/gatsby-plugin-sharp/src/index.js b/packages/gatsby-plugin-sharp/src/index.js index e5872630f1f07..b8d9f9579779f 100644 --- a/packages/gatsby-plugin-sharp/src/index.js +++ b/packages/gatsby-plugin-sharp/src/index.js @@ -658,6 +658,7 @@ async function resolutions({ file, args = {}, reporter }) { async function notMemoizedtraceSVG({ file, args, fileArgs, reporter }) { const potrace = require(`potrace`) + const svgToMiniDataURI = require(`mini-svg-data-uri`) const trace = Promise.promisify(potrace.trace) const defaultArgs = { color: `lightgray`, @@ -730,7 +731,7 @@ async function notMemoizedtraceSVG({ file, args, fileArgs, reporter }) { return trace(tmpFilePath, optionsSVG) .then(svg => optimize(svg)) - .then(svg => encodeOptimizedSVGDataUri(svg)) + .then(svg => svgToMiniDataURI(svg)) } const memoizedTraceSVG = _.memoize( @@ -742,19 +743,6 @@ async function traceSVG(args) { return await memoizedTraceSVG(args) } -// https://codepen.io/tigt/post/optimizing-svgs-in-data-uris -function encodeOptimizedSVGDataUri(svgString) { - var uriPayload = encodeURIComponent(svgString) // encode URL-unsafe characters - .replace(/%0A/g, ``) // remove newlines - .replace(/%20/g, ` `) // put spaces back in - .replace(/%3D/g, `=`) // ditto equals signs - .replace(/%3A/g, `:`) // ditto colons - .replace(/%2F/g, `/`) // ditto slashes - .replace(/%22/g, `'`) // replace quotes with apostrophes (may break certain SVGs) - - return `data:image/svg+xml,` + uriPayload -} - const optimize = svg => { const SVGO = require(`svgo`) const svgo = new SVGO({ multipass: true, floatPrecision: 0 }) diff --git a/packages/gatsby-transformer-sharp/src/extend-node-type.js b/packages/gatsby-transformer-sharp/src/extend-node-type.js index 80949cf7e25bf..b147b0a820c4c 100644 --- a/packages/gatsby-transformer-sharp/src/extend-node-type.js +++ b/packages/gatsby-transformer-sharp/src/extend-node-type.js @@ -1,12 +1,10 @@ const Promise = require(`bluebird`) const { GraphQLObjectType, - GraphQLInputObjectType, GraphQLBoolean, GraphQLString, GraphQLInt, GraphQLFloat, - GraphQLEnumType, } = require(`graphql`) const { queueImageResizing, @@ -21,74 +19,13 @@ const fs = require(`fs`) const fsExtra = require(`fs-extra`) const imageSize = require(`probe-image-size`) const path = require(`path`) -const Potrace = require(`potrace`).Potrace -const ImageFormatType = new GraphQLEnumType({ - name: `ImageFormat`, - values: { - NO_CHANGE: { value: `` }, - JPG: { value: `jpg` }, - PNG: { value: `png` }, - WEBP: { value: `webp` }, - }, -}) - -const ImageCropFocusType = new GraphQLEnumType({ - name: `ImageCropFocus`, - values: { - CENTER: { value: sharp.gravity.center }, - NORTH: { value: sharp.gravity.north }, - NORTHEAST: { value: sharp.gravity.northeast }, - EAST: { value: sharp.gravity.east }, - SOUTHEAST: { value: sharp.gravity.southeast }, - SOUTH: { value: sharp.gravity.south }, - SOUTHWEST: { value: sharp.gravity.southwest }, - WEST: { value: sharp.gravity.west }, - NORTHWEST: { value: sharp.gravity.northwest }, - ENTROPY: { value: sharp.strategy.entropy }, - ATTENTION: { value: sharp.strategy.attention }, - }, -}) - -const DuotoneGradientType = new GraphQLInputObjectType({ - name: `DuotoneGradient`, - fields: () => { - return { - highlight: { type: GraphQLString }, - shadow: { type: GraphQLString }, - opacity: { type: GraphQLInt }, - } - }, -}) - -const PotraceType = new GraphQLInputObjectType({ - name: `Potrace`, - fields: () => { - return { - turnPolicy: { - type: new GraphQLEnumType({ - name: `PotraceTurnPolicy`, - values: { - TURNPOLICY_BLACK: { value: Potrace.TURNPOLICY_BLACK }, - TURNPOLICY_WHITE: { value: Potrace.TURNPOLICY_WHITE }, - TURNPOLICY_LEFT: { value: Potrace.TURNPOLICY_LEFT }, - TURNPOLICY_RIGHT: { value: Potrace.TURNPOLICY_RIGHT }, - TURNPOLICY_MINORITY: { value: Potrace.TURNPOLICY_MINORITY }, - TURNPOLICY_MAJORITY: { value: Potrace.TURNPOLICY_MAJORITY }, - }, - }), - }, - turdSize: { type: GraphQLFloat }, - alphaMax: { type: GraphQLFloat }, - optCurve: { type: GraphQLBoolean }, - optTolerance: { type: GraphQLFloat }, - threshold: { type: GraphQLInt }, - blackOnWhite: { type: GraphQLBoolean }, - color: { type: GraphQLString }, - background: { type: GraphQLString }, - } - }, -}) +const { + ImageFormatType, + ImageCropFocusType, + DuotoneGradientType, + PotraceType, +} = require(`./types`) function toArray(buf) { var arr = new Array(buf.length) diff --git a/packages/gatsby-transformer-sharp/src/types.js b/packages/gatsby-transformer-sharp/src/types.js new file mode 100644 index 0000000000000..ab9039dc6a0f4 --- /dev/null +++ b/packages/gatsby-transformer-sharp/src/types.js @@ -0,0 +1,84 @@ +const { + GraphQLInputObjectType, + GraphQLBoolean, + GraphQLString, + GraphQLInt, + GraphQLFloat, + GraphQLEnumType, +} = require(`graphql`) +const sharp = require(`sharp`) +const { Potrace } = require(`potrace`) + +const ImageFormatType = new GraphQLEnumType({ + name: `ImageFormat`, + values: { + NO_CHANGE: { value: `` }, + JPG: { value: `jpg` }, + PNG: { value: `png` }, + WEBP: { value: `webp` }, + }, +}) + +const ImageCropFocusType = new GraphQLEnumType({ + name: `ImageCropFocus`, + values: { + CENTER: { value: sharp.gravity.center }, + NORTH: { value: sharp.gravity.north }, + NORTHEAST: { value: sharp.gravity.northeast }, + EAST: { value: sharp.gravity.east }, + SOUTHEAST: { value: sharp.gravity.southeast }, + SOUTH: { value: sharp.gravity.south }, + SOUTHWEST: { value: sharp.gravity.southwest }, + WEST: { value: sharp.gravity.west }, + NORTHWEST: { value: sharp.gravity.northwest }, + ENTROPY: { value: sharp.strategy.entropy }, + ATTENTION: { value: sharp.strategy.attention }, + }, +}) + +const DuotoneGradientType = new GraphQLInputObjectType({ + name: `DuotoneGradient`, + fields: () => { + return { + highlight: { type: GraphQLString }, + shadow: { type: GraphQLString }, + opacity: { type: GraphQLInt }, + } + }, +}) + +const PotraceType = new GraphQLInputObjectType({ + name: `Potrace`, + fields: () => { + return { + turnPolicy: { + type: new GraphQLEnumType({ + name: `PotraceTurnPolicy`, + values: { + TURNPOLICY_BLACK: { value: Potrace.TURNPOLICY_BLACK }, + TURNPOLICY_WHITE: { value: Potrace.TURNPOLICY_WHITE }, + TURNPOLICY_LEFT: { value: Potrace.TURNPOLICY_LEFT }, + TURNPOLICY_RIGHT: { value: Potrace.TURNPOLICY_RIGHT }, + TURNPOLICY_MINORITY: { value: Potrace.TURNPOLICY_MINORITY }, + TURNPOLICY_MAJORITY: { value: Potrace.TURNPOLICY_MAJORITY }, + }, + }), + }, + turdSize: { type: GraphQLFloat }, + alphaMax: { type: GraphQLFloat }, + optCurve: { type: GraphQLBoolean }, + optTolerance: { type: GraphQLFloat }, + threshold: { type: GraphQLInt }, + blackOnWhite: { type: GraphQLBoolean }, + color: { type: GraphQLString }, + background: { type: GraphQLString }, + } + }, +}) + +module.exports = { + ImageFormatType, + ImageCropFocusType, + DuotoneGradientType, + PotraceType, +} diff --git a/packages/gatsby-transformer-sqip/.gitignore b/packages/gatsby-transformer-sqip/.gitignore new file mode 100644 index 0000000000000..6e5d88c2855c7 --- /dev/null +++ b/packages/gatsby-transformer-sqip/.gitignore @@ -0,0 +1,4 @@ +/*.js +!index.js +yarn.lock +package-lock.json diff --git a/packages/gatsby-transformer-sqip/README.md b/packages/gatsby-transformer-sqip/README.md new file mode 100644 index 0000000000000..8efa7870fe2b3 --- /dev/null +++ b/packages/gatsby-transformer-sqip/README.md @@ -0,0 +1,33 @@ +# Gatsby SQIP plugin + +Generates vectorized primitive version of images to be used as preview thumbnails. + +## :hand: Usage + +### GraphQL +```graphql +image { + sqip(numberOfPrimitives: 3, blur: 0), + resolutions { + ...GatsbyContentfulResolutions_withWebp_noBase64 + } +} +``` + +### React + +#### Pure HTML + +Coming soon. Doing some preparations first. + +#### Gatsby Image +```jsx +const Img = require(`gatsby-image`) + + +``` diff --git a/packages/gatsby-transformer-sqip/index.js b/packages/gatsby-transformer-sqip/index.js new file mode 100644 index 0000000000000..172f1ae6a468c --- /dev/null +++ b/packages/gatsby-transformer-sqip/index.js @@ -0,0 +1 @@ +// noop diff --git a/packages/gatsby-transformer-sqip/package.json b/packages/gatsby-transformer-sqip/package.json new file mode 100644 index 0000000000000..0ab6ff7afd8a3 --- /dev/null +++ b/packages/gatsby-transformer-sqip/package.json @@ -0,0 +1,43 @@ +{ + "name": "gatsby-transformer-sqip", + "description": "Generates geometric primitive version of images", + "version": "0.0.1", + "author": "Benedikt Rötsch ", + "bugs": { + "url": "https://github.com/gatsbyjs/gatsby/issues" + }, + "dependencies": { + "babel-runtime": "^6.26.0", + "fs-extra": "^4.0.2", + "mini-svg-data-uri": "^1.0.0", + "p-queue": "^2.3.0", + "sqip": "^0.3.0" + }, + "devDependencies": { + "babel-cli": "^6.26.0", + "cross-env": "^5.0.5", + "debug": "^3.1.0" + }, + "peerDependencies": { + "gatsby-source-contentful": "*", + "gatsby-transformer-sharp": "*" + }, + "homepage": "https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-transformer-sqip#readme", + "keywords": [ + "gatsby", + "gatsby-plugin", + "image", + "trace", + "sqip" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/gatsbyjs/gatsby.git" + }, + "scripts": { + "build": "babel src --out-dir . --ignore __tests__", + "prepublish": "cross-env NODE_ENV=production npm run build", + "watch": "babel -w src --out-dir . --ignore __tests__" + } +} diff --git a/packages/gatsby-transformer-sqip/src/__tests__/__snapshots__/generate-sqip.js.snap b/packages/gatsby-transformer-sqip/src/__tests__/__snapshots__/generate-sqip.js.snap new file mode 100644 index 0000000000000..89b07f7efd6c0 --- /dev/null +++ b/packages/gatsby-transformer-sqip/src/__tests__/__snapshots__/generate-sqip.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`gatsby-transformer-sqip generateSqip cached 1`] = ` +Object { + "dataURI": "data:image/svg+xml,%3csvg%3e%3c!-- Cached SQIP SVG --%3e%3c/svg%3e", + "svg": "", +} +`; + +exports[`gatsby-transformer-sqip generateSqip not cached 1`] = ` +Object { + "dataURI": "data:image/svg+xml,%3csvg%3e%3c!-- Mocked SQIP SVG --%3e%3c/svg%3e", + "svg": "", +} +`; + +exports[`gatsby-transformer-sqip generateSqip not cached 2`] = ` +Object { + "blur": 0, + "mode": 3, + "numberOfPrimitives": 5, +} +`; diff --git a/packages/gatsby-transformer-sqip/src/__tests__/generate-sqip.js b/packages/gatsby-transformer-sqip/src/__tests__/generate-sqip.js new file mode 100644 index 0000000000000..179e011fa843f --- /dev/null +++ b/packages/gatsby-transformer-sqip/src/__tests__/generate-sqip.js @@ -0,0 +1,95 @@ +const { resolve } = require(`path`) + +const { exists, readFile, writeFile } = require(`fs-extra`) +const sqip = require(`sqip`) + +const generateSqip = require(`../generate-sqip.js`) + +jest.mock(`sqip`, () => + jest.fn(() => { + return { + final_svg: ``, + } + }) +) + +jest.mock(`fs-extra`, () => { + return { + exists: jest.fn(() => false), + readFile: jest.fn(() => ``), + writeFile: jest.fn(), + } +}) + +afterEach(() => { + sqip.mockClear() + exists.mockClear() + readFile.mockClear() + writeFile.mockClear() +}) + +describe(`gatsby-transformer-sqip`, async () => { + const absolutePath = resolve( + __dirname, + `images`, + `this-file-does-not-neet-to-exist-for-the-test.jpg` + ) + const cacheDir = __dirname + + describe(`generateSqip`, () => { + it(`not cached`, async () => { + const cache = { + get: jest.fn(), + set: jest.fn(), + } + const numberOfPrimitives = 5 + const blur = 0 + const mode = 3 + const result = await generateSqip({ + cache, + cacheDir, + absolutePath, + numberOfPrimitives, + blur, + mode, + }) + expect(result).toMatchSnapshot() + + expect(sqip).toHaveBeenCalledTimes(1) + const sqipArgs = sqip.mock.calls[0][0] + expect(sqipArgs.filename).toMatch(absolutePath) + delete sqipArgs.filename + expect(sqipArgs).toMatchSnapshot() + + expect(exists).toHaveBeenCalledTimes(1) + expect(writeFile).toHaveBeenCalledTimes(1) + expect(readFile).toHaveBeenCalledTimes(0) + }) + it(`cached`, async () => { + exists.mockImplementationOnce(() => true) + const cache = { + get: jest.fn(), + set: jest.fn(), + } + const numberOfPrimitives = 5 + const blur = 0 + const mode = 3 + const result = await generateSqip({ + cache, + cacheDir, + absolutePath, + numberOfPrimitives, + blur, + mode, + }) + + expect(result).toMatchSnapshot() + + expect(sqip).toHaveBeenCalledTimes(0) + + expect(exists).toHaveBeenCalledTimes(1) + expect(writeFile).toHaveBeenCalledTimes(0) + expect(readFile).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/packages/gatsby-transformer-sqip/src/extend-node-type.js b/packages/gatsby-transformer-sqip/src/extend-node-type.js new file mode 100644 index 0000000000000..a68fdc67a34c3 --- /dev/null +++ b/packages/gatsby-transformer-sqip/src/extend-node-type.js @@ -0,0 +1,254 @@ +const { extname, resolve } = require(`path`) + +const { + DuotoneGradientType, + ImageCropFocusType, +} = require(`gatsby-transformer-sharp/types`) +const { queueImageResizing } = require(`gatsby-plugin-sharp`) + +const Debug = require(`debug`) +const fs = require(`fs-extra`) +const { + GraphQLObjectType, + GraphQLString, + GraphQLInt, + GraphQLBoolean, +} = require(`graphql`) +const sharp = require(`sharp`) + +const generateSqip = require(`./generate-sqip`) + +const debug = Debug(`gatsby-transformer-sqip`) +const SUPPORTED_NODES = [`ImageSharp`, `ContentfulAsset`] +const CACHE_DIR = resolve(process.cwd(), `public`, `static`) + +module.exports = async args => { + const { + type: { name }, + } = args + + if (!SUPPORTED_NODES.includes(name)) { + return {} + } + if (name === `ImageSharp`) { + return sqipSharp(args) + } + + if (name === `ContentfulAsset`) { + return sqipContentful(args) + } + + return {} +} + +async function sqipSharp({ type, cache, getNodeAndSavePathDependency }) { + return { + sqip: { + type: new GraphQLObjectType({ + name: `Sqip`, + fields: { + svg: { type: GraphQLString }, + dataURI: { type: GraphQLString }, + }, + }), + args: { + blur: { type: GraphQLInt, defaultValue: 1 }, + numberOfPrimitives: { type: GraphQLInt, defaultValue: 10 }, + mode: { type: GraphQLInt, defaultValue: 0 }, + width: { + type: GraphQLInt, + defaultValue: 256, + }, + height: { + type: GraphQLInt, + }, + grayscale: { + type: GraphQLBoolean, + defaultValue: false, + }, + duotone: { + type: DuotoneGradientType, + defaultValue: false, + }, + cropFocus: { + type: ImageCropFocusType, + defaultValue: sharp.strategy.attention, + }, + rotate: { + type: GraphQLInt, + defaultValue: 0, + }, + }, + async resolve(image, fieldArgs, context) { + const { + blur, + numberOfPrimitives, + mode, + width, + height, + grayscale, + duotone, + cropFocus, + rotate, + } = fieldArgs + + const sharpArgs = { + width, + height, + grayscale, + duotone, + cropFocus, + rotate, + } + + const file = getNodeAndSavePathDependency(image.parent, context.path) + + const job = await queueImageResizing({ file, args: sharpArgs }) + + if (!(await fs.exists(job.absolutePath))) { + debug(`Preparing ${file.name}`) + await job.finishedPromise + } + + const { absolutePath } = job + + return generateSqip({ + cache, + cacheDir: CACHE_DIR, + absolutePath, + numberOfPrimitives, + blur, + mode, + }) + }, + }, + } +} + +async function sqipContentful({ type, cache }) { + const { createWriteStream } = require(`fs`) + const axios = require(`axios`) + + const { + schemes: { ImageResizingBehavior, ImageCropFocusType }, + } = require(`gatsby-source-contentful`) + + return { + sqip: { + type: GraphQLString, + args: { + blur: { + type: GraphQLInt, + defaultValue: 1, + }, + numberOfPrimitives: { + type: GraphQLInt, + defaultValue: 10, + }, + mode: { + type: GraphQLInt, + defaultValue: 0, + }, + width: { + type: GraphQLInt, + defaultValue: 256, + }, + height: { + type: GraphQLInt, + }, + resizingBehavior: { + type: ImageResizingBehavior, + }, + cropFocus: { + type: ImageCropFocusType, + defaultValue: null, + }, + background: { + type: GraphQLString, + defaultValue: null, + }, + }, + async resolve(asset, fieldArgs, context) { + const { + id, + file: { url, fileName, details, contentType }, + } = asset + const { + blur, + numberOfPrimitives, + mode, + width, + height, + resizingBehavior, + cropFocus, + background, + } = fieldArgs + + if (contentType.indexOf(`image/`) !== 0) { + return null + } + + // Downloading small version of the image with same aspect ratio + const assetWidth = width || details.image.width + const assetHeight = height || details.image.height + const aspectRatio = assetHeight / assetWidth + const previewWidth = 256 + const previewHeight = Math.floor(previewWidth * aspectRatio) + + const params = [`w=${previewWidth}`, `h=${previewHeight}`] + if (resizingBehavior) { + params.push(`fit=${resizingBehavior}`) + } + if (cropFocus) { + params.push(`crop=${cropFocus}`) + } + if (background) { + params.push(`bg=${background}`) + } + + const uniqueId = [ + id, + aspectRatio, + resizingBehavior, + cropFocus, + background, + ] + .filter(Boolean) + .join(`-`) + + const extension = extname(fileName) + const absolutePath = resolve(CACHE_DIR, `${uniqueId}${extension}`) + + const alreadyExists = await fs.pathExists(absolutePath) + + if (!alreadyExists) { + const previewUrl = `http:${url}?${params.join(`&`)}` + + debug(`Downloading: ${previewUrl}`) + + const response = await axios({ + method: `get`, + url: previewUrl, + responseType: `stream`, + }) + + await new Promise((resolve, reject) => { + const file = createWriteStream(absolutePath) + response.data.pipe(file) + file.on(`finish`, resolve) + file.on(`error`, reject) + }) + } + + return generateSqip({ + cache, + CACHE_DIR, + absolutePath, + numberOfPrimitives, + blur, + mode, + }) + }, + }, + } +} diff --git a/packages/gatsby-transformer-sqip/src/gatsby-node.js b/packages/gatsby-transformer-sqip/src/gatsby-node.js new file mode 100644 index 0000000000000..ebfc9f04f3598 --- /dev/null +++ b/packages/gatsby-transformer-sqip/src/gatsby-node.js @@ -0,0 +1 @@ +exports.setFieldsOnGraphQLNodeType = require(`./extend-node-type`) diff --git a/packages/gatsby-transformer-sqip/src/generate-sqip.js b/packages/gatsby-transformer-sqip/src/generate-sqip.js new file mode 100644 index 0000000000000..2149cd39ccc47 --- /dev/null +++ b/packages/gatsby-transformer-sqip/src/generate-sqip.js @@ -0,0 +1,80 @@ +const crypto = require(`crypto`) +const { resolve, parse } = require(`path`) + +const Debug = require(`debug`) +const { exists, readFile, writeFile } = require(`fs-extra`) +const svgToMiniDataURI = require(`mini-svg-data-uri`) +const PQueue = require(`p-queue`) +const sqip = require(`sqip`) + +const queue = new PQueue({ concurrency: 1 }) +const debug = Debug(`gatsby-transformer-sqip`) + +module.exports = async function generateSqip(options) { + const { + cache, + absolutePath, + numberOfPrimitives, + blur, + mode, + cacheDir, + } = options + + debug({ options }) + + const { name } = parse(absolutePath) + + const sqipOptions = { + numberOfPrimitives, + blur, + mode, + } + + const optionsHash = crypto + .createHash(`md5`) + .update(JSON.stringify(sqipOptions)) + .digest(`hex`) + + const cacheKey = `sqip-${name}-${optionsHash}` + const cachePath = resolve(cacheDir, `${name}-${optionsHash}.svg`) + let primitiveData = await cache.get(cacheKey) + + debug({ primitiveData }) + + if (!primitiveData) { + let svg + if (await exists(cachePath)) { + const svgBuffer = await readFile(cachePath) + svg = svgBuffer.toString() + } else { + debug(`generate sqip for ${name}`) + const result = await queue.add( + async () => + new Promise((resolve, reject) => { + try { + const result = sqip({ + filename: absolutePath, + ...sqipOptions, + }) + resolve(result) + } catch (error) { + reject(error) + } + }) + ) + + svg = result.final_svg + + await writeFile(cachePath, svg) + } + + primitiveData = { + svg, + dataURI: svgToMiniDataURI(svg), + } + + await cache.set(cacheKey, primitiveData) + } + + return primitiveData +}