├── .editorconfig ├── .env.dev ├── .eslintrc.js ├── .gitignore ├── Dockerfile ├── README.md ├── babel.config.js ├── build ├── setup-dev-server.js ├── svg-sprite.js └── webpack │ ├── base.js │ ├── client.js │ └── server.js ├── favicon.ico ├── index.js ├── package.json ├── setup-proxy.js └── src ├── app.vue ├── assets ├── .gitignore ├── fonts │ └── .gitkeep ├── images │ └── .gitkeep └── svg-icons │ └── .gitkeep ├── components ├── icon.js ├── not-found.vue └── server-error.vue ├── directives └── focus.js ├── entry ├── app.js ├── client.js └── server.js ├── filters ├── lower.js └── upper.js ├── http.js ├── layout.pug ├── pages ├── 404.vue └── index.vue ├── router.js ├── shared.styl ├── store └── index.js ├── styles └── reset.styl └── utils ├── index.js └── ssr.js /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset=utf-8 3 | end_of_line=lf 4 | trim_trailing_whitespace=true 5 | insert_final_newline=true 6 | indent_style=tab 7 | indent_size=4 8 | tab_width=4 9 | 10 | [{*.yml,*.yaml,*.json}] 11 | indent_style=space 12 | indent_size=2 13 | -------------------------------------------------------------------------------- /.env.dev: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=8080 3 | API_BASE_SSR=http://localhost:8080/ 4 | API_BASE_CLIENT=/ 5 | PROXY_ENABLED=1 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'vue-eslint-parser', 3 | parserOptions: { 4 | ecmaVersion: 9, 5 | sourceType: 'module', 6 | ecmaFeatures: { 7 | impliedStrict: true, 8 | experimentalObjectRestSpread: true 9 | } 10 | }, 11 | root: true, 12 | globals: { 13 | document: true, 14 | navigator: true, 15 | window: true, 16 | apiBaseURL: true 17 | }, 18 | env: { 19 | browser: true, 20 | amd: true, 21 | node: true, 22 | es6: true 23 | }, 24 | plugins: [ 25 | 'vue', 26 | 'filenames' 27 | ], 28 | extends: ['plugin:vue/recommended'], 29 | rules: { 30 | 'no-console': 1, 31 | 'no-constant-condition': 2, 32 | 'no-dupe-args': 2, 33 | 'no-dupe-keys': 2, 34 | 'no-duplicate-case': 2, 35 | 'no-empty-character-class': 2, 36 | 'no-empty': 2, 37 | 'no-ex-assign': 2, 38 | 'no-extra-semi': 2, 39 | 'no-func-assign': 2, 40 | 'no-invalid-regexp': 2, 41 | 'no-irregular-whitespace': 1, 42 | 'no-regex-spaces': 2, 43 | 'no-sparse-arrays': 2, 44 | 'no-unexpected-multiline': 1, 45 | 'no-unreachable': 2, 46 | 'no-unsafe-finally': 2, 47 | 'no-unsafe-negation': 2, 48 | 'use-isnan': 1, 49 | 'valid-jsdoc': 0, 50 | 'valid-typeof': 2, 51 | 'array-callback-return': 1, 52 | 'block-scoped-var': 1, 53 | 'dot-notation': 2, 54 | 'eqeqeq': [2, 'smart'], 55 | 'no-alert': 2, 56 | 'no-case-declarations': 2, 57 | 'no-empty-function': 1, 58 | 'no-empty-pattern': 2, 59 | 'no-eq-null': 0, 60 | 'no-eval': 2, 61 | 'no-extra-bind': 2, 62 | 'no-floating-decimal': 2, 63 | 'no-global-assign': 2, 64 | 'no-implied-eval': 2, 65 | 'no-invalid-this': 1, 66 | 'no-iterator': 2, 67 | 'no-labels': 2, 68 | 'no-lone-blocks': 2, 69 | 'no-loop-func': 2, 70 | 'no-multi-spaces': 1, 71 | 'no-redeclare': 2, 72 | 'no-return-assign': 1, 73 | 'no-self-assign': 1, 74 | 'no-self-compare': 1, 75 | 'no-throw-literal': 2, 76 | 'no-unused-expressions': 2, 77 | 'no-useless-call': 2, 78 | 'no-useless-concat': 1, 79 | 'no-useless-escape': 2, 80 | 'no-useless-return': 2, 81 | 'no-void': 2, 82 | 'no-with': 2, 83 | 'radix': [2, 'as-needed'], 84 | 'require-await': 2, 85 | 'yoda': 1, 86 | 'no-catch-shadow': 2, 87 | 'no-delete-var': 2, 88 | 'no-shadow-restricted-names': 2, 89 | 'no-undef': 0, 90 | 'no-unused-vars': 2, 91 | 'no-use-before-define': 2, 92 | 'handle-callback-err': 1, 93 | 'block-spacing': [1, 'always'], 94 | 'brace-style': [1, 'stroustrup', { allowSingleLine: true }], 95 | 'camelcase': 1, 96 | 'comma-dangle': ['error', { 97 | "arrays": "always-multiline", 98 | "objects": "always-multiline", 99 | "imports": "never", 100 | "exports": "never", 101 | "functions": "ignore" 102 | }], 103 | 'comma-spacing': 1, 104 | 'comma-style': 1, 105 | 'computed-property-spacing': 1, 106 | 'func-call-spacing': 1, 107 | 'func-style': [1, 'declaration', { allowArrowFunctions: true }], 108 | 'indent': 0, 109 | 'key-spacing': 1, 110 | 'new-cap': 0, 111 | 'new-parens': 2, 112 | 'no-mixed-spaces-and-tabs': 2, 113 | 'no-trailing-spaces': 2, 114 | 'no-unneeded-ternary': 1, 115 | 'no-whitespace-before-property': 1, 116 | 'object-curly-spacing': [1, 'always', { arraysInObjects: false }], 117 | 'one-var-declaration-per-line': 1, 118 | 'operator-assignment': 1, 119 | 'quote-props': [2, 'as-needed'], 120 | 'quotes': [1, 'single', { allowTemplateLiterals: true }], 121 | 'semi-spacing': 1, 122 | 'semi': 2, 123 | 'space-before-blocks': 1, 124 | 'space-before-function-paren': [1, { 125 | anonymous: 'never', 126 | named: 'never', 127 | asyncArrow: 'always' 128 | }], 129 | 'space-in-parens': [1, 'never'], 130 | 'space-infix-ops': 1, 131 | 'space-unary-ops': [1, { words: true, nonwords: false }], 132 | 'spaced-comment': 1, 133 | 'arrow-spacing': 1, 134 | 'no-class-assign': 2, 135 | 'no-const-assign': 2, 136 | 'no-dupe-class-members': 2, 137 | 'no-duplicate-imports': 2, 138 | 'no-new-symbol': 2, 139 | 'no-useless-computed-key': 2, 140 | 'no-useless-constructor': 2, 141 | 'no-useless-rename': 2, 142 | 'object-shorthand': 1, 143 | 'prefer-arrow-callback': 1, 144 | 'require-yield': 2, 145 | 'template-curly-spacing': 1, 146 | 'vue/component-name-in-template-casing': [1, 'kebab-case'], 147 | 'vue/script-indent': [1, 'tab', { baseIndent: 1 }], 148 | 'vue/require-default-prop': 0, 149 | 'filenames/match-regex': [2, /^([a-z0-9]+[\-\.])*[a-z0-9]+$/, true] 150 | }, 151 | overrides: [ 152 | { 153 | files: ['*.vue'], 154 | rules: { 155 | indent: 0, 156 | } 157 | }, 158 | ] 159 | }; 160 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /dist/ 3 | /node_modules/ 4 | /npm-debug.log 5 | /.env 6 | /package-lock.json 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NODE_VERSION=12.13.1 2 | 3 | # Build step 4 | 5 | FROM node:${NODE_VERSION} AS build 6 | 7 | ENV APPDIR /opt/app 8 | ENV NPM_CONFIG_LOGLEVEL error 9 | 10 | WORKDIR ${APPDIR} 11 | 12 | RUN apt-get update && \ 13 | apt-get install -y --no-install-recommends build-essential chrpath libssl-dev libxft-dev libfreetype6 libfreetype6-dev libfontconfig1 libfontconfig1-dev webp && \ 14 | wget -q https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 && \ 15 | tar -xjf phantomjs-2.1.1-linux-x86_64.tar.bz2 -C /usr/local/share/ && \ 16 | rm -rf phantomjs-2.1.1-linux-x86_64.tar.bz2 /var/lib/apt/lists/* && \ 17 | ln -sf /usr/local/share/phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/local/bin 18 | 19 | COPY ./package.json ./package-lock.json ./ 20 | RUN npm ci 21 | 22 | COPY . . 23 | RUN npm run build 24 | 25 | RUN rm -rf node_modules package.json package-lock.json build src babel.config.js .eslintrc.js 26 | 27 | # Production dependencies installation step 28 | 29 | FROM node:${NODE_VERSION}-alpine AS deps 30 | 31 | ENV APPDIR /opt/app 32 | ENV NPM_CONFIG_LOGLEVEL error 33 | 34 | WORKDIR ${APPDIR} 35 | 36 | COPY ./package.json ./package-lock.json ./ 37 | RUN npm ci --production 38 | RUN rm package.json package-lock.json 39 | 40 | # Ready to use application 41 | 42 | FROM node:${NODE_VERSION}-alpine 43 | 44 | ENV APPDIR /opt/app 45 | ENV NODE_ENV production 46 | 47 | WORKDIR ${APPDIR} 48 | RUN chown node ${APPDIR} 49 | 50 | COPY --from=deps --chown=node:node ${APPDIR} . 51 | COPY --from=build --chown=node:node ${APPDIR} . 52 | 53 | USER node 54 | EXPOSE 8080 55 | ENTRYPOINT ["node", "index.js"] 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Webpack, Vue, SSR project template 2 | 3 | Includes: 4 | 5 | * Webpack 4 6 | * [polka](https://github.com/lukeed/polka) web-server 7 | * Vue 2 with SSR, Vuex and vue-loader 8 | * Stylus with [kouto-swiss](http://kouto-swiss.io/) 9 | * Axios 10 | * Pug 11 | * SVG sprites builder 12 | * ESlint with pre-push hook 13 | 14 | ## Getting started 15 | 16 | ```bash 17 | npm i 18 | 19 | # development server on localhost:8080 20 | npm run dev 21 | 22 | # production build 23 | npm run build 24 | 25 | # production server on localhost:8080 26 | npm start 27 | ``` 28 | 29 | ## Configuration 30 | 31 | `.env.dev` contains environment variables used for local development. You can change application port, 32 | API base URL for server and client and enable/disable proxy (http-proxy-middleware). 33 | 34 | For production builds you should provide same environment variables yourself. 35 | Alternatively you can use `.env` after these steps: 36 | 1. Move `dotenv` from `devDependencies` to `dependencies`. 37 | 2. Create `.env` file with production config. 38 | 3. Run `npm start` or `NODE_ENV=production node -r dotenv/config index`. 39 | 40 | ## API proxy 41 | 42 | See `setup-proxy.js` for description. 43 | 44 | ## Application structure 45 | 46 | * `index.js` - application server 47 | * `setup-proxy.js` - `http-proxy-middleware` setup 48 | * `build/` - build related code 49 | * `setup-dev-server` - development server setup 50 | * `svg-sprite` - svg sprite generation script, gathers icons from `src/assets/svg-icons` and compiles them into `src/assets/sprite.svg` 51 | * `webpack/` - webpack config, `base` - common, `server` for server with SSR, `client` for browser 52 | * `dist/` - production build files 53 | * `src/` 54 | * `assets/` - application static assets (images, fonts, icons etc.) 55 | * `sprite.svg` - generated sprites file, `require('src/assets/sprite.svg')` will return file contents string 56 | * `fonts/` - guess what 57 | * `images/` - static images (backgrounds, patterns etc.) 58 | * `svg-icons/` - contains SVG icons for the sprite 59 | * `entry/` - main entry points 60 | * `app` - shared between server and client, exports a factory function returning root component instance, mixes it with `app.vue` 61 | * `client` - client entry 62 | * `server` - server entry 63 | * `components/` - vue components 64 | * `pages/` - components here are implicitly attached to routes same with componets\' file names 65 | (excluding leading `_` in file or folder names and `404.vue` which will be used as a catch-all route) 66 | * `filters/` - vue filters registered implicitly via `Vue.filter()` 67 | * `directives/` - vue directives registered implicitly via `Vue.directive()` 68 | * `store/` - Vuex storage, `index` returns a factory function returning configured Vuex store instance 69 | * `utils/` - common utility functions 70 | * `index` - common utility functions 71 | * `ssr` - SSR related functions and mixins 72 | * `app.vue` - application root component, implicitly mixed with `entry\app` 73 | * `http` - exports http client instance (Axios) 74 | * `layout.pug` - application HTML layout 75 | * `router` - exports a factory function returning vue-router instance 76 | * `shared.styl` - globally included stylus file (for variables, mixins, etc.) 77 | 78 | ## SSR related component features 79 | 80 | Every component within `src/pages` directory can use some special features providing full SSR support: 81 | 82 | * `component.routePath`, String - additional route suffix. Usually used to provide dynamic route segments. 83 | You can use any string allowed for the vue-router path definition. All dynamic segments are automatically mapped 84 | to component `props`. 85 | * `component.routeMeta`, Object - `route.meta`. Include `statusCode` here to modify an HTTP status returned with SSR. 86 | 404 route includes 404 status code by default. 87 | * `component.prefetch({ store, props, route })`, function 88 | (`store` - vuex store instance, `props` - route params, `route` - current route object). 89 | 90 | Must return a promise. Allows some async routine before actual application rendering on server side. 91 | To pass any data to component: resolve promise with needed data and add corresponding `component.data` fields with 92 | their initial values to prevent *...property or method not defined...* error. 93 | Automatically called on client side from `beforeMount` and `beforeRouteChange` hooks as well. 94 | See `src/utils/ssr` mixin. 95 | * Boolean `prefetching` data field indicates when prefetch is running. 96 | 97 | **IMPORTANT: there is no component context within `prefetch` function because component instance is not created yet! That means you should not try to use `this`.** 98 | 99 | `prefetch` also works on the root component (`src/app.vue`) with some restrictions: 100 | 101 | * no way to pass component data (only store can be affected). 102 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env'], 3 | plugins: ['@babel/plugin-transform-runtime', '@babel/plugin-syntax-dynamic-import'], 4 | }; 5 | -------------------------------------------------------------------------------- /build/setup-dev-server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const path = require('path'), 3 | webpack = require('webpack'), 4 | MFS = require('memory-fs'); 5 | 6 | const clientConfig = require('./webpack/client'), 7 | serverConfig = require('./webpack/server'); 8 | 9 | module.exports = (app, opts) => { 10 | // modify client config to work with hot middleware 11 | clientConfig.entry = ['webpack-hot-middleware/client', clientConfig.entry]; 12 | if (!clientConfig.output) clientConfig.output = {}; 13 | clientConfig.output.filename = '[name].js'; 14 | if (!clientConfig.plugins) clientConfig.plugins = []; 15 | clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin()); 16 | if (!clientConfig.optimization) clientConfig.optimization = {}; 17 | clientConfig.optimization.noEmitOnErrors = true; 18 | 19 | // dev middleware 20 | const clientCompiler = webpack(clientConfig), 21 | serverCompiler = webpack(serverConfig), 22 | devMiddleware = require('webpack-dev-middleware')(clientCompiler, { 23 | publicPath: clientConfig.output.publicPath, 24 | stats: clientConfig.stats || { 25 | colors: true, 26 | chunks: false, 27 | }, 28 | }), 29 | hotMiddleware = require('webpack-hot-middleware')(clientCompiler), 30 | mfs = new MFS(), 31 | layoutPath = path.join(clientConfig.output.path, 'index.html'), 32 | serverBundlePath = path.join(serverConfig.output.path, 'vue-ssr-server-bundle.json'); 33 | 34 | app.use(devMiddleware); 35 | app.use(hotMiddleware); 36 | 37 | serverCompiler.outputFileSystem = mfs; 38 | 39 | clientCompiler.hooks.done.tap('done', () => { 40 | if (devMiddleware.fileSystem.existsSync(layoutPath)) 41 | opts.layoutUpdated(devMiddleware.fileSystem.readFileSync(layoutPath, 'utf-8')); 42 | }); 43 | 44 | serverCompiler.watch({}, (err, stats) => { 45 | if (err) throw err; 46 | stats = stats.toJson(); 47 | stats.errors.forEach(err => console.error(err)); 48 | stats.warnings.forEach(err => console.warn(err)); 49 | opts.bundleUpdated(JSON.parse(mfs.readFileSync(serverBundlePath, 'utf-8'))); 50 | }); 51 | }; 52 | -------------------------------------------------------------------------------- /build/svg-sprite.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const fs = require('fs'), 3 | path = require('path'), 4 | SVGSpriter = require('svg-sprite'); 5 | 6 | const spritesDir = path.resolve(process.cwd(), 'src', 'assets', 'svg-icons'), 7 | outputDir = path.resolve(process.cwd(), 'src', 'assets'); 8 | 9 | const transform = { 10 | cleanupAttrs: true, 11 | removeDoctype: true, 12 | removeXMLProcInst: true, 13 | removeComments: true, 14 | removeMetadata: true, 15 | removeTitle: true, 16 | removeDesc: true, 17 | removeUselessDefs: true, 18 | removeXMLNS: true, 19 | removeEditorsNSData: true, 20 | removeEmptyAttrs: true, 21 | removeHiddenElems: true, 22 | removeEmptyText: true, 23 | removeEmptyContainers: true, 24 | cleanUpEnableBackground: true, 25 | minifyStyles: true, 26 | convertStyleToAttrs: true, 27 | convertPathData: true, 28 | convertTransform: true, 29 | removeUnknownsAndDefaults: true, 30 | removeNonInheritableGroupAttrs: true, 31 | removeUselessStrokeAndFill: true, 32 | removeUnusedNS: true, 33 | cleanupIDs: true, 34 | cleanupNumericValues: { floatPrecision: 1 }, 35 | cleanupListOfValues: { floatPrecision: 1 }, 36 | mergePath: true, 37 | convertShapeToPath: true, 38 | transformsWithOnePath: { floatPrecision: 1 }, 39 | removeDimensions: true, 40 | removeAttrs: { attrs: ['fill', 'stroke']}, 41 | removeStyleElement: true, 42 | collapseGroups: true, 43 | }; 44 | 45 | const spriter = new SVGSpriter({ 46 | dest: outputDir, 47 | log: process.env.NODE_ENV === 'production' ? null : 'debug', 48 | shape: { 49 | id: { 50 | generator: 'i-%s', 51 | }, 52 | transform: [ 53 | { svgo: { plugins: Object.keys(transform).map(k => ({ [k]: transform[k] })) } }, 54 | { svgo: { plugins: [ 55 | // collapseGroups is not recursive ( 56 | { collapseGroups: true }, 57 | ]} }, 58 | ], 59 | }, 60 | svg: { 61 | xmlDeclaration: false, 62 | doctypeDeclaration: false, 63 | dimensionAttributes: false, 64 | }, 65 | mode: { 66 | symbol: { 67 | dest: '.', 68 | sprite: 'sprite.svg', 69 | }, 70 | }, 71 | }); 72 | 73 | for (let f of fs.readdirSync(spritesDir)) { 74 | if (!f.endsWith('.svg')) continue; 75 | let p = spritesDir + '/' + f; 76 | spriter.add(p, f, fs.readFileSync(p), { encoding: 'utf-8' }); 77 | } 78 | 79 | spriter.compile((err, result) => { 80 | if (err) return console.error(err); 81 | for (let mode in result) { 82 | if (!result.hasOwnProperty(mode)) continue; 83 | for (let resource in result[mode]) { 84 | if (!result[mode].hasOwnProperty(resource)) continue; 85 | fs.writeFileSync(result[mode][resource].path, result[mode][resource].contents); 86 | } 87 | } 88 | }); 89 | -------------------------------------------------------------------------------- /build/webpack/base.js: -------------------------------------------------------------------------------- 1 | const path = require('path'), 2 | { VueLoaderPlugin } = require('vue-loader'); 3 | 4 | // shared loader options which will be slightly different on server/client 5 | const staticFileLoaders = { 6 | fonts: { 7 | test: /\.(woff|woff2|eot|otf|ttf)$/, 8 | options: { 9 | esModule: false, 10 | context: 'src/assets', 11 | name: '[path][name].[ext]?[hash:6]', 12 | }, 13 | }, 14 | images: { 15 | test: /\.(png|jpe?g|gif|svg)$/, 16 | options: { 17 | esModule: false, 18 | context: 'src/assets', 19 | limit: 256, 20 | name: '[path][name].[ext]?[hash:6]', 21 | }, 22 | }, 23 | docs: { 24 | test: /\.(pdf|docx?|pptx?|rtf|txt)$/, 25 | options: { 26 | esModule: false, 27 | context: 'src/assets', 28 | name: '[path][name].[ext]?[hash:6]', 29 | }, 30 | }, 31 | }, 32 | pugOptions = { 33 | doctype: 'html', 34 | basedir: process.cwd(), 35 | }; 36 | 37 | exports.staticFileLoaders = staticFileLoaders; 38 | 39 | exports.createConfig = (runtimeEnv) => { 40 | const vueLoader = { 41 | test: /\.vue$/, 42 | loader: 'vue-loader', 43 | options: {}, 44 | }; 45 | 46 | const config = { 47 | devtool: false, 48 | mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', 49 | output: { 50 | publicPath: '/dist/', 51 | filename: '[name].[chunkhash:8].js', 52 | chunkFilename: '[name].[chunkhash:8].js', 53 | }, 54 | module: { 55 | rules: [ 56 | 57 | // source files 58 | 59 | { 60 | test: /\.js$/, 61 | loader: 'babel-loader', 62 | // needed for vue-loader to correctly import modules' components 63 | exclude: file => /node_modules/.test(file) && !/\.vue\.js/.test(file), 64 | }, 65 | vueLoader, 66 | { 67 | test: /\.pug$/, 68 | oneOf: [ 69 | // this applies to