Skip to content

Commit

Permalink
Add support for paths in tsconfig.json and jsconfig.json (#11293)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
timneutkens committed Mar 23, 2020
1 parent d3fb262 commit 7fce52b
Show file tree
Hide file tree
Showing 24 changed files with 413 additions and 0 deletions.
11 changes: 11 additions & 0 deletions packages/next/build/webpack-config.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
209 changes: 209 additions & 0 deletions packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts
@@ -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(' ')}
`)
}
)
}
}
1 change: 1 addition & 0 deletions packages/next/next-server/server/config.ts
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions test/integration/jsconfig-paths/components/world.js
@@ -0,0 +1,5 @@
import React from 'react'

export function World() {
return <>World</>
}
9 changes: 9 additions & 0 deletions test/integration/jsconfig-paths/jsconfig.json
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@c/*": ["components/*"],
"@lib/*": ["lib/a/*", "lib/b/*"]
}
}
}
1 change: 1 addition & 0 deletions test/integration/jsconfig-paths/lib/a/api.js
@@ -0,0 +1 @@
export default () => 'Hello from a'
1 change: 1 addition & 0 deletions test/integration/jsconfig-paths/lib/b/api.js
@@ -0,0 +1 @@
export default () => 'Hello from b'
1 change: 1 addition & 0 deletions test/integration/jsconfig-paths/lib/b/b-only.js
@@ -0,0 +1 @@
export default () => 'Hello from only b'
9 changes: 9 additions & 0 deletions test/integration/jsconfig-paths/next.config.js
@@ -0,0 +1,9 @@
module.exports = {
experimental: {
jsconfigPaths: true,
},
onDemandEntries: {
// Make sure entries are not getting disposed.
maxInactiveAge: 1000 * 60 * 60,
},
}
9 changes: 9 additions & 0 deletions test/integration/jsconfig-paths/pages/basic-alias.js
@@ -0,0 +1,9 @@
import React from 'react'
import { World } from '@c/world'
export default function HelloPage() {
return (
<div>
<World />
</div>
)
}
5 changes: 5 additions & 0 deletions test/integration/jsconfig-paths/pages/resolve-fallback.js
@@ -0,0 +1,5 @@
import React from 'react'
import api from '@lib/b-only'
export default function ResolveOrder() {
return <div>{api()}</div>
}
5 changes: 5 additions & 0 deletions test/integration/jsconfig-paths/pages/resolve-order.js
@@ -0,0 +1,5 @@
import React from 'react'
import api from '@lib/api'
export default function ResolveOrder() {
return <div>{api()}</div>
}
41 changes: 41 additions & 0 deletions test/integration/jsconfig-paths/test/index.test.js
@@ -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 changes: 5 additions & 0 deletions test/integration/typescript-baseurl/components/hi.tsx
@@ -0,0 +1,5 @@
import React from 'react'

export function Hi(): JSX.Element {
return <>Hi</>
}
5 changes: 5 additions & 0 deletions test/integration/typescript-paths/components/world.tsx
@@ -0,0 +1,5 @@
import React from 'react'

export function World(): JSX.Element {
return <>World</>
}
1 change: 1 addition & 0 deletions test/integration/typescript-paths/lib/a/api.ts
@@ -0,0 +1 @@
export default () => 'Hello from a'
1 change: 1 addition & 0 deletions test/integration/typescript-paths/lib/b/api.ts
@@ -0,0 +1 @@
export default () => 'Hello from b'
1 change: 1 addition & 0 deletions test/integration/typescript-paths/lib/b/b-only.ts
@@ -0,0 +1 @@
export default () => 'Hello from only b'
9 changes: 9 additions & 0 deletions test/integration/typescript-paths/next.config.js
@@ -0,0 +1,9 @@
module.exports = {
experimental: {
jsconfigPaths: true,
},
onDemandEntries: {
// Make sure entries are not getting disposed.
maxInactiveAge: 1000 * 60 * 60,
},
}
9 changes: 9 additions & 0 deletions test/integration/typescript-paths/pages/basic-alias.tsx
@@ -0,0 +1,9 @@
import React from 'react'
import { World } from '@c/world'
export default function HelloPage(): JSX.Element {
return (
<div>
<World />
</div>
)
}
5 changes: 5 additions & 0 deletions test/integration/typescript-paths/pages/resolve-fallback.tsx
@@ -0,0 +1,5 @@
import React from 'react'
import api from '@lib/b-only'
export default function ResolveOrder(): JSX.Element {
return <div>{api()}</div>
}
5 changes: 5 additions & 0 deletions test/integration/typescript-paths/pages/resolve-order.tsx
@@ -0,0 +1,5 @@
import React from 'react'
import api from '@lib/api'
export default function ResolveOrder(): JSX.Element {
return <div>{api()}</div>
}

0 comments on commit 7fce52b

Please sign in to comment.