├── examples └── with-next │ ├── .gitignore │ ├── .dockerignore │ ├── static │ ├── logo.png │ └── favicon.png │ ├── styles │ ├── background.jpg │ └── index.scss │ ├── Dockerfile │ ├── components │ ├── page.js │ ├── link.js │ └── navbar.js │ ├── routes.js │ ├── pages │ ├── _document.js │ ├── index.js │ └── editor.js │ ├── package.json │ ├── bin │ ├── bundle │ └── start │ ├── README.md │ └── next.config.js ├── CHANGELOG.md ├── package.json ├── index.js └── README.md /examples/with-next/.gitignore: -------------------------------------------------------------------------------- 1 | !bin 2 | .next 3 | .static 4 | out 5 | -------------------------------------------------------------------------------- /examples/with-next/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | node_modules 3 | .next 4 | .static 5 | out 6 | -------------------------------------------------------------------------------- /examples/with-next/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sheerun/extracted-loader/HEAD/examples/with-next/static/logo.png -------------------------------------------------------------------------------- /examples/with-next/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sheerun/extracted-loader/HEAD/examples/with-next/static/favicon.png -------------------------------------------------------------------------------- /examples/with-next/styles/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sheerun/extracted-loader/HEAD/examples/with-next/styles/background.jpg -------------------------------------------------------------------------------- /examples/with-next/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM sheerun/critical:1.1.0 2 | 3 | WORKDIR /app 4 | 5 | ADD *.json /app/ 6 | 7 | RUN yarn 8 | 9 | RUN yarn add critical@1.1.0 --offline 10 | 11 | ADD . /app/ 12 | 13 | ENV NODE_ENV=production 14 | 15 | RUN yarn build 16 | 17 | EXPOSE 3000 18 | 19 | CMD ["node", "bin/start"] 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.0.4 2 | 3 | Reloaded element can exist anywhere in the DOM and stays in place when reloaded. 4 | 5 | # 1.0.3 6 | 7 | Fix hot reloading on pages other than index 8 | 9 | # 1.0.2 10 | 11 | Final documentation update (to show it on npmjs.org) 12 | 13 | # 1.0.1 14 | 15 | Fix example how to use this plugin 16 | 17 | # 1.0.0 18 | 19 | Initial release 20 | -------------------------------------------------------------------------------- /examples/with-next/components/page.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Head from 'next/head' 3 | 4 | export default class Page extends React.Component { 5 | render () { 6 | return ( 7 |
8 | 9 | 10 | 11 | {this.props.children} 12 |
13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/with-next/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import "~bulma/sass/utilities/initial-variables"; 2 | $primary: #3E3C3D; 3 | $info: $blue; 4 | 5 | @import "~bulma"; 6 | 7 | .editor { 8 | .navbar-brand img { 9 | filter: invert(100%); 10 | } 11 | } 12 | 13 | .hero { 14 | background-image: url("./background.jpg"); 15 | min-height: 100vh; 16 | } 17 | 18 | .editor-container { 19 | width: 300px; 20 | height: 450px; 21 | margin: 0 auto; 22 | border: 1px solid gray; 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "extracted-loader", 3 | "version": "1.0.7", 4 | "description": "Reloads stylesheets extracted with with ExtractTextPlugin", 5 | "main": "index.js", 6 | "files": ["index.js"], 7 | "homepage": "https://github.com/sheerun/extracted-loader", 8 | "repository": { 9 | "type": "git", 10 | "url": "git://github.com/sheerun/extracted-loader.git" 11 | }, 12 | "author": "sheerun", 13 | "license": "MIT", 14 | "keywords": [ 15 | "webpack", 16 | "loader", 17 | "css", 18 | "scss", 19 | "styles", 20 | "stylesheets", 21 | "hotreload", 22 | "next.js" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /examples/with-next/routes.js: -------------------------------------------------------------------------------- 1 | // Order from most specific to least specific 2 | 3 | const routes = { 4 | '/': { page: '/' }, 5 | '/docs/': { page: '/', query: { id: 'docs' } }, 6 | '/examples/': { page: '/', query: { id: 'examples' } }, 7 | '/editor/': { page: '/editor' } 8 | } 9 | 10 | function orderedRoutes () { 11 | const pathnames = Object.keys(routes).sort( 12 | (a, b) => a.split('/').length < b.split('/').length 13 | ) 14 | 15 | return pathnames.map(pathname => ({ 16 | pathname, 17 | ...routes[pathname] 18 | })) 19 | } 20 | 21 | function exportPathMap () { 22 | return routes 23 | } 24 | 25 | module.exports = { 26 | exportPathMap, 27 | orderedRoutes 28 | } 29 | -------------------------------------------------------------------------------- /examples/with-next/components/link.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { orderedRoutes } from '../routes' 3 | import { default as NextLink } from 'next/link' 4 | import { format } from 'url' 5 | 6 | const routes = orderedRoutes() 7 | 8 | function propsForHref (href) { 9 | if (href[href.length - 1]) href += '/' 10 | 11 | for (const route of routes) { 12 | if (href.indexOf(route.pathname) === 0) { 13 | return { 14 | as: route.pathname, 15 | href: format({ ...route, pathname: route.page }) 16 | } 17 | } 18 | } 19 | 20 | throw new Error('Unknown route: ' + href) 21 | } 22 | 23 | export default props => 24 | -------------------------------------------------------------------------------- /examples/with-next/pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, { Head, Main, NextScript } from 'next/document' 2 | import flush from 'styled-jsx/server' 3 | 4 | export default class MyDocument extends Document { 5 | static getInitialProps ({ renderPage }) { 6 | const { html, head, errorHtml, chunks } = renderPage() 7 | const styles = flush() 8 | return { html, head, errorHtml, chunks, styles } 9 | } 10 | 11 | render () { 12 | const { __NEXT_DATA__ } = this.props 13 | 14 | return ( 15 | 16 | 17 | {process.env.NODE_ENV === 'development' ? ( 18 | 23 | ) : ( 24 | 29 | )} 30 | 31 | 32 | 33 |
34 | 35 | 36 | 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/with-next/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Page from '../components/page' 3 | import Navbar from '../components/navbar' 4 | 5 | import '../styles/index.scss' 6 | 7 | export default class Index extends React.Component { 8 | constructor (props) { 9 | super(props) 10 | 11 | this.state = { 12 | name: 'server' 13 | } 14 | } 15 | 16 | componentDidMount () { 17 | this.setState({ name: 'browser' }) 18 | } 19 | 20 | page () { 21 | switch (this.props.url.query.id) { 22 | case 'docs': 23 | return 'Documentation' 24 | case 'examples': 25 | return 'Examples' 26 | default: 27 | return 'Home' 28 | } 29 | } 30 | 31 | render () { 32 | return ( 33 | 34 |
35 |
36 | 37 |
38 |
39 |
40 |

{this.page()}

41 |

{this.state.name}

42 |
43 |
44 |
45 |
46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = function (source) { 2 | if (source.match('module.hot')) { 3 | return source 4 | } 5 | 6 | return ( 7 | source + 8 | `; 9 | if (module.hot) { 10 | var injectCss = function injectCss(prev, href) { 11 | var link = prev.cloneNode(); 12 | link.href = href; 13 | link.onload = link.onerror = function() { 14 | prev.parentNode.removeChild(prev); 15 | }; 16 | prev.stale = true; 17 | prev.parentNode.insertBefore(link, prev.nextSibling); 18 | }; 19 | module.hot.dispose(function() { 20 | window.__webpack_reload_css__ = true; 21 | }); 22 | module.hot.accept(); 23 | if (window.__webpack_reload_css__) { 24 | module.hot.__webpack_reload_css__ = false; 25 | console.log("[HMR] Reloading stylesheets..."); 26 | var prefix = document.location.protocol + '//' + document.location.hostname; 27 | document 28 | .querySelectorAll("link[href][rel=stylesheet]") 29 | .forEach(function(link) { 30 | if (!link.href.match(prefix) || link.stale) return; 31 | injectCss(link, link.href.split("?")[0] + "?unix=${+new Date()}"); 32 | }); 33 | } 34 | } 35 | ` 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /examples/with-next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-next", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "node bin/start", 6 | "build": "NODE_ENV=production next build && next export && node bin/bundle", 7 | "start": "cross-env NODE_ENV=production node bin/start" 8 | }, 9 | "author": "Adam Stankiewicz (https://sheerun.net)", 10 | "license": "Apache-2.0", 11 | "dependencies": { 12 | "autoprefixer": "^7.1.6", 13 | "brace": "^0.11.0", 14 | "bulma": "^0.6.1", 15 | "compression": "^1.7.1", 16 | "cross-env": "^5.1.1", 17 | "css-loader": "^0.28.7", 18 | "express": "^4.16.2", 19 | "express-device": "^0.4.2", 20 | "extract-text-webpack-plugin": "^3.0.2", 21 | "extracted-loader": "^1.0.4", 22 | "file-loader": "1.1.5", 23 | "font-awesome": "^4.7.0", 24 | "fs-extra": "4.0.3", 25 | "glob-promise": "^3.3.0", 26 | "img-loader": "2.0.0", 27 | "next": "latest", 28 | "node-sass": "^4.4.0", 29 | "postcss-loader": "^2.0.7", 30 | "react": "^16.0.0", 31 | "react-ace": "^5.8.0", 32 | "react-dom": "^16.0.0", 33 | "react-icons": "^2.2.7", 34 | "sass-loader": "^6.0.6", 35 | "url-loader": "0.6.2" 36 | }, 37 | "devDependencies": { 38 | "now": "^9.2.7" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /examples/with-next/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path') 4 | const fs = require('fs-extra') 5 | const glob = require('glob-promise') 6 | 7 | let critical 8 | 9 | try { 10 | critical = require("critical") 11 | } catch(e) { } 12 | 13 | const outDir = path.resolve(__dirname, '../out') 14 | const staticDir = path.resolve(__dirname, '../.next/.static') 15 | 16 | function generate (src, filename, resolution) { 17 | return critical.generate({ 18 | base: outDir, 19 | src, 20 | dest: path.join(path.dirname(src), filename), 21 | inline: true, 22 | extract: false, 23 | minify: true, 24 | inlineImages: true, 25 | width: resolution[0], 26 | height: resolution[1], 27 | penthouse: { 28 | blockJSRequests: false 29 | } 30 | }) 31 | } 32 | 33 | async function main () { 34 | console.log('Copying static files to output directory...') 35 | 36 | await fs.copy(staticDir, path.join(outDir, 'static')) 37 | 38 | if (critical) { 39 | for (const index of await glob('**/index.html', { cwd: outDir })) { 40 | console.log('Genetating critical path files for ' + index + '...') 41 | await generate(index, 'index-phone.html', [520, 960]) 42 | await generate(index, 'index-tablet.html', [769, 1024]) 43 | await generate(index, 'index-desktop.html', [1280, 1024]) 44 | } 45 | } else { 46 | console.warn('WARNING: Run "npm install critical" to enable critical CSS injecting') 47 | } 48 | } 49 | 50 | main() 51 | -------------------------------------------------------------------------------- /examples/with-next/components/navbar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import FaPencil from 'react-icons/lib/fa/pencil' 3 | 4 | import Link from './link' 5 | 6 | export default () => ( 7 | 46 | ) 47 | -------------------------------------------------------------------------------- /examples/with-next/pages/editor.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Page from '../components/page' 3 | import Navbar from '../components/navbar' 4 | import '../styles/index.scss' 5 | 6 | const NoSSR = ({ children, placeholder }) => 7 | typeof window === 'undefined' ? placeholder : children() 8 | 9 | export default class MapPage extends React.Component { 10 | render () { 11 | return ( 12 | 13 |
14 | 15 |
16 |
17 |

Heavy editor

18 |

19 | But it doesn't pollute bundle of home page 20 |

21 | }> 22 | {() => { 23 | const AceEditor = require('react-ace').default 24 | require('brace') 25 | require('brace/mode/javascript') 26 | require('brace/theme/github') 27 | 28 | return ( 29 | 38 | ) 39 | }} 40 | 41 |
42 |
43 |
44 |
45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/with-next/README.md: -------------------------------------------------------------------------------- 1 | # Next.js 2 | 3 | This app shows how you can use extracted stylesheets for maximum performance in production. 4 | 5 | Features: 6 | 7 | - Hot reloading in development without CSS-in-JS 8 | - 100% Page Speed score and 100% YSlow score (if put behind CDN) 9 | - Critical stylesheets injected per-device (phone, tablet, desktop) 10 | - SCSS and PostCSS with autoprefixing for styling 11 | - Content hashes for all resources for best integration with CDNs 12 | - Properly setup Cache headers for resources 13 | - Auto-bundling images referenced as `url()` in stylesheets 14 | - Auto-bundling of imported and required images 15 | - Inlining small images as Data URI (in both stylesheets and SSR page) 16 | - Properly handling multiple pages and routing between them 17 | - Ability to fallback to dynamic pages 18 | - Importing single icons from Font Awesome 19 | - GZip compression of served pages 20 | 21 | ## How to use 22 | 23 | Download the example [or clone the repo](https://github.com/zeit/next.js): 24 | 25 | ```bash 26 | curl https://codeload.github.com/zeit/next.js/tar.gz/master | tar -xz --strip=2 next.js-master/examples/with-extracted-stylesheet 27 | cd with-extracted-stylesheet 28 | ``` 29 | 30 | To get this example running you just need to 31 | 32 | npm install 33 | npm run dev 34 | 35 | Visit [http://localhost:3000](http://localhost:3000) and try to modify `styles/index.scss` changing color. Your changes should be picked up instantly. 36 | 37 | ## Deploying as static website 38 | 39 | You should get complete webpage in `out` directory after running `npm run build`. 40 | 41 | It is recommended to use `node bin/start` as a server though, to get dynamic content as fallback. 42 | 43 | ## Deploying to now 44 | 45 | Deploy it to the cloud with [now](https://zeit.co/now) ([download](https://zeit.co/download)) with: 46 | 47 | ```bash 48 | now --npm 49 | ``` 50 | 51 | To deploy with critical CSS optimization (takes longer): 52 | 53 | ```bash 54 | now --docker 55 | ``` 56 | 57 | ## Demo 58 | 59 | You see this app and test with performance analytics tools under following address: 60 | 61 | [https://with-extracted-stylesheet.now.sh/](https://with-extracted-stylesheet.now.sh/) 62 | -------------------------------------------------------------------------------- /examples/with-next/next.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 3 | const exportPathMap = require('./routes').exportPathMap 4 | 5 | module.exports = { 6 | webpack: (config, { buildId, dev }) => { 7 | config.module.rules.push({ 8 | test: /\.((sa|sc|c)ss|jpg|png)$/, 9 | use: [ 10 | { 11 | loader: 'emit-file-loader', 12 | options: { 13 | name: 'dist/[path][name].[ext]' 14 | } 15 | } 16 | ] 17 | }) 18 | 19 | config.module.rules.push({ 20 | test: /\.(jpg|png|svg)$/, 21 | use: [ 22 | { 23 | loader: 'url-loader', 24 | options: { 25 | limit: 10000, 26 | name: '.static/assets/[hash].[ext]', 27 | outputPath: dev ? path.join(__dirname, '/') : undefined, 28 | publicPath: function (url) { 29 | return url.replace(/^.*.static/, '/static') 30 | } 31 | } 32 | } 33 | ] 34 | }) 35 | 36 | config.module.rules.push({ 37 | test: /\.(sa|sc|c)ss$/, 38 | use: ['extracted-loader'].concat( 39 | ExtractTextPlugin.extract({ 40 | use: [ 41 | 'babel-loader', 42 | { 43 | loader: 'css-loader', 44 | options: { 45 | url: true, 46 | minimize: !dev, 47 | sourceMap: dev, 48 | importLoaders: 2 49 | } 50 | }, 51 | { 52 | loader: 'postcss-loader', 53 | options: { 54 | plugins: [ 55 | require('autoprefixer')({ 56 | /* options */ 57 | }) 58 | ] 59 | } 60 | }, 61 | { 62 | loader: 'sass-loader' 63 | } 64 | ] 65 | }) 66 | ) 67 | }) 68 | 69 | config.plugins.push( 70 | new ExtractTextPlugin({ 71 | filename: dev 72 | ? path.join(__dirname, '.static/assets/index.css') 73 | : '.static/assets/' + buildId + '.css', 74 | allChunks: true 75 | }) 76 | ) 77 | 78 | return config 79 | }, 80 | useFileSystemPublicRoutes: false, 81 | exportPathMap 82 | } 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # extracted-loader [![][npm-image]][npm-url] 2 | 3 | [npm-image]: http://img.shields.io/npm/v/extracted-loader.svg?style=flat-square 4 | [npm-url]: http://npmjs.org/package/extracted-loader 5 | 6 | It hotreloads extracted stylesheets extracted with `ExtractTextPlugin`. 7 | 8 | No configuration needed. A better [css-hot-loader](https://github.com/shepherdwind/css-hot-loader). 9 | 10 | ## Use case 11 | 12 | You want to hot reload only stylesheets, not the whole page. Great for editing dynamic views. 13 | 14 | ## Installation 15 | 16 | ``` 17 | npm install extracted-loader --save-dev 18 | ``` 19 | 20 | or 21 | 22 | ``` 23 | yarn add extracted-loader --dev 24 | ``` 25 | 26 | And then you can use it for example as so: 27 | 28 | ```js 29 | const isDev = process.env.NODE_ENV === 'development' 30 | 31 | config.module.rules.push({ 32 | test: /\.css$/, 33 | use: ['extracted-loader'].concat(ExtractTextPlugin.extract({ 34 | filename: isDev ? "[name].css" : "[name].[contenthash].css", 35 | /* Your configuration here */ 36 | })) 37 | }) 38 | 39 | config.plugins.push(new ExtractTextPlugin('index.css')) 40 | ``` 41 | 42 | For hot reloading to work it is important **to not** use `[contenthash]` in development configuration. 43 | 44 | ## Example use with sass 45 | 46 | ```js 47 | config.module.rules.push({ 48 | test: /\.(sa|sc|c)ss$/, 49 | use: ['extracted-loader'].concat(ExtractTextPlugin.extract({ 50 | use: [ 51 | "babel-loader", 52 | { 53 | loader: 'css-loader', 54 | options: { 55 | url: true, 56 | minimize: !isDev, 57 | sourceMap: isDev, 58 | importLoaders: 2 59 | } 60 | }, 61 | { 62 | loader: 'postcss-loader', 63 | options: { 64 | sourceMap: isDev, 65 | plugins: [ 66 | require('autoprefixer')({ 67 | /* options */ 68 | }) 69 | ] 70 | } 71 | }, 72 | { 73 | loader: 'sass-loader', 74 | options: { 75 | sourceMap: isDev 76 | } 77 | } 78 | ] 79 | })) 80 | }) 81 | 82 | config.plugins.push(new ExtractTextPlugin('index.css')) 83 | ``` 84 | 85 | ## How it works 86 | 87 | By reloading all relevant `` when extracted text changes. 88 | 89 | ## How to use with... 90 | 91 | - [next.js](https://github.com/sheerun/extracted-loader/tree/master/examples/with-next) 92 | 93 | ## Contributing 94 | 95 | Yes, please 96 | 97 | ## License 98 | 99 | MIT 100 | -------------------------------------------------------------------------------- /examples/with-next/bin/start: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const express = require('express') 4 | const next = require('next') 5 | const fs = require('fs') 6 | const path = require('path') 7 | const compression = require('compression') 8 | const device = require('express-device') 9 | 10 | const port = parseInt(process.env.PORT, 10) || 3000 11 | const dev = process.env.NODE_ENV === 'development' 12 | const app = next({ dev }) 13 | const handle = app.getRequestHandler() 14 | const rootDir = path.resolve(__dirname, '..') 15 | const outDir = path.join(rootDir, 'out') 16 | const routes = require('../routes') 17 | 18 | app.prepare().then(() => { 19 | const server = express() 20 | 21 | server.use(compression()) 22 | 23 | const orderedRoutes = routes.orderedRoutes() 24 | 25 | const hasCritical = fs.existsSync(path.join(outDir, 'index-phone.html')) 26 | 27 | if (dev) { 28 | server.use('/static', express.static(path.join(rootDir, '.static'))) 29 | } else { 30 | server.use('/_next/', express.static(path.join(outDir, '_next'), { 31 | maxAge: "365d", 32 | immutable: true 33 | })) 34 | 35 | // We know generated assets are immutable so we set high max-age 36 | server.use('/static/assets/', express.static(path.join(outDir, 'static/assets'), { 37 | maxAge: "365d", 38 | immutable: true 39 | })) 40 | 41 | // Other assets are mutable, but we want CDNs to cache it for at least 1m 42 | server.use('/static/', express.static(path.join(outDir, 'static'), { 43 | maxAge: "1m" 44 | })) 45 | 46 | if (hasCritical) { 47 | const criticalMiddlewares = { 48 | phone: express.static(outDir, { index: 'index-phone.html' }), 49 | tablet: express.static(outDir, { index: 'index-tablet.html' }), 50 | desktop: express.static(outDir, { index: 'index-desktop.html' }) 51 | } 52 | 53 | server.use(device.capture()) 54 | server.use((req, res, next) => { 55 | switch (req.device.type) { 56 | case 'phone': 57 | criticalMiddlewares.phone(req, res, next) 58 | return 59 | case 'tablet': 60 | criticalMiddlewares.tablet(req, res, next) 61 | return 62 | default: 63 | criticalMiddlewares.desktop(req, res, next) 64 | } 65 | }); 66 | } else { 67 | server.use('/', express.static(path.join(rootDir, 'out'))) 68 | } 69 | } 70 | 71 | // Fallback to dynamic routes 72 | orderedRoutes.forEach(({ pathname, page, query }) => { 73 | server.get(pathname + '*', (req, res) => 74 | app.render(req, res, page, query || {})) 75 | }) 76 | 77 | server.get('*', (req, res) => handle(req, res)) 78 | 79 | server.listen(port, err => { 80 | if (err) throw err 81 | console.log(`> Ready on http://localhost:${port}`) 82 | }) 83 | }) 84 | --------------------------------------------------------------------------------