Add support for paths in tsconfig.json and jsconfig.json (#11293)
* Add support for tsconfig/json `paths` option * Add tests for paths in tsconfig.json * Don't apply aliases when paths is empty * Clean up unused methods and link to TypeScript license * Add tests for jsconfig * Put feature under an experimental flag * Enable to see if tests pass * Update types * Add feature under an experimental flag
This commit is contained in:
parent
d3fb262958
commit
7fce52b905
24 changed files with 413 additions and 0 deletions
|
@ -43,6 +43,7 @@ import { ProfilingPlugin } from './webpack/plugins/profiling-plugin'
|
|||
import { ReactLoadablePlugin } from './webpack/plugins/react-loadable-plugin'
|
||||
import { ServerlessPlugin } from './webpack/plugins/serverless-plugin'
|
||||
import { TerserPlugin } from './webpack/plugins/terser-webpack-plugin/src/index'
|
||||
import { JsConfigPathsPlugin } from './webpack/plugins/jsconfig-paths-plugin'
|
||||
import WebpackConformancePlugin, {
|
||||
MinificationConformanceCheck,
|
||||
ReactSyncScriptsConformanceCheck,
|
||||
|
@ -910,6 +911,16 @@ export default async function getBaseWebpackConfig(
|
|||
webpackConfig.resolve?.modules?.push(resolvedBaseUrl)
|
||||
}
|
||||
|
||||
if (
|
||||
config.experimental.jsconfigPaths &&
|
||||
jsConfig?.compilerOptions?.paths &&
|
||||
resolvedBaseUrl
|
||||
) {
|
||||
webpackConfig.resolve?.plugins?.push(
|
||||
new JsConfigPathsPlugin(jsConfig.compilerOptions.paths, resolvedBaseUrl)
|
||||
)
|
||||
}
|
||||
|
||||
webpackConfig = await buildConfiguration(webpackConfig, {
|
||||
rootDirectory: dir,
|
||||
customAppFile,
|
||||
|
|
209
packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts
Normal file
209
packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts
Normal file
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* This webpack resolver is largely based on TypeScript's "paths" handling
|
||||
* The TypeScript license can be found here:
|
||||
* https://github.com/microsoft/TypeScript/blob/214df64e287804577afa1fea0184c18c40f7d1ca/LICENSE.txt
|
||||
*/
|
||||
import { ResolvePlugin } from 'webpack'
|
||||
import { join } from 'path'
|
||||
|
||||
export interface Pattern {
|
||||
prefix: string
|
||||
suffix: string
|
||||
}
|
||||
|
||||
const asterisk = 0x2a
|
||||
|
||||
export function hasZeroOrOneAsteriskCharacter(str: string): boolean {
|
||||
let seenAsterisk = false
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (str.charCodeAt(i) === asterisk) {
|
||||
if (!seenAsterisk) {
|
||||
seenAsterisk = true
|
||||
} else {
|
||||
// have already seen asterisk
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function tryParsePattern(pattern: string): Pattern | undefined {
|
||||
// This should be verified outside of here and a proper error thrown.
|
||||
const indexOfStar = pattern.indexOf('*')
|
||||
return indexOfStar === -1
|
||||
? undefined
|
||||
: {
|
||||
prefix: pattern.substr(0, indexOfStar),
|
||||
suffix: pattern.substr(indexOfStar + 1),
|
||||
}
|
||||
}
|
||||
|
||||
function isPatternMatch({ prefix, suffix }: Pattern, candidate: string) {
|
||||
return (
|
||||
candidate.length >= prefix.length + suffix.length &&
|
||||
candidate.startsWith(prefix) &&
|
||||
candidate.endsWith(suffix)
|
||||
)
|
||||
}
|
||||
|
||||
/** Return the object corresponding to the best pattern to match `candidate`. */
|
||||
export function findBestPatternMatch<T>(
|
||||
values: readonly T[],
|
||||
getPattern: (value: T) => Pattern,
|
||||
candidate: string
|
||||
): T | undefined {
|
||||
let matchedValue: T | undefined
|
||||
// use length of prefix as betterness criteria
|
||||
let longestMatchPrefixLength = -1
|
||||
|
||||
for (const v of values) {
|
||||
const pattern = getPattern(v)
|
||||
if (
|
||||
isPatternMatch(pattern, candidate) &&
|
||||
pattern.prefix.length > longestMatchPrefixLength
|
||||
) {
|
||||
longestMatchPrefixLength = pattern.prefix.length
|
||||
matchedValue = v
|
||||
}
|
||||
}
|
||||
|
||||
return matchedValue
|
||||
}
|
||||
|
||||
/**
|
||||
* patternStrings contains both pattern strings (containing "*") and regular strings.
|
||||
* Return an exact match if possible, or a pattern match, or undefined.
|
||||
* (These are verified by verifyCompilerOptions to have 0 or 1 "*" characters.)
|
||||
*/
|
||||
export function matchPatternOrExact(
|
||||
patternStrings: readonly string[],
|
||||
candidate: string
|
||||
): string | Pattern | undefined {
|
||||
const patterns: Pattern[] = []
|
||||
for (const patternString of patternStrings) {
|
||||
if (!hasZeroOrOneAsteriskCharacter(patternString)) continue
|
||||
const pattern = tryParsePattern(patternString)
|
||||
if (pattern) {
|
||||
patterns.push(pattern)
|
||||
} else if (patternString === candidate) {
|
||||
// pattern was matched as is - no need to search further
|
||||
return patternString
|
||||
}
|
||||
}
|
||||
|
||||
return findBestPatternMatch(patterns, _ => _, candidate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whether a value is string
|
||||
*/
|
||||
export function isString(text: unknown): text is string {
|
||||
return typeof text === 'string'
|
||||
}
|
||||
|
||||
/**
|
||||
* Given that candidate matches pattern, returns the text matching the '*'.
|
||||
* E.g.: matchedText(tryParsePattern("foo*baz"), "foobarbaz") === "bar"
|
||||
*/
|
||||
export function matchedText(pattern: Pattern, candidate: string): string {
|
||||
return candidate.substring(
|
||||
pattern.prefix.length,
|
||||
candidate.length - pattern.suffix.length
|
||||
)
|
||||
}
|
||||
|
||||
export function patternText({ prefix, suffix }: Pattern): string {
|
||||
return `${prefix}*${suffix}`
|
||||
}
|
||||
|
||||
const NODE_MODULES_REGEX = /node_modules/
|
||||
|
||||
type Paths = { [match: string]: string[] }
|
||||
|
||||
/**
|
||||
* Handles tsconfig.json or jsconfig.js "paths" option for webpack
|
||||
* Largely based on how the TypeScript compiler handles it:
|
||||
* https://github.com/microsoft/TypeScript/blob/1a9c8197fffe3dace5f8dca6633d450a88cba66d/src/compiler/moduleNameResolver.ts#L1362
|
||||
*/
|
||||
export class JsConfigPathsPlugin implements ResolvePlugin {
|
||||
paths: Paths
|
||||
resolvedBaseUrl: string
|
||||
constructor(paths: Paths, resolvedBaseUrl: string) {
|
||||
this.paths = paths
|
||||
|
||||
this.resolvedBaseUrl = resolvedBaseUrl
|
||||
}
|
||||
apply(resolver: any) {
|
||||
const paths = this.paths
|
||||
const pathsKeys = Object.keys(paths)
|
||||
|
||||
// If no aliases are added bail out
|
||||
if (pathsKeys.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const baseDirectory = this.resolvedBaseUrl
|
||||
const target = resolver.ensureHook('resolve')
|
||||
resolver
|
||||
.getHook('described-resolve')
|
||||
.tapPromise(
|
||||
'JsConfigPathsPlugin',
|
||||
async (request: any, resolveContext: any) => {
|
||||
// Exclude node_modules from paths support (speeds up resolving)
|
||||
if (request.path.match(NODE_MODULES_REGEX)) {
|
||||
return
|
||||
}
|
||||
|
||||
const moduleName = request.request
|
||||
|
||||
// If the module name does not match any of the patterns in `paths` we hand off resolving to webpack
|
||||
const matchedPattern = matchPatternOrExact(pathsKeys, moduleName)
|
||||
if (!matchedPattern) {
|
||||
return
|
||||
}
|
||||
|
||||
const matchedStar = isString(matchedPattern)
|
||||
? undefined
|
||||
: matchedText(matchedPattern, moduleName)
|
||||
const matchedPatternText = isString(matchedPattern)
|
||||
? matchedPattern
|
||||
: patternText(matchedPattern)
|
||||
|
||||
let triedPaths = []
|
||||
|
||||
for (const subst of paths[matchedPatternText]) {
|
||||
const path = matchedStar ? subst.replace('*', matchedStar) : subst
|
||||
const candidate = join(baseDirectory, path)
|
||||
const [err, result] = await new Promise((resolve, reject) => {
|
||||
const obj = Object.assign({}, request, {
|
||||
request: candidate,
|
||||
})
|
||||
resolver.doResolve(
|
||||
target,
|
||||
obj,
|
||||
`Aliased with tsconfig.json or jsconfig.json ${matchedPatternText} to ${candidate}`,
|
||||
resolveContext,
|
||||
(err: any, result: any | undefined) => {
|
||||
resolve([err, result])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// There's multiple paths values possible, so we first have to iterate them all first before throwing an error
|
||||
if (err || result === undefined) {
|
||||
triedPaths.push(candidate)
|
||||
continue
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
throw new Error(`
|
||||
Request "${moduleName}" matched tsconfig.json or jsconfig.json "paths" pattern ${matchedPatternText} but could not be resolved.
|
||||
Tried paths: ${triedPaths.join(' ')}
|
||||
`)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -41,6 +41,7 @@ const defaultConfig: { [key: string]: any } = {
|
|||
(Number(process.env.CIRCLE_NODE_TOTAL) ||
|
||||
(os.cpus() || { length: 1 }).length) - 1
|
||||
),
|
||||
jsconfigPaths: false,
|
||||
css: true,
|
||||
scss: true,
|
||||
documentMiddleware: false,
|
||||
|
|
5
test/integration/jsconfig-paths/components/world.js
Normal file
5
test/integration/jsconfig-paths/components/world.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import React from 'react'
|
||||
|
||||
export function World() {
|
||||
return <>World</>
|
||||
}
|
9
test/integration/jsconfig-paths/jsconfig.json
Normal file
9
test/integration/jsconfig-paths/jsconfig.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@c/*": ["components/*"],
|
||||
"@lib/*": ["lib/a/*", "lib/b/*"]
|
||||
}
|
||||
}
|
||||
}
|
1
test/integration/jsconfig-paths/lib/a/api.js
Normal file
1
test/integration/jsconfig-paths/lib/a/api.js
Normal file
|
@ -0,0 +1 @@
|
|||
export default () => 'Hello from a'
|
1
test/integration/jsconfig-paths/lib/b/api.js
Normal file
1
test/integration/jsconfig-paths/lib/b/api.js
Normal file
|
@ -0,0 +1 @@
|
|||
export default () => 'Hello from b'
|
1
test/integration/jsconfig-paths/lib/b/b-only.js
Normal file
1
test/integration/jsconfig-paths/lib/b/b-only.js
Normal file
|
@ -0,0 +1 @@
|
|||
export default () => 'Hello from only b'
|
9
test/integration/jsconfig-paths/next.config.js
Normal file
9
test/integration/jsconfig-paths/next.config.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
module.exports = {
|
||||
experimental: {
|
||||
jsconfigPaths: true,
|
||||
},
|
||||
onDemandEntries: {
|
||||
// Make sure entries are not getting disposed.
|
||||
maxInactiveAge: 1000 * 60 * 60,
|
||||
},
|
||||
}
|
9
test/integration/jsconfig-paths/pages/basic-alias.js
Normal file
9
test/integration/jsconfig-paths/pages/basic-alias.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import React from 'react'
|
||||
import { World } from '@c/world'
|
||||
export default function HelloPage() {
|
||||
return (
|
||||
<div>
|
||||
<World />
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import React from 'react'
|
||||
import api from '@lib/b-only'
|
||||
export default function ResolveOrder() {
|
||||
return <div>{api()}</div>
|
||||
}
|
5
test/integration/jsconfig-paths/pages/resolve-order.js
Normal file
5
test/integration/jsconfig-paths/pages/resolve-order.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import React from 'react'
|
||||
import api from '@lib/api'
|
||||
export default function ResolveOrder() {
|
||||
return <div>{api()}</div>
|
||||
}
|
41
test/integration/jsconfig-paths/test/index.test.js
Normal file
41
test/integration/jsconfig-paths/test/index.test.js
Normal file
|
@ -0,0 +1,41 @@
|
|||
/* eslint-env jest */
|
||||
/* global jasmine */
|
||||
import { join } from 'path'
|
||||
import cheerio from 'cheerio'
|
||||
import { renderViaHTTP, findPort, launchApp, killApp } from 'next-test-utils'
|
||||
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2
|
||||
|
||||
const appDir = join(__dirname, '..')
|
||||
let appPort
|
||||
let app
|
||||
|
||||
async function get$(path, query) {
|
||||
const html = await renderViaHTTP(appPort, path, query)
|
||||
return cheerio.load(html)
|
||||
}
|
||||
|
||||
describe('TypeScript Features', () => {
|
||||
describe('default behavior', () => {
|
||||
beforeAll(async () => {
|
||||
appPort = await findPort()
|
||||
app = await launchApp(appDir, appPort, {})
|
||||
})
|
||||
afterAll(() => killApp(app))
|
||||
|
||||
it('should alias components', async () => {
|
||||
const $ = await get$('/basic-alias')
|
||||
expect($('body').text()).toMatch(/World/)
|
||||
})
|
||||
|
||||
it('should resolve the first item in the array first', async () => {
|
||||
const $ = await get$('/resolve-order')
|
||||
expect($('body').text()).toMatch(/Hello from a/)
|
||||
})
|
||||
|
||||
it('should resolve the first item in the array first', async () => {
|
||||
const $ = await get$('/resolve-fallback')
|
||||
expect($('body').text()).toMatch(/Hello from only b/)
|
||||
})
|
||||
})
|
||||
})
|
5
test/integration/typescript-baseurl/components/hi.tsx
Normal file
5
test/integration/typescript-baseurl/components/hi.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import React from 'react'
|
||||
|
||||
export function Hi(): JSX.Element {
|
||||
return <>Hi</>
|
||||
}
|
5
test/integration/typescript-paths/components/world.tsx
Normal file
5
test/integration/typescript-paths/components/world.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import React from 'react'
|
||||
|
||||
export function World(): JSX.Element {
|
||||
return <>World</>
|
||||
}
|
1
test/integration/typescript-paths/lib/a/api.ts
Normal file
1
test/integration/typescript-paths/lib/a/api.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export default () => 'Hello from a'
|
1
test/integration/typescript-paths/lib/b/api.ts
Normal file
1
test/integration/typescript-paths/lib/b/api.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export default () => 'Hello from b'
|
1
test/integration/typescript-paths/lib/b/b-only.ts
Normal file
1
test/integration/typescript-paths/lib/b/b-only.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export default () => 'Hello from only b'
|
9
test/integration/typescript-paths/next.config.js
Normal file
9
test/integration/typescript-paths/next.config.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
module.exports = {
|
||||
experimental: {
|
||||
jsconfigPaths: true,
|
||||
},
|
||||
onDemandEntries: {
|
||||
// Make sure entries are not getting disposed.
|
||||
maxInactiveAge: 1000 * 60 * 60,
|
||||
},
|
||||
}
|
9
test/integration/typescript-paths/pages/basic-alias.tsx
Normal file
9
test/integration/typescript-paths/pages/basic-alias.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import React from 'react'
|
||||
import { World } from '@c/world'
|
||||
export default function HelloPage(): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<World />
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import React from 'react'
|
||||
import api from '@lib/b-only'
|
||||
export default function ResolveOrder(): JSX.Element {
|
||||
return <div>{api()}</div>
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import React from 'react'
|
||||
import api from '@lib/api'
|
||||
export default function ResolveOrder(): JSX.Element {
|
||||
return <div>{api()}</div>
|
||||
}
|
41
test/integration/typescript-paths/test/index.test.js
Normal file
41
test/integration/typescript-paths/test/index.test.js
Normal file
|
@ -0,0 +1,41 @@
|
|||
/* eslint-env jest */
|
||||
/* global jasmine */
|
||||
import { join } from 'path'
|
||||
import cheerio from 'cheerio'
|
||||
import { renderViaHTTP, findPort, launchApp, killApp } from 'next-test-utils'
|
||||
|
||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2
|
||||
|
||||
const appDir = join(__dirname, '..')
|
||||
let appPort
|
||||
let app
|
||||
|
||||
async function get$(path, query) {
|
||||
const html = await renderViaHTTP(appPort, path, query)
|
||||
return cheerio.load(html)
|
||||
}
|
||||
|
||||
describe('TypeScript Features', () => {
|
||||
describe('default behavior', () => {
|
||||
beforeAll(async () => {
|
||||
appPort = await findPort()
|
||||
app = await launchApp(appDir, appPort, {})
|
||||
})
|
||||
afterAll(() => killApp(app))
|
||||
|
||||
it('should alias components', async () => {
|
||||
const $ = await get$('/basic-alias')
|
||||
expect($('body').text()).toMatch(/World/)
|
||||
})
|
||||
|
||||
it('should resolve the first item in the array first', async () => {
|
||||
const $ = await get$('/resolve-order')
|
||||
expect($('body').text()).toMatch(/Hello from a/)
|
||||
})
|
||||
|
||||
it('should resolve the first item in the array first', async () => {
|
||||
const $ = await get$('/resolve-fallback')
|
||||
expect($('body').text()).toMatch(/Hello from only b/)
|
||||
})
|
||||
})
|
||||
})
|
24
test/integration/typescript-paths/tsconfig.json
Normal file
24
test/integration/typescript-paths/tsconfig.json
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@c/*": ["components/*"],
|
||||
"@lib/*": ["lib/a/*", "lib/b/*"]
|
||||
},
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"jsx": "preserve",
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
"include": ["next-env.d.ts", "components", "pages"]
|
||||
}
|
Loading…
Reference in a new issue