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,
|
||||
invalidateTypeCache,
|
||||
recordImports,
|
||||
resolveTypeElements
|
||||
resolveTypeElements,
|
||||
registerTS
|
||||
} from '../../src/script/resolveType'
|
||||
|
||||
import ts from 'typescript'
|
||||
registerTS(ts)
|
||||
|
||||
describe('resolveType', () => {
|
||||
test('type literal', () => {
|
||||
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', () => {
|
||||
expect(
|
||||
resolve(`
|
||||
|
@ -258,8 +275,8 @@ describe('resolveType', () => {
|
|||
type Target = P & PP
|
||||
`,
|
||||
{
|
||||
'foo.ts': 'export type P = { foo: number }',
|
||||
'bar.d.ts': 'type X = { bar: string }; export { X as Y }'
|
||||
'/foo.ts': 'export type P = { foo: number }',
|
||||
'/bar.d.ts': 'type X = { bar: string }; export { X as Y }'
|
||||
}
|
||||
).props
|
||||
).toStrictEqual({
|
||||
|
@ -277,9 +294,9 @@ describe('resolveType', () => {
|
|||
type Target = P & PP
|
||||
`,
|
||||
{
|
||||
'foo.vue':
|
||||
'/foo.vue':
|
||||
'<script lang="ts">export type P = { foo: number }</script>',
|
||||
'bar.vue':
|
||||
'/bar.vue':
|
||||
'<script setup lang="tsx">export type P = { bar: string }</script>'
|
||||
}
|
||||
).props
|
||||
|
@ -297,9 +314,9 @@ describe('resolveType', () => {
|
|||
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`,
|
||||
'nested/bar.vue':
|
||||
'/nested/bar.vue':
|
||||
'<script setup lang="ts">export type P = { bar: string }</script>'
|
||||
}
|
||||
).props
|
||||
|
@ -317,14 +334,45 @@ describe('resolveType', () => {
|
|||
type Target = P
|
||||
`,
|
||||
{
|
||||
'foo.ts': `export { P as PP } from './bar'`,
|
||||
'bar.ts': 'export type P = { bar: string }'
|
||||
'/foo.ts': `export { P as PP } from './bar'`,
|
||||
'/bar.ts': 'export type P = { bar: string }'
|
||||
}
|
||||
).props
|
||||
).toStrictEqual({
|
||||
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', () => {
|
||||
|
@ -356,7 +404,7 @@ describe('resolveType', () => {
|
|||
|
||||
function resolve(code: string, files: Record<string, string> = {}) {
|
||||
const { descriptor } = parse(`<script setup lang="ts">\n${code}\n</script>`, {
|
||||
filename: 'Test.vue'
|
||||
filename: '/Test.vue'
|
||||
})
|
||||
const ctx = new ScriptCompileContext(descriptor, {
|
||||
id: 'test',
|
||||
|
|
|
@ -115,7 +115,7 @@ export interface SFCScriptCompileOptions {
|
|||
*/
|
||||
fs?: {
|
||||
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 { compileScript } from './compileScript'
|
||||
export { rewriteDefault, rewriteDefaultAST } from './rewriteDefault'
|
||||
export { invalidateTypeCache } from './script/resolveType'
|
||||
export {
|
||||
shouldTransform as shouldTransformRef,
|
||||
transform as transformRef,
|
||||
|
@ -29,6 +28,9 @@ export {
|
|||
isStaticProperty
|
||||
} from '@vue/compiler-core'
|
||||
|
||||
// Internals for type resolution
|
||||
export { invalidateTypeCache, registerTS } from './script/resolveType'
|
||||
|
||||
// Types
|
||||
export type {
|
||||
SFCParseOptions,
|
||||
|
|
|
@ -20,14 +20,20 @@ import {
|
|||
TSTypeReference,
|
||||
TemplateLiteral
|
||||
} from '@babel/types'
|
||||
import { UNKNOWN_TYPE, getId, getImportedName } from './utils'
|
||||
import {
|
||||
UNKNOWN_TYPE,
|
||||
createGetCanonicalFileName,
|
||||
getId,
|
||||
getImportedName
|
||||
} from './utils'
|
||||
import { ScriptCompileContext, resolveParserPlugins } from './context'
|
||||
import { ImportBinding, SFCScriptCompileOptions } from '../compileScript'
|
||||
import { capitalize, hasOwn } from '@vue/shared'
|
||||
import path from 'path'
|
||||
import { parse as babelParse } from '@babel/parser'
|
||||
import { parse } from '../parse'
|
||||
import { createCache } from '../cache'
|
||||
import type TS from 'typescript'
|
||||
import { join, extname, dirname } from 'path'
|
||||
|
||||
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(
|
||||
ctx: ScriptCompileContext,
|
||||
node: TSTypeReference | TSExpressionWithTypeArguments,
|
||||
name: string,
|
||||
scope: TypeScope
|
||||
): Node | undefined {
|
||||
const fs = ctx.options.fs
|
||||
const fs: FS = ctx.options.fs || ts?.sys
|
||||
if (!fs) {
|
||||
ctx.error(
|
||||
`fs options for compileScript are required for resolving imported types`,
|
||||
node,
|
||||
scope
|
||||
`No fs option provided to \`compileScript\` in non-Node environment. ` +
|
||||
`File system access is required for resolving imported types.`,
|
||||
node
|
||||
)
|
||||
}
|
||||
// TODO (hmr) register dependency file on ctx
|
||||
|
||||
const containingFile = scope.filename
|
||||
const { source, imported } = scope.imports[name]
|
||||
|
||||
let resolved: string | undefined
|
||||
|
||||
if (source.startsWith('.')) {
|
||||
// relative import - fast path
|
||||
const filename = path.join(containingFile, '..', source)
|
||||
const resolved = resolveExt(filename, fs)
|
||||
if (resolved) {
|
||||
return resolveTypeReference(
|
||||
ctx,
|
||||
node,
|
||||
fileToScope(ctx, resolved, fs),
|
||||
imported,
|
||||
true
|
||||
)
|
||||
} else {
|
||||
const filename = join(containingFile, '..', source)
|
||||
resolved = resolveExt(filename, fs)
|
||||
} else {
|
||||
// module or aliased import - use full TS resolution, only supported in Node
|
||||
if (!__NODE_JS__) {
|
||||
ctx.error(
|
||||
`Failed to resolve import source ${JSON.stringify(
|
||||
source
|
||||
)} for type ${name}`,
|
||||
`Type import from non-relative sources is not supported in the browser build.`,
|
||||
node,
|
||||
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 {
|
||||
// TODO module or aliased import - use full TS resolution
|
||||
return
|
||||
ctx.error(
|
||||
`Failed to resolve import source ${JSON.stringify(
|
||||
source
|
||||
)} for type ${name}`,
|
||||
node,
|
||||
scope
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExt(
|
||||
filename: string,
|
||||
fs: NonNullable<SFCScriptCompileOptions['fs']>
|
||||
) {
|
||||
function resolveExt(filename: string, fs: FS) {
|
||||
const tryResolve = (filename: string) => {
|
||||
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>()
|
||||
|
||||
export function invalidateTypeCache(filename: string) {
|
||||
fileToScopeCache.delete(filename)
|
||||
tsConfigCache.delete(filename)
|
||||
}
|
||||
|
||||
function fileToScope(
|
||||
ctx: ScriptCompileContext,
|
||||
filename: string,
|
||||
fs: NonNullable<SFCScriptCompileOptions['fs']>
|
||||
fs: FS
|
||||
): TypeScope {
|
||||
const cached = fileToScopeCache.get(filename)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const source = fs.readFile(filename)
|
||||
const source = fs.readFile(filename) || ''
|
||||
const body = parseFile(ctx, filename, source)
|
||||
const scope: TypeScope = {
|
||||
filename,
|
||||
|
@ -577,7 +671,7 @@ function parseFile(
|
|||
filename: string,
|
||||
content: string
|
||||
): Statement[] {
|
||||
const ext = path.extname(filename)
|
||||
const ext = extname(filename)
|
||||
if (ext === '.ts' || ext === '.tsx') {
|
||||
return babelParse(content, {
|
||||
plugins: resolveParserPlugins(
|
||||
|
@ -705,7 +799,8 @@ function recordType(node: Node, types: Record<string, Node>) {
|
|||
switch (node.type) {
|
||||
case 'TSInterfaceDeclaration':
|
||||
case 'TSEnumDeclaration':
|
||||
case 'TSModuleDeclaration': {
|
||||
case 'TSModuleDeclaration':
|
||||
case 'ClassDeclaration': {
|
||||
const id = node.id.type === 'Identifier' ? node.id.name : node.id.value
|
||||
types[id] = node
|
||||
break
|
||||
|
@ -899,6 +994,9 @@ export function inferRuntimeType(
|
|||
}
|
||||
}
|
||||
|
||||
case 'ClassDeclaration':
|
||||
return ['Object']
|
||||
|
||||
default:
|
||||
return [UNKNOWN_TYPE] // no runtime check
|
||||
}
|
||||
|
|
|
@ -78,3 +78,22 @@ export function getId(node: Expression) {
|
|||
? node.value
|
||||
: 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 Download from './icons/Download.vue'
|
||||
import GitHub from './icons/GitHub.vue'
|
||||
import { ReplStore } from '@vue/repl'
|
||||
import type { ReplStore } from '@vue/repl'
|
||||
|
||||
const props = defineProps<{
|
||||
store: ReplStore
|
||||
|
|
|
@ -7,7 +7,18 @@ import execa from 'execa'
|
|||
const commit = execa.sync('git', ['rev-parse', 'HEAD']).stdout.slice(0, 7)
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), copyVuePlugin()],
|
||||
plugins: [
|
||||
vue({
|
||||
script: {
|
||||
// @ts-ignore
|
||||
fs: {
|
||||
fileExists: fs.existsSync,
|
||||
readFile: file => fs.readFileSync(file, 'utf-8')
|
||||
}
|
||||
}
|
||||
}),
|
||||
copyVuePlugin()
|
||||
],
|
||||
define: {
|
||||
__COMMIT__: JSON.stringify(commit),
|
||||
__VUE_PROD_DEVTOOLS__: JSON.stringify(true)
|
||||
|
|
|
@ -1 +1,3 @@
|
|||
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'
|
||||
|
|
|
@ -2,4 +2,4 @@
|
|||
"main": "index.js",
|
||||
"module": "index.mjs",
|
||||
"types": "index.d.ts"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
if (typeof require !== 'undefined') {
|
||||
try {
|
||||
require('@vue/compiler-sfc').registerTS(require('typescript'))
|
||||
} catch (e) {}
|
||||
}
|
Loading…
Reference in New Issue