diff --git a/package.json b/package.json index faa2b68..9f68e73 100644 --- a/package.json +++ b/package.json @@ -2,39 +2,47 @@ "name": "@diplodoc/client", "version": "1.2.0", "description": "", - "main": "build/app.server.js", + "main": "./build/server/app.js", "scripts": { "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", "lint:fix": "npm run lint -- --fix", - "build": "webpack && tsc --emitDeclarationOnly --outDir build", + "build": "NODE_ENV=production webpack && tsc --emitDeclarationOnly --outDir build", + "build:dev": "NODE_ENV=development webpack", + "build:watch": "NODE_ENV=development webpack --watch", "prepublishOnly": "rm -rf build && npm ci --no-workspaces && npm run build", "test": "exit 0" }, "author": "", "license": "ISC", - "types": "./build/index.d.ts", "engines": { "node": ">=18", "npm": ">=9.*" }, "exports": { ".": { - "node": "./build/app.server.js", "types": "./build/index.d.ts", - "style": "./build/app.client.css", - "default": "./build/app.client.js" + "style": "./build/client/app.css", + "default": "./build/client/app.js" }, - "./styles": "./build/app.client.css" + "./ssr": { + "types": "./build/index.server.d.ts", + "default": "./build/server/app.js" + }, + "./manifest": "./build/client/manifest.json", + "./styles": "./build/client/app.css" }, "dependencies": { "@diplodoc/components": "^3.5.1", "@diplodoc/mermaid-extension": "^1.2.1", "@diplodoc/openapi-extension": "^1.4.10", "@diplodoc/transform": "^4.7.2", + "@gravity-ui/page-constructor": "^4.24.0", "@gravity-ui/uikit": "^5.25.0", + "bem-cn-lite": "^4.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "url": "^0.11.0" + "url": "^0.11.0", + "webpack-manifest-plugin": "^5.0.0" }, "devDependencies": { "@diplodoc/babel-preset": "^1.0.2", diff --git a/src/components/App/App.scss b/src/components/App/App.scss index 83dd8fa..7b2082e 100644 --- a/src/components/App/App.scss +++ b/src/components/App/App.scss @@ -4,13 +4,30 @@ @import '@diplodoc/openapi-extension/runtime/styles'; @import '../../styles/default.scss'; @import '../../styles/typography.scss'; +@import '../../styles/overrides.scss'; .App { margin: 0; min-height: 100vh; } +.Controls { + height: 100%; + + .pc-mobile-navigation & { + margin-left: -14px; + } +} + .g-root { --dc-header-height: 0px; --mermaid-zoom-control-color: var(--g-color-text-primary); + --pc-first-block-indent: 0px; + --pc-first-block-mobile-indent: 0px; + + .dc-root_full-header & { + --dc-header-height: 64px; + } } + + diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx index e47eacb..302d623 100644 --- a/src/components/App/App.tsx +++ b/src/components/App/App.tsx @@ -1,5 +1,10 @@ -import React, {ReactElement, useCallback, useEffect, useState} from 'react'; +import React, {ReactElement, useEffect} from 'react'; +import { + NavigationData, + PageConstructor, + PageConstructorProvider, +} from '@gravity-ui/page-constructor'; import { DocLeadingPage, DocLeadingPageData, @@ -7,10 +12,13 @@ import { DocPageData, Lang, Router, - TextSizes, Theme, } from '@diplodoc/components'; -import {getDocSettings, updateRootClassName, withSavingSetting} from '../../utils'; +import {HeaderControls} from '../HeaderControls'; +import {updateRootClassName} from '../../utils'; +import {Layout} from '../Layout'; +import {useSettings} from '../../hooks/useSettings'; +import {useMobile} from '../../hooks/useMobile'; import '../../interceptors/leading-page-links'; @@ -25,64 +33,123 @@ export interface AppProps { router: Router; } -export type DocInnerProps = {data: Data} & AppProps; +export type DocInnerProps = { + data: Data; +} & AppProps; export type {DocLeadingPageData, DocPageData}; -const MOBILE_VIEW_WIDTH_BREAKPOINT = 900; +function Page(props: DocInnerProps) { + const {data, ...pageProps} = props; + + const Page = data.leading ? DocLeadingPage : DocPage; + + return ( + + + {/*@ts-ignore*/} + + + + ); +} + +type TocData = DocPageData['toc'] & { + navigation?: NavigationData; +}; export function App(props: DocInnerProps): ReactElement { const {data, router, lang} = props; + const {navigation} = data.toc as TocData; - const docSettings = getDocSettings(); - const [isMobileView, setIsMobileView] = useState( - typeof document !== 'undefined' && - document.body.clientWidth <= MOBILE_VIEW_WIDTH_BREAKPOINT, - ); - const [wideFormat, setWideFormat] = useState(docSettings.wideFormat); - const [fullScreen, setFullScreen] = useState(docSettings.fullScreen); - const [showMiniToc, setShowMiniToc] = useState(docSettings.showMiniToc); - const [theme, setTheme] = useState(docSettings.theme); - const [textSize, setTextSize] = useState(docSettings.textSize); + const settings = useSettings(); + const mobileView = useMobile(); + + const {theme, textSize, wideFormat, fullScreen, showMiniToc, onChangeFullScreen} = settings; + const fullHeader = !fullScreen && Boolean(navigation); + const headerHeight = fullHeader ? 64 : 0; const pageProps = { + headerHeight, + data, router, lang, - headerHeight: 0, wideFormat, - fullScreen, showMiniToc, theme, textSize, - onChangeFullScreen: withSavingSetting('fullScreen', setFullScreen), - onChangeWideFormat: withSavingSetting('wideFormat', setWideFormat), - onChangeShowMiniToc: withSavingSetting('showMiniToc', setShowMiniToc), - onChangeTheme: withSavingSetting('theme', setTheme), - onChangeTextSize: withSavingSetting('textSize', setTextSize), + fullScreen, + onChangeFullScreen, }; - const onResizeHandler = useCallback(() => { - setIsMobileView(document.body.clientWidth <= MOBILE_VIEW_WIDTH_BREAKPOINT); - }, []); + const rebase = (item: any) => { + if (item.type !== 'link') { + return item; + } + + return { + ...item, + url: item.url.replace(/^\/?/, '/'), + }; + }; useEffect(() => { - window.addEventListener('resize', onResizeHandler); + updateRootClassName({ + theme, + mobileView, + wideFormat, + fullHeader, + }); + }, [theme, mobileView, wideFormat, fullHeader]); - return () => window.removeEventListener('resize', onResizeHandler); - }, []); + if (!navigation) { + return ( +
+ +
+ ); + } - useEffect(() => { - updateRootClassName(theme, isMobileView); - }, [theme, isMobileView]); + const {header = {}, logo} = navigation; + const {leftItems = [], rightItems = []} = header as NavigationData['header']; + const headerWithControls = rightItems.some((item: {type: string}) => item.type === 'controls'); return ( - // TODO(vladimirfedin): Replace Layout__content class. -
- {data.leading ? ( - - ) : ( - // @ts-ignore - - )} +
+ + ( + + ), + }, + blocks: { + page: () => ( + + ), + }, + }} + content={{ + blocks: [ + { + type: 'page', + }, + ], + }} + navigation={ + fullHeader + ? { + header: { + withBorder: true, + leftItems: leftItems.map(rebase), + rightItems: rightItems.map(rebase), + }, + logo, + } + : undefined + } + /> + void; + +type Props = { + mobileView: boolean; + + theme: Theme; + onChangeTheme: OnChangeCallback; + textSize: TextSizes; + onChangeTextSize: OnChangeCallback; + wideFormat: boolean; + onChangeWideFormat: OnChangeCallback; + showMiniToc: boolean; + onChangeShowMiniToc: OnChangeCallback; +}; + +export const HeaderControls = memo( + ({ + mobileView, + + theme, + onChangeTheme, + + textSize, + onChangeTextSize, + + wideFormat, + onChangeWideFormat, + + showMiniToc, + onChangeShowMiniToc, + }) => { + return ( + + + + ); + }, +); + +HeaderControls.displayName = 'HeaderControls'; diff --git a/src/components/Layout/Layout.scss b/src/components/Layout/Layout.scss new file mode 100644 index 0000000..c92bc6b --- /dev/null +++ b/src/components/Layout/Layout.scss @@ -0,0 +1,56 @@ +.Layout { + &__header { + position: sticky; + top: 0; + z-index: 150; + } + + &__body { + display: flex; + flex-flow: column; + min-height: calc(100vh - var(--dc-header-height)); + } + + .pc-page-constructor & { + &__content { + flex: 1 1 auto; + margin: 0 -10px; + + .desktop.dc-root_wide-format & { + margin: 0px -30px 0 -48px; + } + + .mobile.dc-root_wide-format & { + margin: 0px -28px; + } + } + } + + &__footer { + position: relative; + z-index: 90; + margin-top: 0; + flex: 0 0 auto; + } + + .mobile & { + &__footer { + margin-top: 70px; + + &_doc { + margin-top: 0; + } + } + } + + @media only screen and (min-width: 1920px) { + &__body { + margin-left: calc(100vw - 100%); + margin-right: 0; + + .pc-page-constructor & { + margin: 0; + } + } + } +} diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx new file mode 100644 index 0000000..d7187d9 --- /dev/null +++ b/src/components/Layout/index.tsx @@ -0,0 +1,63 @@ +import React, {FC, PropsWithChildren, ReactElement} from 'react'; +import block from 'bem-cn-lite'; + +import './Layout.scss'; + +const b = block('Layout'); + +function Header() { + return null; +} + +function Content() { + return null; +} + +function Footer() { + return null; +} + +type LayoutStatics = { + Header: FC; + Content: FC; + Footer: FC; +}; + +export const Layout: LayoutStatics & FC> = (props) => { + const {children, doc} = props; + let header, content, footer; + + React.Children.forEach(children as ReactElement[], (child: ReactElement) => { + switch (child.type) { + case Header: + header = child.props.children; + break; + case Content: + content = child.props.children; + break; + case Footer: + footer = child.props.children; + break; + } + }); + + return ( +
+ {header &&
{header}
} +
+ {content &&
{content}
} + {footer &&
{footer}
} +
+
+ ); +}; + +Layout.displayName = 'Layout'; + +Layout.defaultProps = { + doc: false, +}; + +Layout.Header = Header; +Layout.Content = Content; +Layout.Footer = Footer; diff --git a/src/hooks/useMobile.ts b/src/hooks/useMobile.ts new file mode 100644 index 0000000..c3fc867 --- /dev/null +++ b/src/hooks/useMobile.ts @@ -0,0 +1,23 @@ +import {useCallback, useEffect, useState} from 'react'; + +const MOBILE_VIEW_WIDTH_BREAKPOINT = 769; + +export function useMobile() { + const [mobileView, setMobileView] = useState( + typeof document !== 'undefined' && document.body.clientWidth < MOBILE_VIEW_WIDTH_BREAKPOINT, + ); + + const onResizeHandler = useCallback(() => { + setMobileView(document.body.clientWidth < MOBILE_VIEW_WIDTH_BREAKPOINT); + }, []); + + useEffect(onResizeHandler, [onResizeHandler]); + + useEffect(() => { + window.addEventListener('resize', onResizeHandler); + + return () => window.removeEventListener('resize', onResizeHandler); + }, [onResizeHandler]); + + return mobileView; +} diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts new file mode 100644 index 0000000..0e7c2cc --- /dev/null +++ b/src/hooks/useSettings.ts @@ -0,0 +1,79 @@ +import {strToBoolean} from '../utils'; +import {useState} from 'react'; + +import {TextSizes, Theme} from '@diplodoc/components'; + +const DEFAULT_USER_SETTINGS = { + theme: Theme.Light, + textSize: TextSizes.M, + showMiniToc: true, + wideFormat: true, + fullScreen: false, +}; + +export function useSettings() { + const settings = getSettings(); + + const [wideFormat, setWideFormat] = useState(settings.wideFormat); + const [fullScreen, setFullScreen] = useState(settings.fullScreen); + const [showMiniToc, setShowMiniToc] = useState(settings.showMiniToc); + const [theme, setTheme] = useState(settings.theme); + const [textSize, setTextSize] = useState(settings.textSize); + + return { + theme, + onChangeTheme: withSavingSetting('theme', setTheme), + textSize, + onChangeTextSize: withSavingSetting('textSize', setTextSize), + wideFormat, + onChangeWideFormat: withSavingSetting('wideFormat', setWideFormat), + showMiniToc, + onChangeShowMiniToc: withSavingSetting('showMiniToc', setShowMiniToc), + fullScreen, + onChangeFullScreen: withSavingSetting('fullScreen', setFullScreen), + }; +} + +function getSettings() { + const theme = getSetting('theme'); + const textSize = getSetting('textSize'); + const showMiniToc = getSetting('showMiniToc'); + const wideFormat = getSetting('wideFormat'); + const fullScreen = getSetting('fullScreen'); + + return { + theme, + textSize, + showMiniToc: strToBoolean(showMiniToc), + wideFormat: strToBoolean(wideFormat), + fullScreen: strToBoolean(fullScreen), + }; +} + +type TSettings = typeof DEFAULT_USER_SETTINGS; + +function getSetting(name: T): TSettings[T] { + if (typeof sessionStorage === 'undefined') { + return DEFAULT_USER_SETTINGS[name]; + } + + try { + return (sessionStorage.getItem(name) as TSettings[T]) || DEFAULT_USER_SETTINGS[name]; + } catch { + return DEFAULT_USER_SETTINGS[name]; + } +} + +function setSetting(name: string, value: T) { + try { + sessionStorage.setItem(name, String(value)); + } catch {} +} + +function withSavingSetting(settingName: string, onChange: (value: T) => void) { + return (value: T) => { + setSetting(settingName, value); + + onChange(value); + }; +} diff --git a/src/index.server.tsx b/src/index.server.tsx new file mode 100644 index 0000000..cf87514 --- /dev/null +++ b/src/index.server.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import {renderToString} from 'react-dom/server'; + +import {App, DocInnerProps, DocLeadingPageData, DocPageData} from './components/App/App'; + +export type {DocInnerProps, DocPageData, DocLeadingPageData}; + +export const render = (props: DocInnerProps) => renderToString(); diff --git a/src/index.tsx b/src/index.tsx index 948a3a2..b5e6236 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,5 @@ import React from 'react'; import {createRoot, hydrateRoot} from 'react-dom/client'; -import {renderToString} from 'react-dom/server'; import {App, DocInnerProps, DocLeadingPageData, DocPageData} from './components/App/App'; @@ -9,27 +8,19 @@ export type {DocInnerProps, DocPageData, DocLeadingPageData}; declare global { interface Window { STATIC_CONTENT: boolean; + __DATA__: DocInnerProps; } } -let render: (props: DocInnerProps) => string; +const root = document.getElementById('root'); +const props = window.__DATA__; -if (process.env.BROWSER) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const root = document.getElementById('root'); - const props = (window as any).__DATA__ || {}; - - if (!root) { - throw new Error('Root element not found!'); - } +if (!root) { + throw new Error('Root element not found!'); +} - if (window.STATIC_CONTENT) { - hydrateRoot(root, ); - } else { - createRoot(root).render(); - } +if (window.STATIC_CONTENT) { + hydrateRoot(root, ); } else { - render = (props: DocInnerProps) => renderToString(); + createRoot(root).render(); } - -export {render}; diff --git a/src/stub/empty-module.js b/src/stub/empty-module.js new file mode 100644 index 0000000..ea9b101 --- /dev/null +++ b/src/stub/empty-module.js @@ -0,0 +1 @@ +export default function () {} diff --git a/src/styles/default.scss b/src/styles/default.scss index 0a39cf7..32f8097 100644 --- a/src/styles/default.scss +++ b/src/styles/default.scss @@ -9,26 +9,45 @@ body { margin: 0; padding: 0; box-sizing: border-box; +} +.g-root { --dc-header-height: 0; --dc-subheader-height: 40px; --dc-error-image-403: url('data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='); --dc-error-image-404: url('data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='); --dc-error-image-500: url('data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='); + --g-scrollbar-width: 6px; + + &::-webkit-scrollbar, + *::-webkit-scrollbar { + height: var(--g-scrollbar-width); + background: transparent; + } - .g-root { - --g-scrollbar-width: 6px; + &::-webkit-scrollbar-track, + *::-webkit-scrollbar-track { + background: transparent; + } +} - &::-webkit-scrollbar, - *::-webkit-scrollbar { - height: var(--g-scrollbar-width); - background: transparent; +.pc-navigation { + z-index: 101; + + .desktop.dc-root_wide-format & .container-fluid { + padding: 20px; + + & .col { + padding: 0; } + } + + .mobile.dc-root_wide-format & .container-fluid { + padding: 12px; - &::-webkit-scrollbar-track, - *::-webkit-scrollbar-track { - background: transparent; + & .col { + padding: 0; } } } diff --git a/src/styles/overrides.scss b/src/styles/overrides.scss new file mode 100644 index 0000000..282d142 --- /dev/null +++ b/src/styles/overrides.scss @@ -0,0 +1,53 @@ +@media only screen and (max-width: 577px) { + .pc-block-base.pc-block-base.pc-block-base:first-child { + margin-top: var(--pc-first-block-mobile-indent, 64px); + } +} + +.pc-block-base.pc-block-base.pc-block-base:first-child { + margin-top: var(--pc-first-block-indent, 96px); +} + +.pc-block-base { + padding: 0 !important; +} + +.pc-navigation { + z-index: 101; + + .desktop .dc-root_wide-format & .container-fluid { + padding: 0; + + & .col { + padding: 0; + } + } + + .mobile .dc-root_wide-format & .container-fluid { + padding: 12px; + + & .col { + padding: 0; + } + } +} + +.pc-navigation-popup { + background: var(--g-color-base-background); +} + +.pc-desktop-navigation { + &__right { + flex: 0 1 auto; + } + + &__buttons { + flex-basis: 100%; + justify-content: end; + } + + &__item { + width: 100%; + transition: width 0.3s; + } +} diff --git a/src/utils.ts b/src/utils.ts index 89dfe41..bec1654 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,50 +1,4 @@ -import {TextSizes, Theme} from '@diplodoc/components'; - -const DEFAULT_USER_SETTINGS = { - theme: Theme.Light, - textSize: TextSizes.M, - showMiniToc: true, - wideFormat: true, - fullScreen: false, -}; - -export function getDocSettings() { - const { - theme: defaultTheme, - textSize: defaultTextSize, - showMiniToc: defaultShowMiniToc, - wideFormat: defaultWideFormat, - fullScreen: defaultFullScreen, - } = DEFAULT_USER_SETTINGS; - - const theme = (getSetting('theme') as Theme) || defaultTheme; - const textSize = (getSetting('textSize') as TextSizes) || defaultTextSize; - const showMiniToc = getSetting('showMiniToc') || defaultShowMiniToc; - const wideFormat = getSetting('wideFormat') || defaultWideFormat; - const fullScreen = getSetting('fullScreen') || defaultFullScreen; - - return { - theme, - textSize, - showMiniToc: strToBoolean(showMiniToc), - wideFormat: strToBoolean(wideFormat), - fullScreen: strToBoolean(fullScreen), - }; -} - -export function getSetting(name: string) { - if (typeof sessionStorage === 'undefined') { - return null; - } - return sessionStorage.getItem(name); -} - -export function saveSetting(name: string, value: T) { - if (typeof sessionStorage === 'undefined') { - return; - } - sessionStorage.setItem(name, String(value)); -} +import {Theme} from '@diplodoc/components'; export function strToBoolean(str: string | boolean) { if (typeof str === 'boolean') { @@ -54,17 +8,25 @@ export function strToBoolean(str: string | boolean) { return str ? str === 'true' : false; } -export function withSavingSetting(settingName: string, onChange: (value: T) => void) { - return (value: T) => { - saveSetting(settingName, value); - - onChange(value); - }; -} - -export function updateRootClassName(theme: Theme, isMobile = false) { - const themeClassName = theme === 'light' ? 'g-root_theme_light' : 'g-root_theme_dark'; - const mobileClassName = isMobile ? 'mobile' : ''; - - document.body.className = `g-root ${themeClassName} ${mobileClassName}`; +export function updateRootClassName({ + theme, + mobileView = false, + wideFormat = false, + fullHeader = false, +}: { + theme: Theme; + mobileView: boolean; + wideFormat: boolean; + fullHeader: boolean; +}) { + document.body.className = [ + 'g-root', + mobileView ? 'mobile' : 'desktop', + wideFormat && 'dc-root_wide-format', + fullHeader && 'dc-root_full-header', + theme === 'light' && 'g-root_theme_light', + theme === 'dark' && 'g-root_theme_dark', + ] + .filter(Boolean) + .join(' '); } diff --git a/webpack.config.js b/webpack.config.js index 8c60461..0fa7f19 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,17 +1,26 @@ const {resolve} = require('path'); const {DefinePlugin} = require('webpack'); const MiniCSSExtractPlugin = require('mini-css-extract-plugin'); +const {WebpackManifestPlugin} = require('webpack-manifest-plugin'); const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer'); -function config({isServer}) { +function config({isServer, isDev, analyze = false}) { + const mode = isServer ? 'server' : 'client'; + return { - mode: 'production', + mode: isDev ? 'development' : 'production', target: isServer ? 'node' : 'web', devtool: 'source-map', - entry: './src/index.tsx', + entry: { + app: isServer ? './src/index.server.tsx' : './src/index.tsx', + }, + cache: isDev && { + type: 'filesystem', + cacheDirectory: resolve(`cache/${mode}`) + }, output: { - path: resolve(__dirname, 'build'), - filename: `app.${isServer ? 'server' : 'client'}.js`, + path: resolve(__dirname, 'build', mode), + filename: `[name].js`, ...(isServer ? { libraryTarget: 'commonjs2' } : {}) @@ -19,24 +28,62 @@ function config({isServer}) { resolve: { alias: { 'react': require.resolve('react'), + 'react-player': require.resolve('./src/stub/empty-module'), }, - extensions: ['.tsx', '.ts', '.js', '.scss'], + fallback: { + stream: false, + crypto: false, + }, + extensions: (isServer + ? ['.server.tsx', '.server.ts', '.server.js'] + : [] + ).concat(['.tsx', '.ts', '.js', '.scss']), + }, + externals: isServer ? [ + '@diplodoc/transform/dist/js/yfm' + ] : [], + optimization: { + minimize: !isServer, + splitChunks: { + chunks: 'async', + cacheGroups: { + react: { + test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/, + name: 'react', + chunks: 'all', + }, + vendor: { + test: /[\\/]node_modules[\\/]/, + name: 'vendor', + chunks: 'all', + }, + }, + } }, - externals: isServer ? ['@diplodoc/transform/dist/js/yfm'] : [], plugins: [ new DefinePlugin({ 'process.env': { BROWSER: !isServer } }), - // new BundleAnalyzerPlugin({ - // analyzerMode: 'static', - // openAnalyzer: false, - // reportFilename: (isServer ? 'server-' : 'client-') + 'stats.html', - // }), + analyze && new BundleAnalyzerPlugin({ + analyzerMode: 'static', + openAnalyzer: false, + reportFilename: `stats.html`, + }), new MiniCSSExtractPlugin({ - filename: 'app.client.css', - chunkFilename: 'app.client.css', + filename: `[name].css`, + }), + new WebpackManifestPlugin({ + generate: (seed, files) => { + const name = ({name}) => name; + const endsWith = (tail) => ({name}) => name.endsWith(tail); + const runtimeLast = (a, b) => b.chunk.id - a.chunk.id; + return { + js: files.filter(endsWith('.js')).sort(runtimeLast).map(name), + css: files.filter(endsWith('.css')).sort(runtimeLast).map(name), + }; + } }), ].filter(Boolean), module: { @@ -51,7 +98,12 @@ function config({isServer}) { }, { test: /\.s?css$/, use: [ - MiniCSSExtractPlugin.loader, + { + loader: MiniCSSExtractPlugin.loader, + options: { + emit: !isServer + } + }, {loader: 'css-loader'}, {loader: 'sass-loader'}, ], @@ -65,6 +117,6 @@ function config({isServer}) { } module.exports = [ - config({ isServer: false }), - config({ isServer: true }), + config({ isServer: false, isDev: process.env.NODE_ENV === 'development' }), + config({ isServer: true, isDev: process.env.NODE_ENV === 'development' }), ];