mirror of https://github.com/vuejs/core.git
feat(compiler-sfc): support resolving type imports from modules
This commit is contained in:
parent
8451b92a7a
commit
3982bef533
|
@ -5,9 +5,13 @@ import {
|
||||||
inferRuntimeType,
|
inferRuntimeType,
|
||||||
invalidateTypeCache,
|
invalidateTypeCache,
|
||||||
recordImports,
|
recordImports,
|
||||||
resolveTypeElements
|
resolveTypeElements,
|
||||||
|
registerTS
|
||||||
} from '../../src/script/resolveType'
|
} from '../../src/script/resolveType'
|
||||||
|
|
||||||
|
import ts from 'typescript'
|
||||||
|
registerTS(ts)
|
||||||
|
|
||||||
describe('resolveType', () => {
|
describe('resolveType', () => {
|
||||||
test('type literal', () => {
|
test('type literal', () => {
|
||||||
const { props, calls } = resolve(`type Target = {
|
const { props, calls } = resolve(`type Target = {
|
||||||
|
@ -86,6 +90,19 @@ describe('resolveType', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('reference class', () => {
|
||||||
|
expect(
|
||||||
|
resolve(`
|
||||||
|
class Foo {}
|
||||||
|
type Target = {
|
||||||
|
foo: Foo
|
||||||
|
}
|
||||||
|
`).props
|
||||||
|
).toStrictEqual({
|
||||||
|
foo: ['Object']
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('function type', () => {
|
test('function type', () => {
|
||||||
expect(
|
expect(
|
||||||
resolve(`
|
resolve(`
|
||||||
|
@ -258,8 +275,8 @@ describe('resolveType', () => {
|
||||||
type Target = P & PP
|
type Target = P & PP
|
||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
'foo.ts': 'export type P = { foo: number }',
|
'/foo.ts': 'export type P = { foo: number }',
|
||||||
'bar.d.ts': 'type X = { bar: string }; export { X as Y }'
|
'/bar.d.ts': 'type X = { bar: string }; export { X as Y }'
|
||||||
}
|
}
|
||||||
).props
|
).props
|
||||||
).toStrictEqual({
|
).toStrictEqual({
|
||||||
|
@ -277,9 +294,9 @@ describe('resolveType', () => {
|
||||||
type Target = P & PP
|
type Target = P & PP
|
||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
'foo.vue':
|
'/foo.vue':
|
||||||
'<script lang="ts">export type P = { foo: number }</script>',
|
'<script lang="ts">export type P = { foo: number }</script>',
|
||||||
'bar.vue':
|
'/bar.vue':
|
||||||
'<script setup lang="tsx">export type P = { bar: string }</script>'
|
'<script setup lang="tsx">export type P = { bar: string }</script>'
|
||||||
}
|
}
|
||||||
).props
|
).props
|
||||||
|
@ -297,9 +314,9 @@ describe('resolveType', () => {
|
||||||
type Target = P
|
type Target = P
|
||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
'foo.ts': `import type { P as PP } from './nested/bar.vue'
|
'/foo.ts': `import type { P as PP } from './nested/bar.vue'
|
||||||
export type P = { foo: number } & PP`,
|
export type P = { foo: number } & PP`,
|
||||||
'nested/bar.vue':
|
'/nested/bar.vue':
|
||||||
'<script setup lang="ts">export type P = { bar: string }</script>'
|
'<script setup lang="ts">export type P = { bar: string }</script>'
|
||||||
}
|
}
|
||||||
).props
|
).props
|
||||||
|
@ -317,14 +334,45 @@ describe('resolveType', () => {
|
||||||
type Target = P
|
type Target = P
|
||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
'foo.ts': `export { P as PP } from './bar'`,
|
'/foo.ts': `export { P as PP } from './bar'`,
|
||||||
'bar.ts': 'export type P = { bar: string }'
|
'/bar.ts': 'export type P = { bar: string }'
|
||||||
}
|
}
|
||||||
).props
|
).props
|
||||||
).toStrictEqual({
|
).toStrictEqual({
|
||||||
bar: ['String']
|
bar: ['String']
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('ts module resolve', () => {
|
||||||
|
expect(
|
||||||
|
resolve(
|
||||||
|
`
|
||||||
|
import { P } from 'foo'
|
||||||
|
import { PP } from 'bar'
|
||||||
|
type Target = P & PP
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
'/node_modules/foo/package.json': JSON.stringify({
|
||||||
|
name: 'foo',
|
||||||
|
version: '1.0.0',
|
||||||
|
types: 'index.d.ts'
|
||||||
|
}),
|
||||||
|
'/node_modules/foo/index.d.ts': 'export type P = { foo: number }',
|
||||||
|
'/tsconfig.json': JSON.stringify({
|
||||||
|
compilerOptions: {
|
||||||
|
paths: {
|
||||||
|
bar: ['./other/bar.ts']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
'/other/bar.ts': 'export type PP = { bar: string }'
|
||||||
|
}
|
||||||
|
).props
|
||||||
|
).toStrictEqual({
|
||||||
|
foo: ['Number'],
|
||||||
|
bar: ['String']
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('errors', () => {
|
describe('errors', () => {
|
||||||
|
@ -356,7 +404,7 @@ describe('resolveType', () => {
|
||||||
|
|
||||||
function resolve(code: string, files: Record<string, string> = {}) {
|
function resolve(code: string, files: Record<string, string> = {}) {
|
||||||
const { descriptor } = parse(`<script setup lang="ts">\n${code}\n</script>`, {
|
const { descriptor } = parse(`<script setup lang="ts">\n${code}\n</script>`, {
|
||||||
filename: 'Test.vue'
|
filename: '/Test.vue'
|
||||||
})
|
})
|
||||||
const ctx = new ScriptCompileContext(descriptor, {
|
const ctx = new ScriptCompileContext(descriptor, {
|
||||||
id: 'test',
|
id: 'test',
|
||||||
|
|
|
@ -115,7 +115,7 @@ export interface SFCScriptCompileOptions {
|
||||||
*/
|
*/
|
||||||
fs?: {
|
fs?: {
|
||||||
fileExists(file: string): boolean
|
fileExists(file: string): boolean
|
||||||
readFile(file: string): string
|
readFile(file: string): string | undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,6 @@ export { compileTemplate } from './compileTemplate'
|
||||||
export { compileStyle, compileStyleAsync } from './compileStyle'
|
export { compileStyle, compileStyleAsync } from './compileStyle'
|
||||||
export { compileScript } from './compileScript'
|
export { compileScript } from './compileScript'
|
||||||
export { rewriteDefault, rewriteDefaultAST } from './rewriteDefault'
|
export { rewriteDefault, rewriteDefaultAST } from './rewriteDefault'
|
||||||
export { invalidateTypeCache } from './script/resolveType'
|
|
||||||
export {
|
export {
|
||||||
shouldTransform as shouldTransformRef,
|
shouldTransform as shouldTransformRef,
|
||||||
transform as transformRef,
|
transform as transformRef,
|
||||||
|
@ -29,6 +28,9 @@ export {
|
||||||
isStaticProperty
|
isStaticProperty
|
||||||
} from '@vue/compiler-core'
|
} from '@vue/compiler-core'
|
||||||
|
|
||||||
|
// Internals for type resolution
|
||||||
|
export { invalidateTypeCache, registerTS } from './script/resolveType'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type {
|
export type {
|
||||||
SFCParseOptions,
|
SFCParseOptions,
|
||||||
|
|
|
@ -20,14 +20,20 @@ import {
|
||||||
TSTypeReference,
|
TSTypeReference,
|
||||||
TemplateLiteral
|
TemplateLiteral
|
||||||
} from '@babel/types'
|
} from '@babel/types'
|
||||||
import { UNKNOWN_TYPE, getId, getImportedName } from './utils'
|
import {
|
||||||
|
UNKNOWN_TYPE,
|
||||||
|
createGetCanonicalFileName,
|
||||||
|
getId,
|
||||||
|
getImportedName
|
||||||
|
} from './utils'
|
||||||
import { ScriptCompileContext, resolveParserPlugins } from './context'
|
import { ScriptCompileContext, resolveParserPlugins } from './context'
|
||||||
import { ImportBinding, SFCScriptCompileOptions } from '../compileScript'
|
import { ImportBinding, SFCScriptCompileOptions } from '../compileScript'
|
||||||
import { capitalize, hasOwn } from '@vue/shared'
|
import { capitalize, hasOwn } from '@vue/shared'
|
||||||
import path from 'path'
|
|
||||||
import { parse as babelParse } from '@babel/parser'
|
import { parse as babelParse } from '@babel/parser'
|
||||||
import { parse } from '../parse'
|
import { parse } from '../parse'
|
||||||
import { createCache } from '../cache'
|
import { createCache } from '../cache'
|
||||||
|
import type TS from 'typescript'
|
||||||
|
import { join, extname, dirname } from 'path'
|
||||||
|
|
||||||
type Import = Pick<ImportBinding, 'source' | 'imported'>
|
type Import = Pick<ImportBinding, 'source' | 'imported'>
|
||||||
|
|
||||||
|
@ -480,54 +486,82 @@ function qualifiedNameToPath(node: Identifier | TSQualifiedName): string[] {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ts: typeof TS
|
||||||
|
|
||||||
|
export function registerTS(_ts: any) {
|
||||||
|
ts = _ts
|
||||||
|
}
|
||||||
|
|
||||||
|
type FS = NonNullable<SFCScriptCompileOptions['fs']>
|
||||||
|
|
||||||
function resolveTypeFromImport(
|
function resolveTypeFromImport(
|
||||||
ctx: ScriptCompileContext,
|
ctx: ScriptCompileContext,
|
||||||
node: TSTypeReference | TSExpressionWithTypeArguments,
|
node: TSTypeReference | TSExpressionWithTypeArguments,
|
||||||
name: string,
|
name: string,
|
||||||
scope: TypeScope
|
scope: TypeScope
|
||||||
): Node | undefined {
|
): Node | undefined {
|
||||||
const fs = ctx.options.fs
|
const fs: FS = ctx.options.fs || ts?.sys
|
||||||
if (!fs) {
|
if (!fs) {
|
||||||
ctx.error(
|
ctx.error(
|
||||||
`fs options for compileScript are required for resolving imported types`,
|
`No fs option provided to \`compileScript\` in non-Node environment. ` +
|
||||||
node,
|
`File system access is required for resolving imported types.`,
|
||||||
scope
|
node
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// TODO (hmr) register dependency file on ctx
|
|
||||||
const containingFile = scope.filename
|
const containingFile = scope.filename
|
||||||
const { source, imported } = scope.imports[name]
|
const { source, imported } = scope.imports[name]
|
||||||
|
|
||||||
|
let resolved: string | undefined
|
||||||
|
|
||||||
if (source.startsWith('.')) {
|
if (source.startsWith('.')) {
|
||||||
// relative import - fast path
|
// relative import - fast path
|
||||||
const filename = path.join(containingFile, '..', source)
|
const filename = join(containingFile, '..', source)
|
||||||
const resolved = resolveExt(filename, fs)
|
resolved = resolveExt(filename, fs)
|
||||||
if (resolved) {
|
} else {
|
||||||
return resolveTypeReference(
|
// module or aliased import - use full TS resolution, only supported in Node
|
||||||
ctx,
|
if (!__NODE_JS__) {
|
||||||
node,
|
|
||||||
fileToScope(ctx, resolved, fs),
|
|
||||||
imported,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ctx.error(
|
ctx.error(
|
||||||
`Failed to resolve import source ${JSON.stringify(
|
`Type import from non-relative sources is not supported in the browser build.`,
|
||||||
source
|
|
||||||
)} for type ${name}`,
|
|
||||||
node,
|
node,
|
||||||
scope
|
scope
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (!ts) {
|
||||||
|
ctx.error(
|
||||||
|
`Failed to resolve type ${imported} from module ${JSON.stringify(
|
||||||
|
source
|
||||||
|
)}. ` +
|
||||||
|
`typescript is required as a peer dep for vue in order ` +
|
||||||
|
`to support resolving types from module imports.`,
|
||||||
|
node,
|
||||||
|
scope
|
||||||
|
)
|
||||||
|
}
|
||||||
|
resolved = resolveWithTS(containingFile, source, fs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved) {
|
||||||
|
// TODO (hmr) register dependency file on ctx
|
||||||
|
return resolveTypeReference(
|
||||||
|
ctx,
|
||||||
|
node,
|
||||||
|
fileToScope(ctx, resolved, fs),
|
||||||
|
imported,
|
||||||
|
true
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// TODO module or aliased import - use full TS resolution
|
ctx.error(
|
||||||
return
|
`Failed to resolve import source ${JSON.stringify(
|
||||||
|
source
|
||||||
|
)} for type ${name}`,
|
||||||
|
node,
|
||||||
|
scope
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveExt(
|
function resolveExt(filename: string, fs: FS) {
|
||||||
filename: string,
|
|
||||||
fs: NonNullable<SFCScriptCompileOptions['fs']>
|
|
||||||
) {
|
|
||||||
const tryResolve = (filename: string) => {
|
const tryResolve = (filename: string) => {
|
||||||
if (fs.fileExists(filename)) return filename
|
if (fs.fileExists(filename)) return filename
|
||||||
}
|
}
|
||||||
|
@ -540,23 +574,83 @@ function resolveExt(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tsConfigCache = createCache<{
|
||||||
|
options: TS.CompilerOptions
|
||||||
|
cache: TS.ModuleResolutionCache
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function resolveWithTS(
|
||||||
|
containingFile: string,
|
||||||
|
source: string,
|
||||||
|
fs: FS
|
||||||
|
): string | undefined {
|
||||||
|
if (!__NODE_JS__) return
|
||||||
|
|
||||||
|
// 1. resolve tsconfig.json
|
||||||
|
const configPath = ts.findConfigFile(containingFile, fs.fileExists)
|
||||||
|
// 2. load tsconfig.json
|
||||||
|
let options: TS.CompilerOptions
|
||||||
|
let cache: TS.ModuleResolutionCache | undefined
|
||||||
|
if (configPath) {
|
||||||
|
const cached = tsConfigCache.get(configPath)
|
||||||
|
if (!cached) {
|
||||||
|
// The only case where `fs` is NOT `ts.sys` is during tests.
|
||||||
|
// parse config host requires an extra `readDirectory` method
|
||||||
|
// during tests, which is stubbed.
|
||||||
|
const parseConfigHost = __TEST__
|
||||||
|
? {
|
||||||
|
...fs,
|
||||||
|
useCaseSensitiveFileNames: true,
|
||||||
|
readDirectory: () => []
|
||||||
|
}
|
||||||
|
: ts.sys
|
||||||
|
const parsed = ts.parseJsonConfigFileContent(
|
||||||
|
ts.readConfigFile(configPath, fs.readFile).config,
|
||||||
|
parseConfigHost,
|
||||||
|
dirname(configPath),
|
||||||
|
undefined,
|
||||||
|
configPath
|
||||||
|
)
|
||||||
|
options = parsed.options
|
||||||
|
cache = ts.createModuleResolutionCache(
|
||||||
|
process.cwd(),
|
||||||
|
createGetCanonicalFileName(ts.sys.useCaseSensitiveFileNames),
|
||||||
|
options
|
||||||
|
)
|
||||||
|
tsConfigCache.set(configPath, { options, cache })
|
||||||
|
} else {
|
||||||
|
;({ options, cache } = cached)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
options = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. resolve
|
||||||
|
const res = ts.resolveModuleName(source, containingFile, options, fs, cache)
|
||||||
|
|
||||||
|
if (res.resolvedModule) {
|
||||||
|
return res.resolvedModule.resolvedFileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fileToScopeCache = createCache<TypeScope>()
|
const fileToScopeCache = createCache<TypeScope>()
|
||||||
|
|
||||||
export function invalidateTypeCache(filename: string) {
|
export function invalidateTypeCache(filename: string) {
|
||||||
fileToScopeCache.delete(filename)
|
fileToScopeCache.delete(filename)
|
||||||
|
tsConfigCache.delete(filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileToScope(
|
function fileToScope(
|
||||||
ctx: ScriptCompileContext,
|
ctx: ScriptCompileContext,
|
||||||
filename: string,
|
filename: string,
|
||||||
fs: NonNullable<SFCScriptCompileOptions['fs']>
|
fs: FS
|
||||||
): TypeScope {
|
): TypeScope {
|
||||||
const cached = fileToScopeCache.get(filename)
|
const cached = fileToScopeCache.get(filename)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = fs.readFile(filename)
|
const source = fs.readFile(filename) || ''
|
||||||
const body = parseFile(ctx, filename, source)
|
const body = parseFile(ctx, filename, source)
|
||||||
const scope: TypeScope = {
|
const scope: TypeScope = {
|
||||||
filename,
|
filename,
|
||||||
|
@ -577,7 +671,7 @@ function parseFile(
|
||||||
filename: string,
|
filename: string,
|
||||||
content: string
|
content: string
|
||||||
): Statement[] {
|
): Statement[] {
|
||||||
const ext = path.extname(filename)
|
const ext = extname(filename)
|
||||||
if (ext === '.ts' || ext === '.tsx') {
|
if (ext === '.ts' || ext === '.tsx') {
|
||||||
return babelParse(content, {
|
return babelParse(content, {
|
||||||
plugins: resolveParserPlugins(
|
plugins: resolveParserPlugins(
|
||||||
|
@ -705,7 +799,8 @@ function recordType(node: Node, types: Record<string, Node>) {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case 'TSInterfaceDeclaration':
|
case 'TSInterfaceDeclaration':
|
||||||
case 'TSEnumDeclaration':
|
case 'TSEnumDeclaration':
|
||||||
case 'TSModuleDeclaration': {
|
case 'TSModuleDeclaration':
|
||||||
|
case 'ClassDeclaration': {
|
||||||
const id = node.id.type === 'Identifier' ? node.id.name : node.id.value
|
const id = node.id.type === 'Identifier' ? node.id.name : node.id.value
|
||||||
types[id] = node
|
types[id] = node
|
||||||
break
|
break
|
||||||
|
@ -899,6 +994,9 @@ export function inferRuntimeType(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'ClassDeclaration':
|
||||||
|
return ['Object']
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return [UNKNOWN_TYPE] // no runtime check
|
return [UNKNOWN_TYPE] // no runtime check
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,3 +78,22 @@ export function getId(node: Expression) {
|
||||||
? node.value
|
? node.value
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const identity = (str: string) => str
|
||||||
|
const fileNameLowerCaseRegExp = /[^\u0130\u0131\u00DFa-z0-9\\/:\-_\. ]+/g
|
||||||
|
const toLowerCase = (str: string) => str.toLowerCase()
|
||||||
|
|
||||||
|
function toFileNameLowerCase(x: string) {
|
||||||
|
return fileNameLowerCaseRegExp.test(x)
|
||||||
|
? x.replace(fileNameLowerCaseRegExp, toLowerCase)
|
||||||
|
: x
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We need `getCanonicalFileName` when creating ts module resolution cache,
|
||||||
|
* but TS does not expose it directly. This implementation is repllicated from
|
||||||
|
* the TS source code.
|
||||||
|
*/
|
||||||
|
export function createGetCanonicalFileName(useCaseSensitiveFileNames: boolean) {
|
||||||
|
return useCaseSensitiveFileNames ? identity : toFileNameLowerCase
|
||||||
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import Moon from './icons/Moon.vue'
|
||||||
import Share from './icons/Share.vue'
|
import Share from './icons/Share.vue'
|
||||||
import Download from './icons/Download.vue'
|
import Download from './icons/Download.vue'
|
||||||
import GitHub from './icons/GitHub.vue'
|
import GitHub from './icons/GitHub.vue'
|
||||||
import { ReplStore } from '@vue/repl'
|
import type { ReplStore } from '@vue/repl'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
store: ReplStore
|
store: ReplStore
|
||||||
|
|
|
@ -7,7 +7,18 @@ import execa from 'execa'
|
||||||
const commit = execa.sync('git', ['rev-parse', 'HEAD']).stdout.slice(0, 7)
|
const commit = execa.sync('git', ['rev-parse', 'HEAD']).stdout.slice(0, 7)
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue(), copyVuePlugin()],
|
plugins: [
|
||||||
|
vue({
|
||||||
|
script: {
|
||||||
|
// @ts-ignore
|
||||||
|
fs: {
|
||||||
|
fileExists: fs.existsSync,
|
||||||
|
readFile: file => fs.readFileSync(file, 'utf-8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
copyVuePlugin()
|
||||||
|
],
|
||||||
define: {
|
define: {
|
||||||
__COMMIT__: JSON.stringify(commit),
|
__COMMIT__: JSON.stringify(commit),
|
||||||
__VUE_PROD_DEVTOOLS__: JSON.stringify(true)
|
__VUE_PROD_DEVTOOLS__: JSON.stringify(true)
|
||||||
|
|
|
@ -1 +1,3 @@
|
||||||
module.exports = require('@vue/compiler-sfc')
|
module.exports = require('@vue/compiler-sfc')
|
||||||
|
|
||||||
|
require('./register-ts.js')
|
||||||
|
|
|
@ -1 +1,3 @@
|
||||||
export * from '@vue/compiler-sfc'
|
export * from '@vue/compiler-sfc'
|
||||||
|
|
||||||
|
import './register-ts.js'
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
if (typeof require !== 'undefined') {
|
||||||
|
try {
|
||||||
|
require('@vue/compiler-sfc').registerTS(require('typescript'))
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
Loading…
Reference in New Issue