feat(v2): implement ComponentCreator (#1366)

* v2(feat): convert blog to view-driven content queries

* feat(v2): port blog to use ContentRenderer

* misc(v2): fix test and change ContentRenderer url

* avoid chunkName collision

* avoid chunkname collision more

* fix(v2): fix content-renderer ssr problem (#1367)

* wip

* avoid chunk names collision

* ContentRenderer is a wrapper for Loadable

* convert docs and pages

* nits and rename

* rename routeModules -> modules

* remove lodash from component creator

* resolve chunk not being picked up correctly

* add comment for explanation

* small refactoring
This commit is contained in:
Yangshun Tay 2019-04-16 07:18:29 -07:00 committed by Endilie Yacop Sucipto
parent 0ac2441d23
commit 96cb4672d5
14 changed files with 220 additions and 80 deletions

View file

@ -45,6 +45,7 @@ module.exports = {
'jsx-a11y/click-events-have-key-events': OFF, // Revisit in future™
'jsx-a11y/no-noninteractive-element-interactions': OFF, // Revisit in future™
'no-console': OFF,
'no-underscore-dangle': OFF,
'react/jsx-closing-bracket-location': OFF, // Conflicts with Prettier.
'react/jsx-filename-extension': OFF,
'react/jsx-one-expression-per-line': OFF,

View file

@ -129,13 +129,18 @@ class DocusaurusPluginContentBlog {
path: permalink,
component: blogPageComponent,
metadata: metadataItem,
modules: metadataItem.posts.map(post => ({
path: post.source,
query: {
truncated: true,
},
})),
modules: {
entries: metadataItem.posts.map(post => ({
// To tell routes.js this is an import and not a nested object to recurse.
__import: true,
path: post.source,
query: {
truncated: true,
},
})),
},
});
return;
}
@ -143,7 +148,9 @@ class DocusaurusPluginContentBlog {
path: permalink,
component: blogPostComponent,
metadata: metadataItem,
modules: [metadataItem.source],
modules: {
content: metadataItem.source,
},
});
});
}

View file

@ -215,7 +215,9 @@ class DocusaurusPluginContentDocs {
path: metadataItem.permalink,
component: docItemComponent,
metadata: metadataItem,
modules: [metadataItem.source],
modules: {
content: metadataItem.source,
},
})),
});
}

View file

@ -104,7 +104,9 @@ class DocusaurusPluginContentPages {
path: permalink,
component,
metadata: metadataItem,
modules: [source],
modules: {
content: source,
},
});
});
}

View file

