feat(v2): implement theme component overriding (#1435)

* feat(v2): implement component overriding

* siteDir theme overriding should work for > 1 level directory

* fallback for essential component like Loading

* rename default -> classic
This commit is contained in:
Yangshun Tay 2019-05-06 05:25:04 -07:00 committed by Endi
parent 1697f9cebb
commit eedd4c481c
38 changed files with 529 additions and 202 deletions

View file

@ -16,6 +16,11 @@
"loader-utils": "^1.2.3"
},
"peerDependencies": {
"@docusaurus/core": "^2.0.0"
"@docusaurus/core": "^2.0.0",
"react": "^16.8.4",
"react-dom": "^16.8.4"
},
"engines": {
"node": ">=8"
}
}

View file

@ -158,6 +158,22 @@ class DocusaurusPluginContentBlog {
configureWebpack(config, isServer, {getBabelLoader, getCacheLoader}) {
return {
resolve: {
alias: {
'@theme/BlogListPage': path.resolve(
__dirname,
'./theme/BlogListPage',
),
'@theme/BlogPostItem': path.resolve(
__dirname,
'./theme/BlogPostItem',
),
'@theme/BlogPostPage': path.resolve(
__dirname,
'./theme/BlogPostPage',
),
},
},
module: {
rules: [
{

View file

@ -8,7 +8,7 @@
import React from 'react';
import Layout from '@theme/Layout'; // eslint-disable-line
import BlogPostItem from '../BlogPostItem';
import BlogPostItem from '@theme/BlogPostItem';
function BlogListPage(props) {
const {

View file

@ -8,7 +8,7 @@
import React from 'react';
import Layout from '@theme/Layout'; // eslint-disable-line
import BlogPostItem from '../BlogPostItem';
import BlogPostItem from '@theme/BlogPostItem';
function BlogPostPage(props) {
const {content: BlogPostContents, metadata} = props;

View file

@ -18,6 +18,12 @@
"loader-utils": "^1.2.3"
},
"peerDependencies": {
"@docusaurus/core": "^2.0.0"
"@docusaurus/core": "^2.0.0",
"react": "^16.8.4",
"react-dom": "^16.8.4",
"react-router-config": "^5.0.0"
},
"engines": {
"node": ">=8"
}
}

View file

@ -153,6 +153,17 @@ class DocusaurusPluginContentDocs {
configureWebpack(config, isServer, {getBabelLoader, getCacheLoader}) {
return {
resolve: {
alias: {
'@theme/DocItem': path.resolve(__dirname, './theme/DocItem'),
'@theme/DocPage': path.resolve(__dirname, './theme/DocPage'),
'@theme/DocPaginator': path.resolve(
__dirname,
'./theme/DocPaginator',
),
'@theme/DocSidebar': path.resolve(__dirname, './theme/DocSidebar'),
},
},
module: {
rules: [
{

View file

@ -9,7 +9,7 @@ import React from 'react';
import Head from '@docusaurus/Head';
import DocPaginator from '../DocPaginator';
import DocPaginator from '@theme/DocPaginator';
import styles from './styles.module.css';

View file

@ -10,7 +10,7 @@ import {renderRoutes} from 'react-router-config';
import Layout from '@theme/Layout'; // eslint-disable-line
import DocSidebar from '../DocSidebar';
import DocSidebar from '@theme/DocSidebar';
function DocPage(props) {
const {route, docsMetadata, location} = props;

View file

@ -12,6 +12,11 @@
"globby": "^9.1.0"
},
"peerDependencies": {
"@docusaurus/core": "^2.0.0"
"@docusaurus/core": "^2.0.0",
"react": "^16.8.4",
"react-dom": "^16.8.4"
},
"engines": {
"node": ">=8"
}
}

View file

@ -7,6 +7,11 @@
module.exports = function preset(context, opts = {}) {
return {
themes: [
{
name: '@docusaurus/theme-classic',
},
],
plugins: [
{
name: '@docusaurus/plugin-content-docs',

View file

@ -0,0 +1,21 @@
{
"name": "@docusaurus/theme-classic",
"version": "2.0.0-alpha.13",
"description": "Classic theme for Docusaurus",
"main": "src/index.js",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"dependencies": {
"docsearch.js": "^2.5.2"
},
"peerDependencies": {
"@docusaurus/core": "^2.0.0",
"react": "^16.8.4",
"react-dom": "^16.8.4"
},
"engines": {
"node": ">=8"
}
}

View file

@ -0,0 +1,36 @@
/**
* Copyright (c) 2017-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const path = require('path');
const DEFAULT_OPTIONS = {};
class DocusaurusThemeDefault {
constructor(context, opts) {
this.options = {...DEFAULT_OPTIONS, ...opts};
this.context = context;
}
getName() {
return 'docusaurus-theme-classic';
}
configureWebpack() {
return {
resolve: {
alias: {
'@theme/Footer': path.resolve(__dirname, './theme/Footer'),
'@theme/Navbar': path.resolve(__dirname, './theme/Navbar'),
'@theme/NotFound': path.resolve(__dirname, './theme/NotFound'),
'@theme/Search': path.resolve(__dirname, './theme/Search'),
},
},
};
}
}
module.exports = DocusaurusThemeDefault;

View file

@ -0,0 +1,28 @@
/**
* Copyright (c) 2017-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import Head from '@docusaurus/Head'; // eslint-disable-line
import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; // eslint-disable-line
function Layout(props) {
const context = useDocusaurusContext();
const {siteConfig = {}} = context;
const {baseUrl, favicon, tagline, title: defaultTitle} = siteConfig;
const {children, title} = props;
return (
<React.Fragment>
<Head defaultTitle={`${defaultTitle} · ${tagline}`}>
{title && <title>{`${title} · ${tagline}`}</title>}
{favicon && <link rel="shortcut icon" href={baseUrl + favicon} />}
</Head>
{children}
</React.Fragment>
);
}
export default Layout;

View file

@ -0,0 +1,110 @@
/**
* Copyright (c) 2017-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
export default props => {
if (props.error) {
console.warn(props.error);
return <div align="center">Error</div>;
}
if (props.pastDelay) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}>
<svg
id="loader"
style={{
width: 128,
height: 110,
position: 'absolute',
top: 'calc(100vh - 64%)',
}}
viewBox="0 0 45 45"
xmlns="http://www.w3.org/2000/svg"
stroke="#61dafb">
<g
fill="none"
fillRule="evenodd"
transform="translate(1 1)"
strokeWidth="2">
<circle cx="22" cy="22" r="6" strokeOpacity="0">
<animate
attributeName="r"
begin="1.5s"
dur="3s"
values="6;22"
calcMode="linear"
repeatCount="indefinite"
/>
<animate
attributeName="stroke-opacity"
begin="1.5s"
dur="3s"
values="1;0"
calcMode="linear"
repeatCount="indefinite"
/>
<animate
attributeName="stroke-width"
begin="1.5s"
dur="3s"
values="2;0"
calcMode="linear"
repeatCount="indefinite"
/>
</circle>
<circle cx="22" cy="22" r="6" strokeOpacity="0">
<animate
attributeName="r"
begin="3s"
dur="3s"
values="6;22"
calcMode="linear"
repeatCount="indefinite"
/>
<animate
attributeName="stroke-opacity"
begin="3s"
dur="3s"
values="1;0"
calcMode="linear"
repeatCount="indefinite"
/>
<animate
attributeName="stroke-width"
begin="3s"
dur="3s"
values="2;0"
calcMode="linear"
repeatCount="indefinite"
/>
</circle>
<circle cx="22" cy="22" r="8">
<animate
attributeName="r"
begin="0s"
dur="1.5s"
values="6;1;2;3;4;5;6"
calcMode="linear"
repeatCount="indefinite"
/>
</circle>
</g>
</svg>
</div>
);
}
return null;
};

View file

@ -0,0 +1,28 @@
/**
* Copyright (c) 2017-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import Layout from '@theme/Layout';
function NotFound() {
return (
<Layout title="Page Not Found">
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '50vh',
fontSize: '20px',
}}>
<h1>Oops, page not found </h1>
</div>
</Layout>
);
}
export default NotFound;

View file

@ -84,7 +84,7 @@ module.exports = async function build(siteDir, cliOptions = {}) {
);
});
// Run webpack to build js bundle (client) and static html files (server) !!
// Run webpack to build JS bundle (client) and static html files (server).
await compile([clientConfig, serverConfig]);
// Remove server.bundle.js because it is useless

View file

@ -1,28 +0,0 @@
/**
* Copyright (c) 2017-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import styles from './styles.module.css';
export default props => {
if (props.error) {
console.warn(props.error);
return <div align="center">Error</div>;
}
if (props.pastDelay) {
return (
<div className={styles.loader}>
<p>Please wait a moment</p>
<div className={styles.loaderSpinning} />
</div>
);
}
return null;
};

View file

@ -1,30 +0,0 @@
/**
* Copyright (c) 2017-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
.loader {
padding: 20px;
text-align: center;
}
.loaderSpinning {
width: 49px;
height: 49px;
margin: 0 auto;
border: 5px solid #ccc;
border-radius: 50%;
border-top: 5px solid #1d4d8b;
animation: loader-spin infinite 1s linear;
}
@keyframes loader-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View file

@ -31,10 +31,18 @@ module.exports = async function load(siteDir, cliOptions = {}) {
const context = {siteDir, generatedFilesDir, siteConfig, cliOptions};
// Process presets.
const presetPlugins = loadPresets(context);
const {plugins: presetPlugins, themes: presetThemes} = loadPresets(context);
// Process plugins and themes. Themes are also plugins, but they run after all
// the explicit plugins because they may override the resolve.alias(es)
// defined by the plugins.
const pluginConfigs = [
...presetPlugins,
...(siteConfig.plugins || []),
...presetThemes,
...(siteConfig.themes || []),
];
// Process plugins.
const pluginConfigs = [...presetPlugins, ...siteConfig.plugins];
const {plugins, pluginsRouteConfigs} = await loadPlugins({
pluginConfigs,
context,
@ -43,8 +51,18 @@ module.exports = async function load(siteDir, cliOptions = {}) {
const outDir = path.resolve(siteDir, 'build');
const {baseUrl} = siteConfig;
// Resolve theme. TBD (Experimental)
const themePath = loadTheme(siteDir);
// Resolve custom theme override aliases.
const themeAliases = await loadTheme(siteDir);
// Make a fake plugin to resolve user's theme overrides.
if (themeAliases != null) {
plugins.push({
configureWebpack: () => ({
resolve: {
alias: themeAliases,
},
}),
});
}
// Routing
const {
@ -81,7 +99,6 @@ ${Object.keys(registry)
siteConfig,
siteDir,
outDir,
themePath,
baseUrl,
generatedFilesDir,
routesPaths,

View file

@ -0,0 +1,16 @@
module.exports = function preset(context, opts = {}) {
return {
themes: [
{
name: '@docusaurus/theme-classic',
options: opts.test,
},
],
plugins: [
{
name: '@docusaurus/plugin-test',
options: opts.test,
},
],
};
};

View file

@ -13,7 +13,12 @@ import loadPresets from '../presets';
describe('loadPresets', () => {
test('no presets', () => {
const presets = loadPresets({siteConfig: {presets: []}});
expect(presets).toEqual([]);
expect(presets).toMatchInlineSnapshot(`
Object {
"plugins": Array [],
"themes": Array [],
}
`);
});
test('string form', () => {
@ -23,16 +28,19 @@ describe('loadPresets', () => {
},
});
expect(presets).toMatchInlineSnapshot(`
Array [
Object {
"name": "@docusaurus/plugin-content-docs",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-content-blog",
"options": undefined,
},
]
Object {
"plugins": Array [
Object {
"name": "@docusaurus/plugin-content-docs",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-content-blog",
"options": undefined,
},
],
"themes": Array [],
}
`);
});
@ -46,24 +54,27 @@ Array [
},
});
expect(presets).toMatchInlineSnapshot(`
Array [
Object {
"name": "@docusaurus/plugin-content-docs",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-content-blog",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-content-pages",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-sitemap",
"options": undefined,
},
]
Object {
"plugins": Array [
Object {
"name": "@docusaurus/plugin-content-docs",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-content-blog",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-content-pages",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-sitemap",
"options": undefined,
},
],
"themes": Array [],
}
`);
});
@ -74,16 +85,19 @@ Array [
},
});
expect(presets).toMatchInlineSnapshot(`
Array [
Object {
"name": "@docusaurus/plugin-content-docs",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-content-blog",
"options": undefined,
},
]
Object {
"plugins": Array [
Object {
"name": "@docusaurus/plugin-content-docs",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-content-blog",
"options": undefined,
},
],
"themes": Array [],
}
`);
});
@ -99,18 +113,21 @@ Array [
},
});
expect(presets).toMatchInlineSnapshot(`
Array [
Object {
"name": "@docusaurus/plugin-content-docs",
"options": Object {
"path": "../",
Object {
"plugins": Array [
Object {
"name": "@docusaurus/plugin-content-docs",
"options": Object {
"path": "../",
},
},
},
Object {
"name": "@docusaurus/plugin-content-blog",
"options": undefined,
},
]
Object {
"name": "@docusaurus/plugin-content-blog",
"options": undefined,
},
],
"themes": Array [],
}
`);
});
@ -130,28 +147,31 @@ Array [
},
});
expect(presets).toMatchInlineSnapshot(`
Array [
Object {
"name": "@docusaurus/plugin-content-docs",
"options": Object {
"path": "../",
Object {
"plugins": Array [
Object {
"name": "@docusaurus/plugin-content-docs",
"options": Object {
"path": "../",
},
},
},
Object {
"name": "@docusaurus/plugin-content-blog",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-content-pages",
"options": Object {
"path": "../",
Object {
"name": "@docusaurus/plugin-content-blog",
"options": undefined,
},
},
Object {
"name": "@docusaurus/plugin-sitemap",
"options": undefined,
},
]
Object {
"name": "@docusaurus/plugin-content-pages",
"options": Object {
"path": "../",
},
},
Object {
"name": "@docusaurus/plugin-sitemap",
"options": undefined,
},
],
"themes": Array [],
}
`);
});
@ -168,26 +188,78 @@ Array [
},
});
expect(presets).toMatchInlineSnapshot(`
Array [
Object {
"name": "@docusaurus/plugin-content-docs",
"options": Object {
"path": "../",
Object {
"plugins": Array [
Object {
"name": "@docusaurus/plugin-content-docs",
"options": Object {
"path": "../",
},
},
},
Object {
"name": "@docusaurus/plugin-content-blog",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-content-pages",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-sitemap",
"options": undefined,
},
]
Object {
"name": "@docusaurus/plugin-content-blog",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-content-pages",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-sitemap",
"options": undefined,
},
],
"themes": Array [],
}
`);
});
test('mixed form with themes', () => {
const presets = loadPresets({
siteConfig: {
presets: [
[
path.join(__dirname, '__fixtures__/preset-bar.js'),
{docs: {path: '../'}},
],
path.join(__dirname, '__fixtures__/preset-foo.js'),
path.join(__dirname, '__fixtures__/preset-qux.js'),
],
},
});
expect(presets).toMatchInlineSnapshot(`
Object {
"plugins": Array [
Object {
"name": "@docusaurus/plugin-content-docs",
"options": Object {
"path": "../",
},
},
Object {
"name": "@docusaurus/plugin-content-blog",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-content-pages",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-sitemap",
"options": undefined,
},
Object {
"name": "@docusaurus/plugin-test",
"options": undefined,
},
],
"themes": Array [
Object {
"name": "@docusaurus/theme-classic",
"options": undefined,
},
],
}
`);
});
});

View file

@ -7,29 +7,28 @@
const importFresh = require('import-fresh');
const _ = require('lodash');
const fs = require('fs-extra');
module.exports = function loadPresets(context) {
const presets = context.siteConfig.presets || [];
return _.flatten(
presets.map(presetItem => {
let presetModule;
let presetOptions = {};
if (typeof presetItem === 'string') {
presetModule = presetItem;
} else if (Array.isArray(presetItem)) {
[presetModule, presetOptions] = presetItem;
}
const plugins = [];
const themes = [];
let preset;
if (presetModule && fs.existsSync(presetModule)) {
// Local preset.
preset = importFresh(presetModule);
} else {
// From npm.
preset = importFresh(presetModule);
}
return preset(context, presetOptions).plugins;
}),
);
presets.forEach(presetItem => {
let presetModule;
let presetOptions = {};
if (typeof presetItem === 'string') {
presetModule = presetItem;
} else if (Array.isArray(presetItem)) {
[presetModule, presetOptions] = presetItem;
}
const preset = importFresh(presetModule);
plugins.push(preset(context, presetOptions).plugins);
themes.push(preset(context, presetOptions).themes);
});
return {
plugins: _.compact(_.flatten(plugins)),
themes: _.compact(_.flatten(themes)),
};
};

View file

@ -5,25 +5,32 @@
* LICENSE file in the root directory of this source tree.
*/
const globby = require('globby');
const fs = require('fs-extra');
const path = require('path');
const {fileToPath, posixPath, normalizeUrl} = require('@docusaurus/utils');
module.exports = function loadConfig(siteDir) {
const customThemePath = path.resolve(siteDir, 'theme');
const themePath = fs.existsSync(customThemePath)
? customThemePath
: path.resolve(__dirname, '../../default-theme');
module.exports = async function loadTheme(siteDir) {
const themePath = path.resolve(siteDir, 'theme');
if (!fs.existsSync(themePath)) {
return null;
}
const requiredComponents = ['Loading', 'NotFound'];
requiredComponents.forEach(component => {
try {
require.resolve(path.join(themePath, component));
} catch (e) {
throw new Error(
`Failed to load ${themePath}/${component}. It does not exist.`,
);
}
const themeComponentFiles = await globby(['**/*.{js,jsx}'], {
cwd: themePath,
});
return themePath;
const alias = {};
await Promise.all(
themeComponentFiles.map(async relativeSource => {
const filePath = path.join(themePath, relativeSource);
const fileName = fileToPath(relativeSource);
const aliasName = posixPath(
normalizeUrl(['@theme', fileName]).replace(/\/$/, ''),
);
alias[aliasName] = filePath;
}),
);
return alias;
};

View file

@ -18,7 +18,6 @@ const CSS_MODULE_REGEX = /\.module\.css$/;
module.exports = function createBaseConfig(props, isServer) {
const {
outDir,
themePath,
siteDir,
baseUrl,
generatedFilesDir,
@ -26,6 +25,7 @@ module.exports = function createBaseConfig(props, isServer) {
} = props;
const isProd = process.env.NODE_ENV === 'production';
const themeFallback = path.resolve(__dirname, '../client/theme-fallback');
return {
mode: isProd ? 'production' : 'development',
output: {
@ -42,10 +42,14 @@ module.exports = function createBaseConfig(props, isServer) {
resolve: {
symlinks: true,
alias: {
// https://stackoverflow.com/a/55433680/6072730
ejs: 'ejs/ejs.min.js',
'@theme': themePath,
// These alias can be overriden in plugins. However, these components are essential
// (e.g: react-loadable requires Loading component) so we alias it here first as fallback.
'@theme/Layout': path.join(themeFallback, 'Layout'),
'@theme/Loading': path.join(themeFallback, 'Loading'),
'@theme/NotFound': path.join(themeFallback, 'NotFound'),
'@site': siteDir,
'@build': outDir,
'@generated': generatedFilesDir,
'@docusaurus': path.resolve(__dirname, '../client/exports'),
},

View file

@ -45,7 +45,6 @@
"clean-webpack-plugin": "^2.0.1",
"commander": "^2.16.0",
"css-loader": "^1.0.0",
"docsearch.js": "^2.5.2",
"ejs": "^2.6.1",
"envinfo": "^7.2.0",
"express": "^4.16.4",

View file

@ -66,7 +66,7 @@ function Home() {
// TODO: (wrapper function) API so that user won't need to concatenate url manually
const feedbackUrl = `${siteConfig.baseUrl}feedback/`;
const gettingStartedUrl = `${siteConfig.baseUrl}docs/installation`;
const gettingStartedUrl = `${siteConfig.baseUrl}docs/introduction`;
useEffect(() => {
// Prefetch feedback pages & getting started pages