Skip to content

Commit

Permalink
feat(gatsby): extend core theming composition API to be recursive (#1…
Browse files Browse the repository at this point in the history
…0787)

* introduce child theming

* add themes reducer for component shadowing usage

* Update packages/gatsby/src/internal-plugins/webpack-theme-component-shadowing/gatsby-node.js

Co-Authored-By: ChristopherBiscardi <chris@christopherbiscardi.com>

* more

* enable component shadowing for child themes

* move shadowing to work on src/

* lintup, add comments
  • Loading branch information
ChristopherBiscardi authored and DSchau committed Jan 28, 2019
1 parent 84abff0 commit 63c9dd9
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 46 deletions.
33 changes: 7 additions & 26 deletions packages/gatsby/src/bootstrap/index.js
Expand Up @@ -11,11 +11,11 @@ const convertHrtime = require(`convert-hrtime`)
const Promise = require(`bluebird`)

const apiRunnerNode = require(`../utils/api-runner-node`)
const mergeGatsbyConfig = require(`../utils/merge-gatsby-config`)
const getBrowserslist = require(`../utils/browserslist`)
const { graphql } = require(`graphql`)
const { store, emitter } = require(`../redux`)
const loadPlugins = require(`./load-plugins`)
const loadThemes = require(`./load-themes`)
const report = require(`gatsby-cli/lib/reporter`)
const getConfigFile = require(`./get-config-file`)
const tracer = require(`opentracing`).globalTracer()
Expand Down Expand Up @@ -84,32 +84,13 @@ module.exports = async (args: BootstrapArgs) => {

// theme gatsby configs can be functions or objects
if (config && config.__experimentalThemes) {
const themesConfig = await Promise.mapSeries(
config.__experimentalThemes,
async plugin => {
const themeName = plugin.resolve || plugin
const themeConfig = plugin.options || {}
const theme = await preferDefault(
getConfigFile(themeName, `gatsby-config`)
)
// if theme is a function, call it with the themeConfig
let themeConfigObj = theme
if (_.isFunction(theme)) {
themeConfigObj = theme(themeConfig)
}
// themes function as plugins too (gatsby-node, etc)
return {
...themeConfigObj,
plugins: [
...(themeConfigObj.plugins || []),
// theme plugin is last so it's gatsby-node, etc can override it's declared plugins, like a normal site.
{ resolve: themeName, options: themeConfig },
],
}
}
).reduce(mergeGatsbyConfig, {})
const themes = await loadThemes(config)
config = themes.config

config = mergeGatsbyConfig(themesConfig, config)
store.dispatch({
type: `SET_RESOLVED_THEMES`,
payload: themes.themes,
})
}

if (config && config.polyfill) {
Expand Down
86 changes: 86 additions & 0 deletions packages/gatsby/src/bootstrap/load-themes/index.js
@@ -0,0 +1,86 @@
const path = require(`path`)
const mergeGatsbyConfig = require(`../../utils/merge-gatsby-config`)
const Promise = require(`bluebird`)
const _ = require(`lodash`)
const debug = require(`debug`)(`gatsby:load-themes`)
const preferDefault = require(`../prefer-default`)
const getConfigFile = require(`../get-config-file`)

// get the gatsby-config file for a theme
const resolveTheme = async themeSpec => {
const themeName = themeSpec.resolve || themeSpec
const themeDir = path.dirname(require.resolve(themeName))
const theme = await preferDefault(getConfigFile(themeDir, `gatsby-config`))
// if theme is a function, call it with the themeConfig
let themeConfig = theme
if (_.isFunction(theme)) {
themeConfig = theme(themeSpec.options || {})
}
return { themeName, themeConfig, themeSpec }
}

// single iteration of a recursive function that resolve parent themes
// It's recursive because we support child themes declaring parents and
// have to resolve all the way `up the tree` of parent/children relationships
//
// Theoretically, there could be an infinite loop here but in practice there is
// no use case for a loop so I expect that to only happen if someone is very
// off track and creating their own set of themes
const processTheme = ({ themeName, themeConfig, themeSpec }) => {
// gatsby themes don't have to specify a gatsby-config.js (they might only use gatsby-node, etc)
// in this case they're technically plugins, but we should support it anyway
// because we can't guarentee which files theme creators create first
if (themeConfig && themeConfig.__experimentalThemes) {
// for every parent theme a theme defines, resolve the parent's
// gatsby config and return it in order [parentA, parentB, child]
return Promise.mapSeries(themeConfig.__experimentalThemes, async spec => {
const themeObj = await resolveTheme(spec)
return processTheme(themeObj)
}).then(arr => arr.concat([{ themeName, themeConfig, themeSpec }]))
} else {
// if a theme doesn't define additional themes, return the original theme
return [{ themeName, themeConfig, themeSpec }]
}
}

module.exports = async config => {
const themesA = await Promise.mapSeries(
config.__experimentalThemes,
async themeSpec => {
const themeObj = await resolveTheme(themeSpec)
return processTheme(themeObj)
}
).then(arr => _.flattenDeep(arr))

// log out flattened themes list to aid in debugging
debug(themesA)

// map over each theme, adding the theme itself to the plugins
// list in the config for the theme. This enables the usage of
// gatsby-node, etc in themes.
return (
Promise.mapSeries(themesA, ({ themeName, themeConfig = {}, themeSpec }) => {
return {
...themeConfig,
plugins: [
...(themeConfig.plugins || []),
// theme plugin is last so it's gatsby-node, etc can override it's declared plugins, like a normal site.
{ resolve: themeName, options: themeSpec.options || {} },
],
}
})
/**
* themes resolve to a gatsby-config, so here we merge all of the configs
* into a single config, making sure to maintain the order in which
* they were defined so that later configs, like the user's site and
* children, can override functionality in earlier themes.
*/
.reduce(mergeGatsbyConfig, {})
.then(newConfig => {
return {
config: mergeGatsbyConfig(newConfig, config),
themes: themesA,
}
})
)
}
Expand Up @@ -4,14 +4,14 @@ exports.onCreateWebpackConfig = (
{ store, stage, getConfig, rules, loaders, actions },
pluginOptions
) => {
const { config, program } = store.getState()
const { program, themes } = store.getState()

if (config.__experimentalThemes) {
if (themes.themes) {
actions.setWebpackConfig({
resolve: {
plugins: [
new GatsbyThemeComponentShadowingResolverPlugin({
themes: config.__experimentalThemes.map(({ resolve }) => resolve),
themes: themes.themes.map(({ themeName }) => themeName),
projectRoot: program.directory,
}),
],
Expand Down
@@ -1,10 +1,12 @@
const path = require(`path`)
const report = require(`gatsby-cli/lib/reporter`)
const debug = require(`debug`)(`gatsby:component-shadowing`)
const fs = require(`fs`)

module.exports = class GatsbyThemeComponentShadowingResolverPlugin {
cache = {}

constructor({ projectRoot, themes }) {
debug(`themes list`, themes)
this.themes = themes
this.projectRoot = projectRoot
}
Expand All @@ -13,7 +15,7 @@ module.exports = class GatsbyThemeComponentShadowingResolverPlugin {
resolver.plugin(`relative`, (request, callback) => {
// find out which theme's src/components dir we're requiring from
const matchingThemes = this.themes.filter(name =>
request.path.includes(path.join(name, `src`, `components`))
request.path.includes(path.join(name, `src`))
)
// 0 matching themes happens a lot fo rpaths we don't want to handle
// > 1 matching theme means we have a path like
Expand All @@ -30,22 +32,23 @@ module.exports = class GatsbyThemeComponentShadowingResolverPlugin {
}
// theme is the theme package from which we're requiring the relative component
const [theme] = matchingThemes
// get the location of the component relative to src/components
const [, component] = request.path.split(
path.join(theme, `src`, `components`)
)
// get the location of the component relative to src/
const [, component] = request.path.split(path.join(theme, `src`))

const builtComponentPath = this.resolveComponentPath({
theme,
matchingTheme: theme,
themes: this.themes,
component,
projectRoot: this.projectRoot,
})

if (!builtComponentPath) {
// if you mess up your component imports in a theme, resolveComponentPath will return undefined
report.panic(
`We can't find the component located at ${
request.path
} and imported in ${request.context.issuer}`
return resolver.doResolve(
`describedRelative`,
request,
null,
{},
callback
)
}
const resolvedComponentPath = require.resolve(builtComponentPath)
Expand All @@ -60,20 +63,42 @@ module.exports = class GatsbyThemeComponentShadowingResolverPlugin {
}

// check the cache, the user's project, and finally the theme files
resolveComponentPath({ theme, component, projectRoot }) {
resolveComponentPath({
matchingTheme: theme,
themes: ogThemes,
component,
projectRoot,
}) {
// don't include matching theme in possible shadowing paths
const themes = ogThemes.filter(t => t !== theme)
if (!this.cache[`${theme}-${component}`]) {
this.cache[`${theme}-${component}`] = [
path.join(projectRoot, `src`, `components`, theme),
path.join(path.dirname(require.resolve(theme)), `src`, `components`),
path.join(path.resolve(`.`), `src`, theme),
]
.concat(
themes.map(aTheme =>
path.join(path.dirname(require.resolve(aTheme)), `src`, theme)
)
)
.map(dir => path.join(dir, component))
.find(possibleComponentPath => {
debug(`possibleComponentPath`, possibleComponentPath)
let dir
try {
require.resolve(possibleComponentPath)
return true
// we use fs/path instead of require.resolve to work with
// TypeScript and alternate syntaxes
dir = fs.readdirSync(path.dirname(possibleComponentPath))
} catch (e) {
return false
}
const exists = dir
.map(filepath => {
const ext = path.extname(filepath)
const filenameWithoutExtension = path.basename(filepath, ext)
return filenameWithoutExtension
})
.includes(path.basename(possibleComponentPath))
return exists
})
}

Expand Down
1 change: 1 addition & 0 deletions packages/gatsby/src/redux/reducers/index.js
Expand Up @@ -42,4 +42,5 @@ module.exports = {
babelrc: require(`./babelrc`),
jsonDataPaths: require(`./json-data-paths`),
thirdPartySchemas: require(`./thirdPartySchemas`),
themes: require(`./themes`),
}
12 changes: 12 additions & 0 deletions packages/gatsby/src/redux/reducers/themes.js
@@ -0,0 +1,12 @@
module.exports = (state = {}, action) => {
switch (action.type) {
case `SET_RESOLVED_THEMES`:
return {
...state,
themes: action.payload,
}

default:
return state
}
}

0 comments on commit 63c9dd9

Please sign in to comment.