@ -33,7 +33,7 @@ describe('load utils', () => {
test('genComponentName', () => {
const asserts = {
'/': 'Index',
'/': 'index',
'/foo-bar': 'FooBar096',
'/foo/bar': 'FooBar1Df',
'/blog/2017/12/14/introducing-docusaurus':
@ -51,7 +51,7 @@ describe('load utils', () => {
test('docuHash', () => {
const asserts = {
'': '-d41',
'/': 'Index',
'/': 'index',
'/foo-bar': 'foo-bar-096',
'/foo/bar': 'foo-bar-1df',
'/endi/lie': 'endi-lie-9fa',
@ -81,7 +81,7 @@ describe('load utils', () => {
});
test('genChunkName', () => {
const asserts = {
let asserts = {
'/docs/adding-blog': 'docs-adding-blog-062',
'/docs/versioning': 'docs-versioning-8a8',
'/': 'index',
@ -94,6 +94,20 @@ describe('load utils', () => {
Object.keys(asserts).forEach(str => {
expect(genChunkName(str)).toBe(asserts[str]);
});
// Don't allow different chunk name for same path.
expect(genChunkName('path/is/similar', 'oldPrefix')).toEqual(
genChunkName('path/is/similar', 'newPrefix'),
);
// Even with same preferred name, still different chunk name for different path
asserts = {
'/blog/1': 'blog-85-f-089',
'/blog/2': 'blog-353-489',
};
Object.keys(asserts).forEach(str => {
expect(genChunkName(str, undefined, 'blog')).toBe(asserts[str]);
});
});
test('idx', () => {

View file

@ -47,7 +47,7 @@ function encodePath(userpath) {
*/
function docuHash(str) {
if (str === '/') {
return 'Index';
return 'index';
}
const shortHash = createHash('md5')
.update(str)
@ -63,7 +63,7 @@ function docuHash(str) {
*/
function genComponentName(pagePath) {
if (pagePath === '/') {
return 'Index';
return 'index';
}
const pageHash = docuHash(pagePath);
const pascalCase = _.flow(
@ -88,9 +88,23 @@ function posixPath(str) {
return str.replace(/\\/g, '/');
}
function genChunkName(str, prefix) {
const name = str === '/' ? 'index' : docuHash(str);
return prefix ? `${prefix}---${name}` : name;
const chunkNameCache = new Map();
function genChunkName(modulePath, prefix, preferredName) {
let chunkName = chunkNameCache.get(modulePath);
if (!chunkName) {
let str = modulePath;
if (preferredName) {
const shortHash = createHash('md5')
.update(modulePath)
.digest('hex')
.substr(0, 3);
str = `${preferredName}${shortHash}`;
}
const name = str === '/' ? 'index' : docuHash(str);
chunkName = prefix ? `${prefix}---${name}` : name;
chunkNameCache.set(modulePath, chunkName);
}
return chunkName;
}
function idx(target, keyPaths) {

View file

@ -0,0 +1,84 @@
/**
* 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 Loadable from 'react-loadable';
import Loading from '@theme/Loading';
import routesAsyncModules from '@generated/routesAsyncModules';
import registry from '@generated/registry';
function ComponentCreator(path) {
const modules = routesAsyncModules[path];
const originalModules = modules;
const optsModules = [];
const optsWebpack = [];
const mappedModules = {};
// Transform an object of
// {
// a: 'foo',
// b: { c: 'bar' },
// d: ['baz', 'qux']
// }
// into
// {
// a: () => import('foo'),
// b.c: () => import('bar'),
// d.0: () => import('baz'),
// d.1: () => import('qux'),
// }
// for React Loadable to process.
function traverseModules(module, keys) {
if (Array.isArray(module)) {
module.forEach((value, index) => {
traverseModules(value, [...keys, index]);
});
return;
}
if (typeof module === 'object') {
Object.keys(module).forEach(key => {
traverseModules(module[key], [...keys, key]);
});
return;
}
mappedModules[keys.join('.')] = registry[module].importStatement;
optsModules.push(registry[module].module);
optsWebpack.push(registry[module].webpack);
}
traverseModules(modules, []);
return Loadable.Map({
loading: Loading,
loader: mappedModules,
// We need to provide opts.modules and opts.webpack to React Loadable
// Our loader is now dynamical, the react-loadable/babel won't do the heavy lifting for us.
// https://github.com/jamiebuilds/react-loadable#declaring-which-modules-are-being-loaded
modules: optsModules,
webpack: () => optsWebpack,
render: (loaded, props) => {
// Transform back loaded modules back into the original structure.
const loadedModules = originalModules;
Object.keys(loaded).forEach(key => {
let val = loadedModules;
const keyPath = key.split('.');
for (let i = 0; i < keyPath.length - 1; i += 1) {
val = val[keyPath[i]];
}
val[keyPath[keyPath.length - 1]] = loaded[key].default;
});
const Component = loadedModules.component;
delete loadedModules.component;
return <Component {...loadedModules} {...props} />;
},
});
}
export default ComponentCreator;

View file

@ -18,7 +18,7 @@ function BlogPage(props) {
const {baseUrl, favicon} = siteConfig;
const {
metadata: {posts = []},
modules: BlogPosts,
entries: BlogPosts,
} = props;
return (

View file

@ -19,8 +19,8 @@ function BlogPost(props) {
);
const {baseUrl, favicon} = siteConfig;
const {language, title} = contextMetadata;
const {modules, metadata} = props;
const BlogPostContents = modules[0];
const {content, metadata} = props;
const BlogPostContents = content;
return (
<Layout>

View file

@ -14,14 +14,14 @@ import Head from '@docusaurus/Head';
import styles from './styles.module.css';
function DocBody(props) {
const {metadata, modules} = props;
const {metadata, content} = props;
const {language, version} = metadata;
const context = useContext(DocusaurusContext);
useEffect(() => {
context.setContext({metadata});
}, []);
const DocContents = modules[0];
const DocContents = content;
return (
<div className={styles.docBody}>
<Head>

View file

@ -12,12 +12,12 @@ import Layout from '@theme/Layout'; // eslint-disable-line
import DocusaurusContext from '@docusaurus/context';
function Pages({modules}) {
function Pages({content}) {
const context = useContext(DocusaurusContext);
const {metadata = {}, siteConfig = {}} = context;
const {baseUrl, favicon} = siteConfig;
const {language} = metadata;
const PageContents = modules[0];
const PageContents = content;
return (
<Layout>

View file

@ -58,6 +58,7 @@ module.exports = async function load(siteDir, cliOptions = {}) {
// Routing
const {
registry,
routesAsyncModules,
routesConfig,
routesMetadata,
@ -65,12 +66,19 @@ module.exports = async function load(siteDir, cliOptions = {}) {
routesPaths,
} = await loadRoutes(pluginsRouteConfigs);
// Mapping of routePath -> metadataPath. Example: '/blog' -> '@generated/metadata/blog-c06.json'
// Very useful to know which json metadata file is related to certain route
await generate(
generatedFilesDir,
'routesMetadataPath.json',
JSON.stringify(routesMetadataPath, null, 2),
'registry.js',
`export default {
${Object.keys(registry)
.map(
key => ` '${key}': {
'importStatement': ${registry[key].importStatement},
'module': '${registry[key].modulePath}',
'webpack': require.resolveWeak('${registry[key].modulePath}'),
},`,
)
.join('\n')}};\n`,
);
// Mapping of routePath -> async imported modules. Example: '/blog' -> ['@theme/BlogPage']

View file

@ -63,6 +63,7 @@ module.exports = async function loadPlugins({pluginConfigs = [], context}) {
// 3. Plugin lifecycle - contentLoaded
const pluginsRouteConfigs = [];
const actions = {
addRoute: config => pluginsRouteConfigs.push(config),
};

View file

@ -7,25 +7,27 @@
const {genChunkName, docuHash} = require('@docusaurus/utils');
const {stringify} = require('querystring');
const _ = require('lodash');
async function loadRoutes(pluginsRouteConfigs) {
const routesImports = [
`import React from 'react';`,
`import Loadable from 'react-loadable';`,
`import Loading from '@theme/Loading';`,
`import NotFound from '@theme/NotFound';`,
`import ComponentCreator from '@docusaurus/ComponentCreator';`,
];
// Routes paths. Example: ['/', '/docs', '/blog/2017/09/03/test']
const routesPaths = [];
const addRoutesPath = routePath => {
routesPaths.push(routePath);
};
// Mapping of routePath -> metadataPath. Example: '/blog' -> '@generated/metadata/blog-c06.json'
const routesMetadataPath = {};
const addRoutesMetadataPath = routePath => {
const fileName = `${docuHash(routePath)}.json`;
routesMetadataPath[routePath] = `@generated/metadata/${fileName}`;
};
// Mapping of routePath -> metadata. Example: '/blog' -> { isBlogPage: true, permalink: '/blog' }
const routesMetadata = {};
const addRoutesMetadata = (routePath, metadata) => {
@ -33,13 +35,21 @@ async function loadRoutes(pluginsRouteConfigs) {
routesMetadata[routePath] = metadata;
}
};
const registry = {};
// Mapping of routePath -> async imported modules. Example: '/blog' -> ['@theme/BlogPage']
const routesAsyncModules = {};
const addRoutesAsyncModule = (routePath, module) => {
const addRoutesAsyncModule = (routePath, key, importChunk) => {
// TODO: Port other plugins to use modules and not rely on this.
if (!routesAsyncModules[routePath]) {
routesAsyncModules[routePath] = [];
routesAsyncModules[routePath] = {};
}
routesAsyncModules[routePath].push(module);
routesAsyncModules[routePath][key] = importChunk.chunkName;
registry[importChunk.chunkName] = {
importStatement: importChunk.importStatement,
modulePath: importChunk.modulePath,
};
};
// This is the higher level overview of route code generation
@ -48,7 +58,7 @@ async function loadRoutes(pluginsRouteConfigs) {
path: routePath,
component,
metadata,
modules = [],
modules = {},
routes,
} = routeConfig;
@ -58,9 +68,9 @@ async function loadRoutes(pluginsRouteConfigs) {
// Given an input (object or string), get the import path str
const getModulePath = target => {
const isObj = typeof target === 'object';
const importStr = isObj ? target.path : target;
const importStr = _.isObject(target) ? target.path : target;
const queryStr = target.query ? `?${stringify(target.query)}` : '';
return `${importStr}${queryStr}`;
};
@ -68,71 +78,67 @@ async function loadRoutes(pluginsRouteConfigs) {
throw new Error(`path: ${routePath} need a component`);
}
const componentPath = getModulePath(component);
addRoutesAsyncModule(routePath, componentPath);
const genImportStr = (modulePath, prefix, name) => {
const chunkName = genChunkName(name || modulePath, prefix);
const genImportChunk = (modulePath, prefix, name) => {
const chunkName = genChunkName(modulePath, prefix, name);
const finalStr = JSON.stringify(modulePath);
return `() => import(/* webpackChunkName: '${chunkName}' */ ${finalStr})`;
return {
chunkName,
modulePath,
importStatement: `() => import(/* webpackChunkName: '${chunkName}' */ ${finalStr})`,
};
};
const componentChunk = genImportChunk(componentPath, 'component');
addRoutesAsyncModule(routePath, 'component', componentChunk);
if (routes) {
const componentStr = `Loadable({
loader: ${genImportStr(componentPath, 'component')},
loading: Loading
})`;
return `
{
path: '${routePath}',
component: ${componentStr},
component: ComponentCreator('${routePath}'),
routes: [${routes.map(generateRouteCode).join(',')}],
}`;
}
const modulesImportStr = modules
.map((module, i) => {
const modulePath = getModulePath(module);
addRoutesAsyncModule(routePath, modulePath);
return `Mod${i}: ${genImportStr(modulePath, i, routePath)},`;
})
.join('\n');
const modulesLoadedStr = modules
.map((module, i) => `loaded.Mod${i}.default,`)
.join('\n');
function genRouteAsyncModule(value) {
if (Array.isArray(value)) {
return value.map(genRouteAsyncModule);
}
let metadataImportStr = '';
if (metadata) {
const metadataPath = routesMetadataPath[routePath];
addRoutesAsyncModule(routePath, metadataPath);
metadataImportStr = `metadata: ${genImportStr(
metadataPath,
'metadata',
if (_.isObject(value) && !value.__import) {
const newValue = {};
Object.keys(value).forEach(key => {
newValue[key] = genRouteAsyncModule(value[key]);
});
return newValue;
}
const importChunk = genImportChunk(
getModulePath(value),
'module',
routePath,
)},`;
);
registry[importChunk.chunkName] = {
importStatement: importChunk.importStatement,
modulePath: importChunk.modulePath,
};
return importChunk.chunkName;
}
const componentStr = `Loadable.Map({
loader: {
${modulesImportStr}
${metadataImportStr}
Component: ${genImportStr(componentPath, 'component')},
},
loading: Loading,
render(loaded, props) {
const Component = loaded.Component.default;
const metadata = loaded.metadata || {};
const modules = [${modulesLoadedStr}];
return (
<Component {...props} metadata={metadata} modules={modules}/>
);
}
})\n`;
_.assign(routesAsyncModules[routePath], genRouteAsyncModule(modules));
if (metadata) {
const metadataPath = routesMetadataPath[routePath];
const metadataChunk = genImportChunk(metadataPath, 'metadata', routePath);
addRoutesAsyncModule(routePath, 'metadata', metadataChunk);
}
return `
{
path: '${routePath}',
exact: true,
component: ${componentStr}
component: ComponentCreator('${routePath}')
}`;
}
@ -152,6 +158,7 @@ export default [
];\n`;
return {
registry,
routesAsyncModules,
routesConfig,
routesMetadata,