4

Is there a way I can get Webpack to add a compiled SCSS → CSS file to my Angular project's index.html head as an inline style tag?

The goal is to style the "Loading ..." page displayed while Angular is busy bootstrapping. In order to avoid FOUC the resulting CSS file needs to be injected inline, as a style tag in my index.html's head. This way, once index.html is loaded, we don't need to wait for another network resource to load in order to see our pre-bootstrap styling.

This might also be a worthy approach to inline a small logo inside the index.html page as a base64 data URI.

The project was created with Angular CLI and uses Angular 4 with Webpack 2. I ejected the Webpack configuration with ng eject and made minor modifications to webpack.config.js. I pretty much only removed LESS and Stylus support from the configuration.

Here's my webpack.config.js for reference:

const path = require('path');
const ProgressPlugin = require('webpack/lib/ProgressPlugin');
const ProvidePlugin = require('webpack/lib/ProvidePlugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const autoprefixer = require('autoprefixer');
const postcssUrl = require('postcss-url');

const {NoEmitOnErrorsPlugin, LoaderOptionsPlugin} = require('webpack');
const {GlobCopyWebpackPlugin, BaseHrefWebpackPlugin} = require('@angular/cli/plugins/webpack');
const {CommonsChunkPlugin} = require('webpack').optimize;
const {AotPlugin} = require('@ngtools/webpack');

const nodeModules = path.join(process.cwd(), 'node_modules');
const entryPoints = ["inline", "polyfills", "sw-register", "styles", "twbs", "vendor", "main"];
const baseHref = "";
const deployUrl = "";

module.exports = {
    devtool: "source-map",
    devServer: {
        port: 4200,
        host: "0.0.0.0",
        historyApiFallback: true
    },
    resolve: {
        extensions: [
            ".ts",
            ".js"
        ],
        modules: [
            "./node_modules"
        ]
    },
    resolveLoader: {
        modules: [
            "./node_modules"
        ]
    },
    entry: {
        main: [
            "./src/main.ts"
        ],
        polyfills: [
            "./src/polyfills.ts"
        ],
        styles: [
            "./src/styles/styles.scss",
            "./src/styles/vendor.scss"
        ],
        twbs: 'bootstrap-loader'
    },
    output: {
        path: path.join(process.cwd(), "dist"),
        filename: "[name].bundle.js",
        chunkFilename: "[id].chunk.js"
    },
    module: {
        rules: [
            {
                enforce: "pre",
                test: /\.js$/,
                loader: "source-map-loader",
                exclude: [
                    /node_modules/
                ]
            },
            {
                test: /\.json$/,
                loader: "json-loader"
            },
            {
                test: /\.html$/,
                loader: "raw-loader"
            },
            {
                test: /\.(eot|svg)$/,
                loader: "file-loader?name=[name].[hash:20].[ext]"
            },
            {
                test: /\.(jpg|png|gif|otf|ttf|woff|woff2|cur|ani)$/,
                loader: "url-loader?name=[name].[hash:20].[ext]&limit=10000"
            },
            {
                exclude: [
                    path.join(process.cwd(), "src/styles/styles.scss"),
                    path.join(process.cwd(), "src/styles/vendor.scss")
                ],
                test: /\.css$/,
                loaders: [
                    "exports-loader?module.exports.toString()",
                    "css-loader?{\"sourceMap\":false,\"importLoaders\":1}",
                    "postcss-loader"
                ]
            },
            {
                exclude: [
                    path.join(process.cwd(), "src/styles/styles.scss"),
                    path.join(process.cwd(), "src/styles/vendor.scss")
                ],
                test: /\.scss$/,
                loaders: [
                    "exports-loader?module.exports.toString()",
                    "css-loader?{\"sourceMap\":false,\"importLoaders\":1}",
                    "postcss-loader",
                    "sass-loader"
                ]
            },
            {
                include: [
                    path.join(process.cwd(), "src/styles/styles.scss"),
                    path.join(process.cwd(), "src/styles/vendor.scss")
                ],
                test: /\.css$/,
                loaders: ExtractTextPlugin.extract({
                    use: [
                        "css-loader?{\"sourceMap\":false,\"importLoaders\":1}",
                        "postcss-loader"
                    ],
                    fallback: "style-loader",
                    publicPath: ""
                })
            },
            {
                include: [
                    path.join(process.cwd(), "src/styles/styles.scss"),
                    path.join(process.cwd(), "src/styles/vendor.scss")
                ],
                test: /\.scss$/,
                loaders: ExtractTextPlugin.extract({
                    use: [
                        "css-loader?{\"sourceMap\":false,\"importLoaders\":1}",
                        "postcss-loader",
                        "sass-loader"
                    ],
                    fallback: "style-loader",
                    publicPath: ""
                })
            },
            {
                test: /\.ts$/,
                loader: "@ngtools/webpack"
            }
        ]
    },
    plugins: [
        new ProvidePlugin({
            $: "jquery",
            jQuery: "jquery",
            "window.jQuery": "jquery",
            Tether: "tether",
            "window.Tether": "tether",
            Tooltip: "exports-loader?Tooltip!bootstrap/js/dist/tooltip",
            Alert: "exports-loader?Alert!bootstrap/js/dist/alert",
            Button: "exports-loader?Button!bootstrap/js/dist/button",
            Carousel: "exports-loader?Carousel!bootstrap/js/dist/carousel",
            Collapse: "exports-loader?Collapse!bootstrap/js/dist/collapse",
            Dropdown: "exports-loader?Dropdown!bootstrap/js/dist/dropdown",
            Modal: "exports-loader?Modal!bootstrap/js/dist/modal",
            Popover: "exports-loader?Popover!bootstrap/js/dist/popover",
            Scrollspy: "exports-loader?Scrollspy!bootstrap/js/dist/scrollspy",
            Tab: "exports-loader?Tab!bootstrap/js/dist/tab",
            Util: "exports-loader?Util!bootstrap/js/dist/util"
        }),
        new NoEmitOnErrorsPlugin(),
        new GlobCopyWebpackPlugin({
            patterns: [
                "assets",
                "favicon.ico"
            ],
            globOptions: {
                "cwd": "./src",
                "dot": true,
                "ignore": "**/.gitkeep"
            }
        }),
        new ProgressPlugin(),
        new HtmlWebpackPlugin({
            template: "./src/index.html",
            filename: "./index.html",
            hash: false,
            inject: true,
            compile: true,
            favicon: false,
            minify: false,
            cache: true,
            showErrors: true,
            chunks: "all",
            excludeChunks: [],
            title: "Webpack App",
            xhtml: true,
            chunksSortMode: function sort(left, right) {
                let leftIndex = entryPoints.indexOf(left.names[0]);
                let rightindex = entryPoints.indexOf(right.names[0]);
                if (leftIndex > rightindex) {
                    return 1;
                }
                else if (leftIndex < rightindex) {
                    return -1;
                }
                else {
                    return 0;
                }
            }
        }),
        new BaseHrefWebpackPlugin({}),
        new CommonsChunkPlugin({
            name: "inline",
            minChunks: null
        }),
        new CommonsChunkPlugin({
            name: "vendor",
            minChunks: (module) => module.resource && module.resource.startsWith(nodeModules),
            chunks: [
                "main"
            ]
        }),
        new ExtractTextPlugin({
            filename: "[name].bundle.css",
            disable: true
        }),
        new LoaderOptionsPlugin({
            sourceMap: false,
            options: {
                postcss: [
                    autoprefixer(),
                    postcssUrl({
                        url: (URL) => {
                            // Only convert root relative URLs, which CSS-Loader won't process into require().
                            if (!URL.startsWith('/') || URL.startsWith('//')) {
                                return URL;
                            }
                            if (deployUrl.match(/:\/\//)) {
                                // If deployUrl contains a scheme, ignore baseHref use deployUrl as is.
                                return `${deployUrl.replace(/\/$/, '')}${URL}`;
                            }
                            else if (baseHref.match(/:\/\//)) {
                                // If baseHref contains a scheme, include it as is.
                                return baseHref.replace(/\/$/, '') +
                                    `/${deployUrl}/${URL}`.replace(/\/\/+/g, '/');
                            }
                            else {
                                // Join together base-href, deploy-url and the original URL.
                                // Also dedupe multiple slashes into single ones.
                                return `/${baseHref}/${deployUrl}/${URL}`.replace(/\/\/+/g, '/');
                            }
                        }
                    })
                ],
                sassLoader: {
                    sourceMap: false,
                    includePaths: []
                },
                context: ""
            }
        }),
        new AotPlugin({
            mainPath: "main.ts",
            hostReplacementPaths: {
                "environments/environment.ts": "environments/environment.ts"
            },
            exclude: [],
            tsConfigPath: "src/tsconfig.app.json",
            skipCodeGeneration: true
        })
    ],
    node: {
        fs: "empty",
        global: true,
        crypto: "empty",
        tls: "empty",
        net: "empty",
        process: true,
        module: false,
        clearImmediate: false,
        setImmediate: false
    }
};
josef.van.niekerk
  • 11,941
  • 20
  • 97
  • 157
  • I'm nudging closer to a solution by installing the html-webpack-inline-source-plugin, and just blindly setting inlineSource to '.' for now. I can see all my JS loading, so probably would need to create a separate bundle for the stuff I want to inline, and refine the regex on inlineSource to only inline that single file. – josef.van.niekerk Apr 11 '17 at 13:17
  • It seems what I am asking is to force a CSS file to fallback to style-loader, perhaps? – josef.van.niekerk Apr 11 '17 at 14:14
  • I think you're on the right path. – Nemesarial Apr 11 '17 at 23:50
  • 1
    Have you considered using Angular Universal to have part of the page rendered on the server for fast "time-to-render" on the browser? – Gabriel Kohen Apr 12 '17 at 01:28

2 Answers2

1

I have managed to resolve my puzzle by using a combination of the extract-text-webpack-plugin and style-ext-html-webpack-plugin Let's assume a folder structure as follows:

|- src
   |- index.ejs
   |- inline.css
   |- main.css
   |- main.js

main.js contains the following:

import _ from 'lodash';
import './inline.css';
import './main.css';

function component() {
    const element = document.createElement('div');
    element.innerHTML = _.join(['Hello', 'Webpack', '!!!'], ' ');
    return element;
}

document.body.appendChild(component());

The aim is to have Webpack generate dist/index.html and render inline.css directly in the resulting head of index.html. Further main.css loads via the css-loader.

To achieve this, I created webpack.config.js as follows:

const path = require('path');

const ExtractTextPlugin = require('extract-text-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const StyleExtHtmlPlugin = require('style-ext-html-webpack-plugin');

const extractSplashCSS = new ExtractTextPlugin('splash.css');
const extractMainCSS = new ExtractTextPlugin('main.css');

module.exports = {
    entry: {
        main: './src/main.js'
    },
    output: {
        path: path.join(process.cwd(), 'dist'),
        filename: '[name].bundle.js'
    },
    module: {
        rules: [
            {
                include: [
                    path.join(process.cwd(), 'src/inline.css')
                ],
                test: /\.css$/,
                loaders: extractSplashCSS.extract({
                    use: 'css-loader'
                })
            },
            {
                exclude: [
                    path.join(process.cwd(), 'src/inline.css')
                ],
                test: /\.css$/,
                loaders: extractMainCSS.extract({
                    use: 'css-loader'
                })
            }
        ]
    },
    plugins: [
        extractSplashCSS,
        extractMainCSS,
        new HtmlWebpackPlugin({
            title: 'Hello Webpack 2',
            template: 'src/index.ejs',
            filename: 'index.html'
        }),
        new StyleExtHtmlPlugin('splash.css')
    ]
};

The resulting index.html contains inline.css embedded as a style tag in the head of index.html:

<html>
<head>
<title>Hello Webpack 2</title>
<style>body {
    background-color: lightgrey;
}</style><link href="main.css" rel="stylesheet"></head>
<body>
<p>Webpack 2...</p>
<script type="text/javascript" src="main.bundle.js"></script></body>
</html>
josef.van.niekerk
  • 11,941
  • 20
  • 97
  • 157
1

This was too much work, this is easier. It's the barest min you can do to do an animated spinner with no magic and extra plugins.

  <app-root>
  <style type="text/css">
    initial-loading-indicator {
      z-index: 1200;
      position: fixed;
      top: 50%;
      left: 50%;
      content: '';
      font-size: 10px;
      width: 1em;
      height: 1em;
      -ms-animation: spinner 1500ms infinite linear;
      animation: spinner 1500ms infinite linear;
      border-radius: 0.5em;
      box-shadow: #495057 1.5em 0 0 0, #495057 1.1em 1.1em 0 0, #495057 0 1.5em 0 0, #495057 -1.1em 1.1em 0 0, #495057 -1.5em 0 0 0, #495057 -1.1em -1.1em 0 0, #495057 0 -1.5em 0 0, #495057 1.1em -1.1em 0 0;
    }

    @keyframes spinner {
      0% {
        -webkit-transform: rotate(0deg);
        -ms-transform: rotate(0deg);
        transform: rotate(0deg);
      }
      100% {
        -webkit-transform: rotate(360deg);
        -ms-transform: rotate(360deg);
        transform: rotate(360deg);
      }
    }
  </style>
<initial-loading-indicator></initial-loading-indicator>
  </app-root>
httpete
  • 2,765
  • 26
  • 34