Skip to content

Commit

Permalink
fix(theme-shadowing): Add support for legacy handling of file extensions
Browse files Browse the repository at this point in the history
fix(theme-shadowing): Use file extension to category dictionary instead of extension map
  • Loading branch information
mjameswh committed Apr 23, 2021
1 parent 14efb64 commit 9df0fea
Show file tree
Hide file tree
Showing 11 changed files with 190 additions and 36 deletions.
Expand Up @@ -66,6 +66,26 @@ Object {
}
`;

exports[`npm package resource installs 2 resources, one prod & one dev: NPMPackage destroy 1`] = `
Object {
"_message": "Installed NPM package is-sorted@1.0.2",
"description": "A small module to check if an Array is sorted",
"id": "is-sorted",
"name": "is-sorted",
"version": "1.0.2",
}
`;

exports[`npm package resource installs 2 resources, one prod & one dev: NPMPackage update 1`] = `
Object {
"_message": "Installed NPM package is-sorted@1.0.2",
"description": "A small module to check if an Array is sorted",
"id": "is-sorted",
"name": "is-sorted",
"version": "1.0.2",
}
`;

exports[`package manager client commands generates the correct commands for npm 1`] = `
Array [
"install",
Expand Down
Expand Up @@ -201,6 +201,51 @@ test.each([
`../theme-a/src/file-d.ts`, // src/theme-a/file-c.js => theme-a/src/file-d.js
],
],
[
`support for legacy extension handling`,
{
mode: `development`,
entry: `./index.js`,
resolve: {
extensions: [`.js`, `.customscript`],
plugins: [
new ShadowRealm({
extensions: [`.js`, `.customscript`],
themes: [
{
themeName: `theme-a`,
themeDir: path.join(
__dirname,
`./fixtures/test-sites/legacy-extensions-shadowing/node_modules/theme-a`
),
},
],
projectRoot: path.resolve(
__dirname,
`fixtures/test-sites/legacy-extensions-shadowing`
),
}),
],
},
module: {
rules: [{ test: /\.customscript?$/, use: `gatsby-raw-loader` }],
},
resolveLoader: {
modules: [`../../fake-loaders`],
},
},
{
context: path.resolve(
__dirname,
`fixtures/test-sites/legacy-extensions-shadowing`
),
},
[
`./node_modules/theme-a/src/file-a.js`,
`./src/theme-a/file-b.js`,
`./src/theme-a/file-c.customscript`,
],
],
[
`edge case; extra extensions in filename`,
{
Expand Down
@@ -0,0 +1,3 @@
const filea = require(`theme-a/src/file-a.js`)
const fileb = require(`theme-a/src/file-b`)
const filec = require(`theme-a/src/file-c`)
@@ -0,0 +1,2 @@
// Note that this file should not be loaded, because `extensions` does not contain `.ts` in this test
module.exports = "file-a from 'site'";
@@ -0,0 +1 @@
// Sample file with custom extension
@@ -0,0 +1 @@
module.exports = `file-b from 'site'`
@@ -0,0 +1 @@
module.exports = "file-c from 'site'"
Expand Up @@ -39,6 +39,7 @@ describe(`Component Shadowing`, () => {
themeDir: xplatPath(`/some/place/${name}`),
}
}),
extensions: [],
})
expect(plugin.getThemeAndComponent(xplatPath(componentFullPath))).toEqual(
[
Expand Down
Expand Up @@ -18,6 +18,7 @@ module.exports = function (pageComponent) {
const componentPath = shadowingPlugin.resolveComponentPath({
theme,
component,
originalRequestComponent: pageComponent,
})
if (componentPath) {
return componentPath
Expand Down
Expand Up @@ -10,6 +10,7 @@ exports.onCreateWebpackConfig = (
resolve: {
plugins: [
new GatsbyThemeComponentShadowingResolverPlugin({
extensions: program.extensions,
themes: flattenedPlugins.map(plugin => {
return {
themeDir: plugin.pluginFilepath,
Expand Down
Expand Up @@ -3,17 +3,51 @@ const debug = require(`debug`)(`gatsby:component-shadowing`)
const fs = require(`fs`)
const _ = require(`lodash`)

// By default, a file can only be shadowed by a file of the same extension.
// However, the following table determine additionnal shadowing extensions that
// will be looked for, given the extension of the file being shadowed.
// This list maybe extended by user (by customizing webpack's configuration), in
// order to allow less common use cases (ie. allow css files being shadowed by
// a scss file, or jpg files being shadowed by png...)
const DEFAULT_ADDITIONNAL_SHADOW_EXTENSIONS = {
js: [`js`, `jsx`, `ts`, `tsx`],
jsx: [`js`, `jsx`, `ts`, `tsx`],
ts: [`js`, `jsx`, `ts`, `tsx`],
tsx: [`js`, `jsx`, `ts`, `tsx`],
// A file can be shadowed by a file of the same extension, or a file of a
// "compatible" file extension; two files extensions are compatible if they both
// belongs to the same "category". For example, a .JS file (that is code), may
// be shadowed by a .TS file or a .JSX file (both are code), but not by a .CSS
// file (that is a stylesheet) or a .PNG file (that is an image). The following
// list establish to which category a given file extension belongs. Note that if
// a file is not present in this list, then it can only be shadowed by a file
// of the same extension.

// FIXME: Determine how this list can be extended by user/plugins
const DEFAULT_FILE_EXTENSIONS_CATEGORIES = {
// Code formats
js: `code`,
jsx: `code`,
ts: `code`,
tsx: `code`,
cjs: `code`,
mjs: `code`,
coffee: `code`,

// JSON-like data formats
json: `json`,
yaml: `json`,
yml: `json`,

// Stylesheets formats
css: `stylesheet`,
sass: `stylesheet`,
scss: `stylesheet`,
less: `stylesheet`,
"css.js": `stylesheet`,

// Images formats
jpeg: `image`,
jpg: `image`,
jfif: `image`,
png: `image`,
tiff: `image`,
webp: `image`,
avif: `image`,
gif: `image`,

// Fonts
woff: `font`,
woff2: `font`,
}

// TO-DO:
Expand All @@ -23,30 +57,58 @@ const DEFAULT_ADDITIONNAL_SHADOW_EXTENSIONS = {
// see memoized `shadowCreatePagePath` function used in `createPage` action creator.

module.exports = class GatsbyThemeComponentShadowingResolverPlugin {
constructor({ projectRoot, themes, additionnalShadowExtensions }) {
constructor({ projectRoot, themes, extensions, extensionsCategory }) {
debug(
`themes list`,
themes.map(({ themeName }) => themeName)
)
this.themes = themes
this.projectRoot = projectRoot

// Concatenate default additionnal extensions with those configured by user
// then sort these in reverse length (so that something such as ".css.js"
// get caught before ".js"); also make sure the extension itself is added in
// the list of allowed shadow extensions.
const additionnalShadowExtensionsList = Object.entries({
...DEFAULT_ADDITIONNAL_SHADOW_EXTENSIONS,
...(additionnalShadowExtensions || {}),
})
this.additionnalShadowExtensions = additionnalShadowExtensionsList
.sort(([a], [b]) => a.length <= b.length)
.map(([key, value]) => {
return { key, value: [...value, key] }
})
this.extensions = extensions ?? []
this.extensionsCategory = {
...DEFAULT_FILE_EXTENSIONS_CATEGORIES,
...extensionsCategory,
}
this.additionnalShadowExtensions = this.buildAdditionnalShadowExtensions()
}

buildAdditionnalShadowExtensions() {
const extensionsByCategory = _.groupBy(
this.extensions,
ext => this.extensionsCategory[ext.substring(1)] || `undefined`
)

const additionnalExtensions = []
for (const [category, exts] of Object.entries(extensionsByCategory)) {
if (category === `undefined`) continue
for (const ext of exts) {
additionnalExtensions.push({ key: ext, value: exts })
}
}

// Sort extensions in reverse length order, so that something such as
// ".css.js" get caught before ".js"
return additionnalExtensions.sort(
({ key: a }, { key: b }) => a.length <= b.length
)
}

apply(resolver) {
// This hook is executed very early and captures the original file name
resolver
.getHook(`resolve`)
.tapAsync(
`GatsbyThemeComponentShadowingResolverPlugin`,
(request, stack, callback) => {
if (!request._gatsbyThemeShadowingOriginalRequestPath) {
request._gatsbyThemeShadowingOriginalRequestPath = request.request
}
return callback()
}
)

// This is where the magic really happens
resolver
.getHook(`before-resolved`)
.tapAsync(
Expand Down Expand Up @@ -86,10 +148,15 @@ module.exports = class GatsbyThemeComponentShadowingResolverPlugin {
return callback()
}

const originalRequestPath =
request._gatsbyThemeShadowingOriginalRequestPath
const originalRequestComponent = path.basename(originalRequestPath)

// This is the shadowing algorithm.
const builtComponentPath = this.resolveComponentPath({
theme,
component,
originalRequestComponent,
})

if (builtComponentPath) {
Expand All @@ -108,7 +175,7 @@ module.exports = class GatsbyThemeComponentShadowingResolverPlugin {
}

// check the user's project and the theme files
resolveComponentPath({ theme, component }) {
resolveComponentPath({ theme, component, originalRequestComponent }) {
// don't include matching theme in possible shadowing paths
const themes = this.themes.filter(
({ themeName }) => themeName !== theme.themeName
Expand All @@ -123,18 +190,19 @@ module.exports = class GatsbyThemeComponentShadowingResolverPlugin {
)

const acceptableShadowFileNames = this.getAcceptableShadowFileNames(
path.basename(component)
path.basename(component),
originalRequestComponent
)

for (const theme of themesArray) {
const possibleComponentPath = path.dirname(path.join(theme, component))
const possibleComponentPath = path.join(theme, component)
debug(`possibleComponentPath`, possibleComponentPath)

let dir
try {
// we use fs/path instead of require.resolve to work with
// TypeScript and alternate syntaxes
dir = fs.readdirSync(possibleComponentPath)
dir = fs.readdirSync(path.dirname(possibleComponentPath))
} catch (e) {
continue
}
Expand All @@ -145,7 +213,10 @@ module.exports = class GatsbyThemeComponentShadowingResolverPlugin {
existsDir.includes(shadowFile)
)
if (matchingShadowFile) {
return path.join(possibleComponentPath, matchingShadowFile)
return path.join(
path.dirname(possibleComponentPath),
matchingShadowFile
)
}
}
return null
Expand Down Expand Up @@ -212,17 +283,24 @@ module.exports = class GatsbyThemeComponentShadowingResolverPlugin {
return shadowFiles.includes(issuerPath)
}

getAcceptableShadowFileNames(componentName) {
getAcceptableShadowFileNames(componentName, originalRequestComponent) {
const matchingEntry = this.additionnalShadowExtensions.find(entry =>
componentName.endsWith(entry.key)
)

// By default, a file may only be shadowed by a file of the same extension
if (!matchingEntry) {
return [componentName]
let additionnalNames = []
if (matchingEntry) {
const baseName = componentName.slice(0, -matchingEntry.key.length)
additionnalNames = matchingEntry.value.map(ext => `${baseName}${ext}`)
}

let legacyAdditionnalNames = []
if (originalRequestComponent) {
legacyAdditionnalNames = this.extensions.map(
ext => `${originalRequestComponent}${ext}`
)
}

const baseName = componentName.slice(0, -(matchingEntry.key.length + 1))
return matchingEntry.value.map(ext => `${baseName}.${ext}`)
return [componentName, ...additionnalNames, ...legacyAdditionnalNames]
}
}

0 comments on commit 9df0fea

Please sign in to comment.