diff --git a/package-lock.json b/package-lock.json index 435f16d19bf..6253b0dfdaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6535,6 +6535,10 @@ "resolved": "packages/react", "link": true }, + "node_modules/@primer/react-metadata": { + "resolved": "packages/react-metadata", + "link": true + }, "node_modules/@primer/stylelint-config": { "version": "13.1.1", "dev": true, @@ -28412,9 +28416,9 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -30769,6 +30773,13 @@ } } }, + "packages/react-metadata": { + "name": "@primer/react-metadata", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.8.3" + } + }, "packages/react/node_modules/@figma/code-connect": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@figma/code-connect/-/code-connect-1.3.1.tgz", diff --git a/packages/react-metadata/package.json b/packages/react-metadata/package.json new file mode 100644 index 00000000000..38489d4b8c5 --- /dev/null +++ b/packages/react-metadata/package.json @@ -0,0 +1,10 @@ +{ + "name": "@primer/react-metadata", + "version": "0.1.0", + "type": "module", + "private": true, + "exports": "./src/index.ts", + "devDependencies": { + "typescript": "^5.8.3" + } +} diff --git a/packages/react-metadata/src/__tests__/react-metadata.test.ts b/packages/react-metadata/src/__tests__/react-metadata.test.ts new file mode 100644 index 00000000000..3881990789a --- /dev/null +++ b/packages/react-metadata/src/__tests__/react-metadata.test.ts @@ -0,0 +1,1461 @@ +import ts from 'typescript' +import {describe, test, expect} from 'vitest' +import {getMetadataFromSourceFile} from '../' + +describe('keywords', () => { + test('any', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = any + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'Any', + }, + typeParameters: [], + }, + ], + }) + }) + + test('boolean', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = boolean + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'Boolean', + }, + typeParameters: [], + }, + ], + }) + }) + + test('never', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = never + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'Never', + }, + typeParameters: [], + }, + ], + }) + }) + + test('null', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = null + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'Null', + }, + typeParameters: [], + }, + ], + }) + }) + + test('number', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = number + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'Number', + }, + typeParameters: [], + }, + ], + }) + }) + + test('string', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = string + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'String', + }, + typeParameters: [], + }, + ], + }) + }) + + test('undefined', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = undefined + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'Undefined', + }, + typeParameters: [], + }, + ], + }) + }) + + test('unknown', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = unknown + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'Unknown', + }, + typeParameters: [], + }, + ], + }) + }) +}) + +describe('literals', () => { + test('boolean literal', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t1 = true + export type t2 = false + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't1', + type: { + kind: 'BooleanLiteral', + value: 'true', + }, + typeParameters: [], + }, + { + kind: 'TypeAlias', + name: 't2', + type: { + kind: 'BooleanLiteral', + value: 'false', + }, + typeParameters: [], + }, + ], + }) + }) + + test('numeric literal', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = 1 + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'NumericLiteral', + value: '1', + }, + typeParameters: [], + }, + ], + }) + }) + + test('string literal', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = 'test' + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'StringLiteral', + value: 'test', + }, + typeParameters: [], + }, + ], + }) + }) +}) + +describe('functions', () => { + test('function declaration', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export function t(): void {} + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'FunctionDeclaration', + name: 't', + signatures: [ + { + kind: 'Signature', + parameters: [], + returnType: { + kind: 'Void', + }, + typeParameters: [], + }, + ], + }, + ], + }) + }) + + test('function declaration with inferred return type', () => { + const program = getTypeScriptProgram({ + 'test.tsx': ` + import React from 'react' + + export function t1() {} + + export function t2() { + return 1 + } + + export function t3() { + return [1, 2, 3] + } + + export function t4() { + return
+ } + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.tsx')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'FunctionDeclaration', + name: 't1', + signatures: [ + { + kind: 'Signature', + parameters: [], + returnType: { + kind: 'Void', + }, + typeParameters: [], + }, + ], + }, + { + kind: 'FunctionDeclaration', + name: 't2', + signatures: [ + { + kind: 'Signature', + parameters: [], + returnType: { + kind: 'Number', + }, + typeParameters: [], + }, + ], + }, + { + kind: 'FunctionDeclaration', + name: 't3', + signatures: [ + { + kind: 'Signature', + parameters: [], + returnType: { + kind: 'TypeReference', + typeName: { + kind: 'Identifier', + name: 'Array', + }, + typeArguments: [ + { + kind: 'Number', + }, + ], + }, + typeParameters: [], + }, + ], + }, + { + kind: 'FunctionDeclaration', + name: 't4', + signatures: [ + { + kind: 'Signature', + parameters: [], + returnType: { + kind: 'TypeReference', + typeName: { + kind: 'QualifiedName', + left: { + kind: 'QualifiedName', + left: { + kind: 'Identifier', + name: 'React', + }, + right: { + kind: 'Identifier', + name: 'JSX', + }, + }, + right: { + kind: 'Identifier', + name: 'Element', + }, + }, + typeArguments: [], + }, + typeParameters: [], + }, + ], + }, + ], + }) + }) + + test('parameters', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export function t1(a: number) {} + export function t2(a?: number) {} + export function t3(a: React.ReactNode) {} + export function t4(a: number, ...rest: Array) {} + export function t5({a, b: renamed, ...rest}: {a: number; b: number; c: number}) {} + export function t6([a, ...rest]: [number, number]) {} + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'FunctionDeclaration', + name: 't1', + signatures: [ + { + kind: 'Signature', + parameters: [ + { + kind: 'Parameter', + name: { + kind: 'Identifier', + name: 'a', + }, + type: { + kind: 'Number', + }, + optional: false, + rest: false, + }, + ], + returnType: { + kind: 'Void', + }, + typeParameters: [], + }, + ], + }, + { + kind: 'FunctionDeclaration', + name: 't2', + signatures: [ + { + kind: 'Signature', + parameters: [ + { + kind: 'Parameter', + name: { + kind: 'Identifier', + name: 'a', + }, + type: { + kind: 'Number', + }, + optional: true, + rest: false, + }, + ], + returnType: { + kind: 'Void', + }, + typeParameters: [], + }, + ], + }, + { + kind: 'FunctionDeclaration', + name: 't3', + signatures: [ + { + kind: 'Signature', + parameters: [ + { + kind: 'Parameter', + name: { + kind: 'Identifier', + name: 'a', + }, + type: { + kind: 'TypeReference', + typeName: { + kind: 'QualifiedName', + left: { + kind: 'Identifier', + name: 'React', + }, + right: { + kind: 'Identifier', + name: 'ReactNode', + }, + }, + typeArguments: [], + }, + optional: false, + rest: false, + }, + ], + returnType: { + kind: 'Void', + }, + typeParameters: [], + }, + ], + }, + { + kind: 'FunctionDeclaration', + name: 't4', + signatures: [ + { + kind: 'Signature', + parameters: [ + { + kind: 'Parameter', + name: { + kind: 'Identifier', + name: 'a', + }, + type: { + kind: 'Number', + }, + optional: false, + rest: false, + }, + { + kind: 'Parameter', + name: { + kind: 'Identifier', + name: 'rest', + }, + type: { + kind: 'TypeReference', + typeName: { + kind: 'Identifier', + name: 'Array', + }, + typeArguments: [ + { + kind: 'String', + }, + ], + }, + optional: false, + rest: true, + }, + ], + returnType: { + kind: 'Void', + }, + typeParameters: [], + }, + ], + }, + { + kind: 'FunctionDeclaration', + name: 't5', + signatures: [ + { + kind: 'Signature', + parameters: [ + { + kind: 'Parameter', + name: { + kind: 'ObjectBindingPattern', + elements: [ + { + kind: 'BindingElement', + name: { + kind: 'Identifier', + name: 'a', + }, + restProperty: false, + }, + { + kind: 'BindingElement', + name: { + kind: 'Identifier', + name: 'renamed', + }, + propertyName: { + kind: 'Identifier', + name: 'b', + }, + restProperty: false, + }, + { + kind: 'BindingElement', + name: { + kind: 'Identifier', + name: 'rest', + }, + restProperty: true, + }, + ], + }, + type: { + kind: 'TypeLiteral', + members: [ + { + kind: 'PropertySignature', + name: 'a', + optional: false, + type: { + kind: 'Number', + }, + }, + { + kind: 'PropertySignature', + name: 'b', + optional: false, + type: { + kind: 'Number', + }, + }, + { + kind: 'PropertySignature', + name: 'c', + optional: false, + type: { + kind: 'Number', + }, + }, + ], + }, + optional: false, + rest: false, + }, + ], + returnType: { + kind: 'Void', + }, + typeParameters: [], + }, + ], + }, + { + kind: 'FunctionDeclaration', + name: 't6', + signatures: [ + { + kind: 'Signature', + parameters: [ + { + kind: 'Parameter', + name: { + kind: 'ArrayBindingPattern', + elements: [ + { + kind: 'BindingElement', + name: { + kind: 'Identifier', + name: 'a', + }, + restProperty: false, + }, + { + kind: 'BindingElement', + name: { + kind: 'Identifier', + name: 'rest', + }, + restProperty: true, + }, + ], + }, + type: { + kind: 'Tuple', + elements: [ + { + kind: 'Number', + }, + { + kind: 'Number', + }, + ], + }, + optional: false, + rest: false, + }, + ], + returnType: { + kind: 'Void', + }, + typeParameters: [], + }, + ], + }, + ], + }) + }) + + test('type parameters', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export function t(a: T) {} + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'FunctionDeclaration', + name: 't', + signatures: [ + { + kind: 'Signature', + parameters: [ + { + kind: 'Parameter', + name: { + kind: 'Identifier', + name: 'a', + }, + type: { + kind: 'TypeReference', + typeName: { + kind: 'Identifier', + name: 'T', + }, + typeArguments: [], + }, + optional: false, + rest: false, + }, + ], + returnType: { + kind: 'Void', + }, + typeParameters: [ + { + kind: 'TypeParameter', + name: 'T', + constraint: { + kind: 'String', + }, + default: { + kind: 'String', + }, + }, + ], + }, + ], + }, + ], + }) + }) + + test('function type', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t1 = () => void + export type t2 = (a: number) => void + export type t3 = (a?: number) => void + export type t4 = (a: number, ...rest: Array) => void + export type t5 = ({ a, b, ...rest}: { a: number; b: number; c: number; }) => void + export type t6 = ([a, ...rest]: [number, number]) => void + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't1', + type: { + kind: 'Function', + parameters: [], + returnType: { + kind: 'Void', + }, + typeParameters: [], + }, + typeParameters: [], + }, + { + kind: 'TypeAlias', + name: 't2', + type: { + kind: 'Function', + parameters: [ + { + kind: 'Parameter', + name: { + kind: 'Identifier', + name: 'a', + }, + type: { + kind: 'Number', + }, + optional: false, + rest: false, + }, + ], + returnType: { + kind: 'Void', + }, + typeParameters: [], + }, + typeParameters: [], + }, + { + kind: 'TypeAlias', + name: 't3', + type: { + kind: 'Function', + parameters: [ + { + kind: 'Parameter', + name: { + kind: 'Identifier', + name: 'a', + }, + type: { + kind: 'Number', + }, + optional: true, + rest: false, + }, + ], + returnType: { + kind: 'Void', + }, + typeParameters: [], + }, + typeParameters: [], + }, + { + kind: 'TypeAlias', + name: 't4', + type: { + kind: 'Function', + parameters: [ + { + kind: 'Parameter', + name: { + kind: 'Identifier', + name: 'a', + }, + type: { + kind: 'Number', + }, + optional: false, + rest: false, + }, + { + kind: 'Parameter', + name: { + kind: 'Identifier', + name: 'rest', + }, + type: { + kind: 'TypeReference', + typeName: { + kind: 'Identifier', + name: 'Array', + }, + typeArguments: [ + { + kind: 'String', + }, + ], + }, + optional: false, + rest: true, + }, + ], + returnType: { + kind: 'Void', + }, + typeParameters: [], + }, + typeParameters: [], + }, + { + kind: 'TypeAlias', + name: 't5', + type: { + kind: 'Function', + parameters: [ + { + kind: 'Parameter', + optional: false, + rest: false, + name: { + kind: 'ObjectBindingPattern', + elements: [ + { + kind: 'BindingElement', + name: { + kind: 'Identifier', + name: 'a', + }, + restProperty: false, + }, + { + kind: 'BindingElement', + name: { + kind: 'Identifier', + name: 'b', + }, + restProperty: false, + }, + { + kind: 'BindingElement', + name: { + kind: 'Identifier', + name: 'rest', + }, + restProperty: true, + }, + ], + }, + type: { + kind: 'TypeLiteral', + members: [ + { + kind: 'PropertySignature', + name: 'a', + optional: false, + type: { + kind: 'Number', + }, + }, + { + kind: 'PropertySignature', + name: 'b', + optional: false, + type: { + kind: 'Number', + }, + }, + { + kind: 'PropertySignature', + name: 'c', + optional: false, + type: { + kind: 'Number', + }, + }, + ], + }, + }, + ], + returnType: { + kind: 'Void', + }, + typeParameters: [], + }, + typeParameters: [], + }, + { + kind: 'TypeAlias', + name: 't6', + type: { + kind: 'Function', + parameters: [ + { + kind: 'Parameter', + name: { + kind: 'ArrayBindingPattern', + elements: [ + { + kind: 'BindingElement', + name: { + kind: 'Identifier', + name: 'a', + }, + restProperty: false, + }, + { + kind: 'BindingElement', + name: { + kind: 'Identifier', + name: 'rest', + }, + restProperty: true, + }, + ], + }, + type: { + kind: 'Tuple', + elements: [ + { + kind: 'Number', + }, + { + kind: 'Number', + }, + ], + }, + optional: false, + rest: false, + }, + ], + returnType: { + kind: 'Void', + }, + typeParameters: [], + }, + typeParameters: [], + }, + ], + }) + }) +}) + +describe('type references', () => { + test('identifier', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = Array + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'TypeReference', + typeName: { + kind: 'Identifier', + name: 'Array', + }, + typeArguments: [ + { + kind: 'String', + }, + ], + }, + typeParameters: [], + }, + ], + }) + }) + + test('qualified name', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = React.ComponentPropsWithoutRef<'div'> + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'TypeReference', + typeName: { + kind: 'QualifiedName', + left: { + kind: 'Identifier', + name: 'React', + }, + right: { + kind: 'Identifier', + name: 'ComponentPropsWithoutRef', + }, + }, + typeArguments: [ + { + kind: 'StringLiteral', + value: 'div', + }, + ], + }, + typeParameters: [], + }, + ], + }) + }) +}) + +describe('type aliases', () => { + test('type alias', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = string + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'String', + }, + typeParameters: [], + }, + ], + }) + }) + + test('type alias with generics', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = string + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + typeParameters: [ + { + kind: 'TypeParameter', + name: 'A', + constraint: undefined, + default: undefined, + }, + { + kind: 'TypeParameter', + name: 'B', + constraint: { + kind: 'String', + }, + default: undefined, + }, + { + kind: 'TypeParameter', + name: 'C', + constraint: undefined, + default: { + kind: 'String', + }, + }, + ], + type: { + kind: 'String', + }, + }, + ], + }) + }) +}) + +describe('type literals', () => { + test('type literal', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = { + className: string + } + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'TypeLiteral', + members: [ + { + kind: 'PropertySignature', + name: 'className', + optional: false, + type: { + kind: 'String', + }, + }, + ], + }, + typeParameters: [], + }, + ], + }) + }) + + test('type literal with optional member', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = { + className?: string + } + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'TypeLiteral', + members: [ + { + kind: 'PropertySignature', + name: 'className', + optional: true, + type: { + kind: 'String', + }, + }, + ], + }, + typeParameters: [], + }, + ], + }) + }) +}) + +describe('intersections', () => { + test('intersection', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = { open: boolean } & { className: string } + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'Intersection', + members: [ + { + kind: 'TypeLiteral', + members: [ + { + kind: 'PropertySignature', + name: 'open', + optional: false, + type: { + kind: 'Boolean', + }, + }, + ], + }, + { + kind: 'TypeLiteral', + members: [ + { + kind: 'PropertySignature', + name: 'className', + optional: false, + type: { + kind: 'String', + }, + }, + ], + }, + ], + }, + typeParameters: [], + }, + ], + }) + }) +}) + +describe('unions', () => { + test('union', () => { + const program = getTypeScriptProgram({ + 'test.ts': ` + export type t = { type: 'a' } | { type: 'b' } + `, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'TypeAlias', + name: 't', + type: { + kind: 'Union', + members: [ + { + kind: 'TypeLiteral', + members: [ + { + kind: 'PropertySignature', + name: 'type', + optional: false, + type: { + kind: 'StringLiteral', + value: 'a', + }, + }, + ], + }, + { + kind: 'TypeLiteral', + members: [ + { + kind: 'PropertySignature', + name: 'type', + optional: false, + type: { + kind: 'StringLiteral', + value: 'b', + }, + }, + ], + }, + ], + }, + typeParameters: [], + }, + ], + }) + }) +}) + +describe('variable declarations', () => { + test('variable declaration', () => { + const program = getTypeScriptProgram({ + 'test.ts': `export const t = 1;`, + }) + + const typeChecker = program.getTypeChecker() + const sourceFile = program.getSourceFile('test.ts')! + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + expect(metadata).toEqual({ + exports: [ + { + kind: 'VariableDeclaration', + name: { + kind: 'Identifier', + name: 't', + }, + type: { + kind: 'NumericLiteral', + value: '1', + }, + }, + ], + }) + }) +}) + +describe.skip('interfaces', () => { + // +}) + +describe.skip('export specifiers', () => { + // +}) + +describe.skip('unsupported', () => { + // +}) + +function getTypeScriptProgram(fsMap: Record): ts.Program { + const memfs = new Map() + for (const [fileName, content] of Object.entries(fsMap)) { + memfs.set(fileName, ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true)) + } + + const compilerOptions: ts.CompilerOptions = { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + target: ts.ScriptTarget.Latest, + skipLibCheck: true, + strict: true, + isolatedModules: true, + esModuleInterop: true, + jsx: ts.JsxEmit.Preserve, + } + const host = ts.createCompilerHost(compilerOptions) + const program = ts.createProgram({ + rootNames: Array.from(memfs.keys()), + options: compilerOptions, + host: { + ...host, + getSourceFile(fileName, ...args) { + if (memfs.has(fileName)) { + return memfs.get(fileName) + } + return host.getSourceFile(fileName, ...args) + }, + }, + }) + const diagnostics = [ + ...program.getSyntacticDiagnostics(), + ...program.getGlobalDiagnostics(), + ...program.getSemanticDiagnostics(), + ...program.getDeclarationDiagnostics(), + ] + if (diagnostics.length > 0) { + throw new Error(diagnostics.map(d => d.messageText).join('\n')) + } + + return program +} diff --git a/packages/react-metadata/src/index.ts b/packages/react-metadata/src/index.ts new file mode 100644 index 00000000000..82382f71b08 --- /dev/null +++ b/packages/react-metadata/src/index.ts @@ -0,0 +1,1203 @@ +import ts from 'typescript' +import {isNodeExported} from './isNodeExported.ts' + +function getMetadataFromSourceFile(typeChecker: ts.TypeChecker, sourceFile: ts.SourceFile): Metadata { + const metadata: Metadata = { + exports: [], + } + + ts.forEachChild(sourceFile, node => { + if (!isNodeExported(node)) { + return + } + + if (ts.isExportDeclaration(node)) { + if (ts.isExportDeclaration(node)) { + const exportClause = node.exportClause + if (!exportClause) { + return + } + + if (ts.isNamedExports(exportClause)) { + for (const element of exportClause.elements) { + if (!ts.isExportSpecifier(element)) { + continue + } + + // Get node for export specifier + const symbol = typeChecker.getExportSpecifierLocalTargetSymbol(element) + if (!symbol) { + continue + } + + if (!symbol.declarations) { + continue + } + + for (const declaration of symbol.declarations) { + const type = getTypeInfo(typeChecker, declaration) + metadata.exports.push({ + kind: 'ExportSpecifier', + name: element.name.getText(), + type, + }) + break + } + } + } + } + } else if (ts.isVariableStatement(node)) { + for (const declaration of node.declarationList.declarations) { + const type = getTypeInfo(typeChecker, declaration) + metadata.exports.push(type) + } + } else { + const type = getTypeInfo(typeChecker, node) + metadata.exports.push(type) + } + }) + + return metadata +} + +type Metadata = { + exports: Array +} + +type Type = + | { + kind: 'ExportSpecifier' + name: string + type: Type + } + | { + kind: 'TypeAlias' + name: string + typeParameters: Array + type: Type + } + | CallExpression + | PropertyAccessExpression + | TypeParameter + | VariableDeclaration + | { + kind: 'TypeLiteral' + members: Array + } + | PropertySignature + | { + kind: 'Intersection' + members: Array + } + | { + kind: 'Union' + members: Array + } + | { + kind: 'TypeReference' + typeName: QualifiedName | Identifier + typeArguments: Array + } + | FunctionDeclaration + | Function + | Parameter + | Identifier + | QualifiedName + | Tuple + | { + kind: 'BooleanLiteral' + value: string + } + | { + kind: 'StringLiteral' + value: string + } + | { + kind: 'NumericLiteral' + value: string + } + | { + kind: 'Boolean' + } + | { + kind: 'Number' + } + | { + kind: 'String' + } + | { + kind: 'Never' + } + | { + kind: 'Null' + } + | { + kind: 'Undefined' + } + | { + kind: 'Any' + } + | { + kind: 'Void' + } + | { + kind: 'Unknown' + } + | { + kind: 'Unsupported' + typeKind?: string + type: string + } + +type VariableDeclaration = { + kind: 'VariableDeclaration' + name: Identifier + type: Type +} + +type PropertySignature = { + kind: 'PropertySignature' + name: string + optional: boolean + type: Type +} + +type Identifier = { + kind: 'Identifier' + name: string +} + +type QualifiedName = { + kind: 'QualifiedName' + left: QualifiedName | Identifier + right: Identifier +} + +type TypeParameter = { + kind: 'TypeParameter' + name: string + constraint?: Type + default?: Type +} + +type CallExpression = { + kind: 'CallExpression' + expression: Identifier | PropertyAccessExpression + typeArguments: Array + arguments: Array +} + +type PropertyAccessExpression = { + kind: 'PropertyAccessExpression' + expression: Identifier | PropertyAccessExpression + name: Identifier +} + +type FunctionDeclaration = { + kind: 'FunctionDeclaration' + name: string + signatures: Array +} + +type Function = { + kind: 'Function' + parameters: Array + returnType: Type + typeParameters: Array +} + +type Signature = { + kind: 'Signature' + parameters: Array + returnType: Type + typeParameters: Array +} + +type Parameter = { + kind: 'Parameter' + name: Identifier | ObjectBindingPattern | ArrayBindingPattern + type: Type + optional: boolean + rest: boolean +} + +type ObjectBindingPattern = { + kind: 'ObjectBindingPattern' + elements: Array +} + +type BindingElement = { + kind: 'BindingElement' + propertyName?: Identifier + name: Identifier + restProperty: boolean +} + +type ArrayBindingPattern = { + kind: 'ArrayBindingPattern' + elements: Array +} + +type OmittedExpression = { + kind: 'OmittedExpression' +} + +type Tuple = { + kind: 'Tuple' + elements: Array +} + +function getTypeInfo(typeChecker: ts.TypeChecker, node: ts.Node): Type { + if (ts.isVariableDeclaration(node)) { + const type = node.type + ? getTypeInfo(typeChecker, node.type) + : node.initializer + ? getTypeInfo(typeChecker, node.initializer) + : ({kind: 'Any'} as const) + + return { + kind: 'VariableDeclaration', + name: { + kind: 'Identifier', + name: node.name.getText(), + }, + type, + } + } + + if (ts.isFunctionDeclaration(node)) { + if (!node.name) { + return unsupported(typeChecker, node) + } + + const symbol = typeChecker.getSymbolAtLocation(node.name) + if (!symbol) { + return unsupported(typeChecker, node) + } + + const type = typeChecker.getTypeOfSymbolAtLocation(symbol, node) + const signatures: Array = [] + + for (const signature of type.getCallSignatures()) { + const returnType = getTypeInfoFromType(typeChecker, signature.getReturnType()) + const parameters: Array = [] + const typeParameters: Array = [] + + for (const parameter of signature.getParameters()) { + const valueDeclaration = parameter.valueDeclaration + if (!valueDeclaration) { + continue + } + + const parameterDeclaration = valueDeclaration as ts.ParameterDeclaration + const parameterType = getTypeInfo(typeChecker, parameterDeclaration) + + if (parameterType.kind === 'Parameter') { + parameters.push(parameterType) + } + } + + if (signature.typeParameters) { + for (const typeParameter of signature.typeParameters) { + const typeParameterType = getTypeInfoFromType(typeChecker, typeParameter) + if (typeParameterType.kind === 'TypeParameter') { + typeParameters.push(typeParameterType) + } + } + } + + signatures.push({ + kind: 'Signature', + parameters, + returnType, + typeParameters, + }) + } + + return { + kind: 'FunctionDeclaration', + name: node.name.getText(), + signatures, + } + } + + if (ts.isFunctionTypeNode(node)) { + const parameters: Array = [] + + for (const parameter of node.parameters) { + const parameterType = getTypeInfo(typeChecker, parameter) + if (parameterType.kind === 'Parameter') { + parameters.push(parameterType) + } + } + + const typeParameters: Array = [] + + if (node.typeParameters) { + for (const typeParameter of node.typeParameters) { + const typeParameterType = getTypeInfo(typeChecker, typeParameter) + if (typeParameterType.kind === 'TypeParameter') { + typeParameters.push(typeParameterType) + } + } + } + + return { + kind: 'Function', + parameters, + returnType: getTypeInfo(typeChecker, node.type), + typeParameters, + } + } + + if (ts.isFunctionExpression(node)) { + if (!node.name) { + return unsupported(typeChecker, node) + } + + const symbol = typeChecker.getSymbolAtLocation(node.name) + if (!symbol) { + return unsupported(typeChecker, node) + } + + const type = typeChecker.getTypeOfSymbolAtLocation(symbol, node) + const signatures: Array = [] + + for (const signature of type.getCallSignatures()) { + const returnType = getTypeInfoFromType(typeChecker, signature.getReturnType()) + const parameters: Array = [] + const typeParameters: Array = [] + + for (const parameter of signature.getParameters()) { + const valueDeclaration = parameter.valueDeclaration + if (!valueDeclaration) { + continue + } + + const parameterDeclaration = valueDeclaration as ts.ParameterDeclaration + const parameterType = getTypeInfo(typeChecker, parameterDeclaration) + + if (parameterType.kind === 'Parameter') { + parameters.push(parameterType) + } + } + + if (signature.typeParameters) { + for (const typeParameter of signature.typeParameters) { + const typeParameterType = getTypeInfoFromType(typeChecker, typeParameter) + if (typeParameterType.kind === 'TypeParameter') { + typeParameters.push(typeParameterType) + } + } + } + + signatures.push({ + kind: 'Signature', + parameters, + returnType, + typeParameters, + }) + } + + return { + kind: 'FunctionDeclaration', + name: node.name.getText(), + signatures, + } + } + + if (ts.isArrowFunction(node)) { + const symbol = typeChecker.getSymbolAtLocation(node) + if (!symbol) { + return unsupported(typeChecker, node) + } + + const type = typeChecker.getTypeOfSymbolAtLocation(symbol, node) + const signatures: Array = [] + + for (const signature of type.getCallSignatures()) { + const returnType = getTypeInfoFromType(typeChecker, signature.getReturnType()) + const parameters: Array = [] + const typeParameters: Array = [] + + for (const parameter of signature.getParameters()) { + const valueDeclaration = parameter.valueDeclaration + if (!valueDeclaration) { + continue + } + + const parameterDeclaration = valueDeclaration as ts.ParameterDeclaration + const parameterType = getTypeInfo(typeChecker, parameterDeclaration) + + if (parameterType.kind === 'Parameter') { + parameters.push(parameterType) + } + } + + if (signature.typeParameters) { + for (const typeParameter of signature.typeParameters) { + const typeParameterType = getTypeInfoFromType(typeChecker, typeParameter) + if (typeParameterType.kind === 'TypeParameter') { + typeParameters.push(typeParameterType) + } + } + } + + signatures.push({ + kind: 'Signature', + parameters, + returnType, + typeParameters, + }) + } + + return { + kind: 'FunctionDeclaration', + name: node.name.getText(), + signatures, + } + } + + if (ts.isInterfaceDeclaration(node)) { + // ... + } + + if (ts.isTypeAliasDeclaration(node)) { + const typeParameters: Array = [] + + if (node.typeParameters) { + for (const typeParameter of node.typeParameters) { + const type = getTypeInfo(typeChecker, typeParameter) + if (type.kind === 'TypeParameter') { + typeParameters.push(type) + } + } + } + + return { + kind: 'TypeAlias', + name: node.name.getText(), + typeParameters, + type: getTypeInfo(typeChecker, node.type), + } + } + + if (ts.isTypeParameterDeclaration(node)) { + return { + kind: 'TypeParameter', + name: node.name.getText(), + constraint: node.constraint ? getTypeInfo(typeChecker, node.constraint) : undefined, + default: node.default ? getTypeInfo(typeChecker, node.default) : undefined, + } + } + + if (ts.isIntersectionTypeNode(node)) { + return { + kind: 'Intersection', + members: node.types.map(type => getTypeInfo(typeChecker, type)), + } + } + + if (ts.isUnionTypeNode(node)) { + return { + kind: 'Union', + members: node.types.map(type => getTypeInfo(typeChecker, type)), + } + } + + if (ts.isTypeLiteralNode(node)) { + const typeNode = node as ts.TypeLiteralNode + const members: Array = [] + + ts.forEachChild(typeNode, child => { + if (ts.isPropertySignature(child)) { + const type = getTypeInfo(typeChecker, child) + if (type.kind === 'PropertySignature') { + members.push(type) + } + } + }) + + return { + kind: 'TypeLiteral', + members, + } + } + + if (ts.isPropertySignature(node)) { + const propertySignature = node as ts.PropertySignature + if (!propertySignature.type) { + return { + kind: 'Unsupported', + type: typeChecker.typeToString( + typeChecker.getTypeAtLocation(propertySignature), + undefined, + ts.TypeFormatFlags.NoTypeReduction | ts.TypeFormatFlags.WriteArrayAsGenericType, + ), + } + } + + return { + kind: 'PropertySignature', + name: propertySignature.name.getText(), + optional: !!propertySignature.questionToken, + type: getTypeInfo(typeChecker, propertySignature.type), + } + } + + if (ts.isCallExpression(node)) { + const expression = getTypeInfo(typeChecker, node.expression) + if (expression.kind === 'Identifier' || expression.kind === 'PropertyAccessExpression') { + const args: Array = [] + + for (const arg of node.arguments) { + const type = getTypeInfo(typeChecker, arg) + args.push(type) + } + + const typeArguments: Array = [] + + if (node.typeArguments) { + for (const typeArgument of node.typeArguments) { + const type = getTypeInfo(typeChecker, typeArgument) + if (type.kind === 'TypeReference') { + typeArguments.push(type) + } + } + } + + return { + kind: 'CallExpression', + expression, + typeArguments, + arguments: args, + } + } + } + + if (ts.isPropertyAccessExpression(node)) { + const expression = getTypeInfo(typeChecker, node.expression) + if (expression.kind === 'Identifier' || expression.kind === 'PropertyAccessExpression') { + return { + kind: 'PropertyAccessExpression', + expression, + name: { + kind: 'Identifier', + name: node.name.getText(), + }, + } + } + } + + if (ts.isParameter(node)) { + if (node.name.kind === ts.SyntaxKind.ObjectBindingPattern) { + return { + kind: 'Parameter', + name: { + kind: 'ObjectBindingPattern', + elements: node.name.elements.map(element => { + if (element.propertyName) { + return { + kind: 'BindingElement', + propertyName: { + kind: 'Identifier', + name: element.propertyName.getText(), + }, + name: { + kind: 'Identifier', + name: element.name.getText(), + }, + restProperty: !!element.dotDotDotToken, + } + } + + return { + kind: 'BindingElement', + name: { + kind: 'Identifier', + name: element.name.getText(), + }, + restProperty: !!element.dotDotDotToken, + } + }), + }, + type: node.type ? getTypeInfo(typeChecker, node.type) : {kind: 'Any'}, + optional: !!node.questionToken, + rest: !!node.dotDotDotToken, + } + } + + if (node.name.kind === ts.SyntaxKind.ArrayBindingPattern) { + return { + kind: 'Parameter', + name: { + kind: 'ArrayBindingPattern', + elements: node.name.elements.map(element => { + if (ts.isOmittedExpression(element)) { + return { + kind: 'OmittedExpression', + } + } + return { + kind: 'BindingElement', + name: { + kind: 'Identifier', + name: element.name.getText(), + }, + restProperty: !!element.dotDotDotToken, + } + }), + }, + type: node.type ? getTypeInfo(typeChecker, node.type) : {kind: 'Any'}, + optional: !!node.questionToken, + rest: !!node.dotDotDotToken, + } + } + + return { + kind: 'Parameter', + name: { + kind: 'Identifier', + name: node.name.getText(), + }, + type: node.type ? getTypeInfo(typeChecker, node.type) : {kind: 'Any'}, + optional: !!node.questionToken, + rest: !!node.dotDotDotToken, + } + } + + if (ts.isTypeReferenceNode(node)) { + const typeName = getTypeInfo(typeChecker, node.typeName) + if (typeName.kind === 'Identifier' || typeName.kind === 'QualifiedName') { + return { + kind: 'TypeReference', + typeName, + typeArguments: node.typeArguments ? node.typeArguments.map(type => getTypeInfo(typeChecker, type)) : [], + } + } + } + + if (ts.isTupleTypeNode(node)) { + return { + kind: 'Tuple', + elements: node.elements.map(element => { + return getTypeInfo(typeChecker, element) + }), + } + } + + if (ts.isIdentifier(node)) { + return { + kind: 'Identifier', + name: node.text, + } + } + + if (ts.isQualifiedName(node)) { + const left = getTypeInfo(typeChecker, node.left) + const right = getTypeInfo(typeChecker, node.right) + + if (left.kind === 'Identifier' || left.kind === 'QualifiedName') { + if (right.kind === 'Identifier') { + return { + kind: 'QualifiedName', + left, + right, + } + } + } + } + + if (ts.isLiteralTypeNode(node)) { + return getTypeInfo(typeChecker, node.literal) + } + + if (ts.isStringLiteral(node)) { + return { + kind: 'StringLiteral', + value: node.text, + } + } + + if (ts.isNumericLiteral(node)) { + return { + kind: 'NumericLiteral', + value: node.text, + } + } + + if (node.kind === ts.SyntaxKind.TrueKeyword) { + return { + kind: 'BooleanLiteral', + value: 'true', + } + } + + if (node.kind === ts.SyntaxKind.FalseKeyword) { + return { + kind: 'BooleanLiteral', + value: 'false', + } + } + + if (node.kind === ts.SyntaxKind.NullKeyword) { + return { + kind: 'Null', + } + } + + if (node.kind === ts.SyntaxKind.UndefinedKeyword) { + return { + kind: 'Undefined', + } + } + + if (node.kind === ts.SyntaxKind.AnyKeyword) { + return { + kind: 'Any', + } + } + + if (node.kind === ts.SyntaxKind.UnknownKeyword) { + return { + kind: 'Unknown', + } + } + + if (node.kind === ts.SyntaxKind.NeverKeyword) { + return { + kind: 'Never', + } + } + + if (node.kind === ts.SyntaxKind.StringKeyword) { + return { + kind: 'String', + } + } + + if (node.kind === ts.SyntaxKind.NumberKeyword) { + return { + kind: 'Number', + } + } + + if (node.kind === ts.SyntaxKind.BooleanKeyword) { + return { + kind: 'Boolean', + } + } + + if (node.kind === ts.SyntaxKind.VoidKeyword) { + return { + kind: 'Void', + } + } + + return { + kind: 'Unsupported', + typeKind: ts.SyntaxKind[node.kind], + type: typeChecker.typeToString( + typeChecker.getTypeAtLocation(node), + undefined, + ts.TypeFormatFlags.WriteArrayAsGenericType | + ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | + ts.TypeFormatFlags.NoTypeReduction, + ), + } +} + +function unsupported(typeChecker: ts.TypeChecker, node: ts.Node) { + return { + kind: 'Unsupported', + typeKind: ts.SyntaxKind[node.kind], + type: typeChecker.typeToString( + typeChecker.getTypeAtLocation(node), + undefined, + ts.TypeFormatFlags.WriteArrayAsGenericType | + ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | + ts.TypeFormatFlags.NoTypeReduction, + ), + } as const +} + +function getTypeInfoFromType(typeChecker: ts.TypeChecker, type: ts.Type): Type { + if (type.flags & ts.TypeFlags.BooleanLiteral) { + return { + kind: 'BooleanLiteral', + value: typeChecker.typeToString(type), + } + } + + if (type.flags & ts.TypeFlags.StringLiteral) { + return { + kind: 'StringLiteral', + // When using typeToString, the string literal is wrapped in quotes + value: typeChecker.typeToString(type).slice(1, -1), + } + } + + if (type.flags & ts.TypeFlags.NumberLiteral) { + return { + kind: 'NumericLiteral', + value: typeChecker.typeToString(type), + } + } + + if (type.flags & ts.TypeFlags.Boolean) { + return { + kind: 'Boolean', + } + } + + if (type.flags & ts.TypeFlags.Number) { + return { + kind: 'Number', + } + } + + if (type.flags & ts.TypeFlags.String) { + return { + kind: 'String', + } + } + + if (type.flags & ts.TypeFlags.Any) { + return { + kind: 'Any', + } + } + + if (type.flags & ts.TypeFlags.Never) { + return { + kind: 'Never', + } + } + + if (type.flags & ts.TypeFlags.Null) { + return { + kind: 'Null', + } + } + + if (type.flags & ts.TypeFlags.Undefined) { + return { + kind: 'Undefined', + } + } + + if (type.flags & ts.TypeFlags.Unknown) { + return { + kind: 'Unknown', + } + } + + if (type.flags & ts.TypeFlags.Void) { + return { + kind: 'Void', + } + } + + if (type.flags & ts.TypeFlags.Union) { + const unionType = type as ts.UnionType + return { + kind: 'Union', + members: unionType.types.map(type => getTypeInfoFromType(typeChecker, type)), + } + } + + if (type.flags & ts.TypeFlags.Intersection) { + const intersectionType = type as ts.IntersectionType + return { + kind: 'Intersection', + members: intersectionType.types.map(type => getTypeInfoFromType(typeChecker, type)), + } + } + + if (type.flags & ts.TypeFlags.TypeParameter) { + const constraint = type.getConstraint() + const defaultType = type.getDefault() + + return { + kind: 'TypeParameter', + name: typeChecker.typeToString(type), + constraint: constraint ? getTypeInfoFromType(typeChecker, constraint) : undefined, + default: defaultType ? getTypeInfoFromType(typeChecker, defaultType) : undefined, + } + } + + if (type.flags & ts.TypeFlags.Object) { + const objectType = type as ts.ObjectType + + if (objectType.objectFlags & ts.ObjectFlags.Reference) { + const typeReference = objectType as ts.TypeReference + const target = typeReference.target + + return { + kind: 'TypeReference', + typeName: getSymbolName(typeChecker, target.symbol), + typeArguments: Array.isArray(typeReference.typeArguments) + ? typeReference.typeArguments.map(type => { + return getTypeInfoFromType(typeChecker, type) + }) + : [], + } + } else if (objectType.objectFlags & ts.ObjectFlags.Anonymous) { + const members: Array = [] + + objectType.getProperties().forEach(property => { + const declaration = property.valueDeclaration + if (!declaration) { + return + } + + const type = getTypeInfoFromType(typeChecker, typeChecker.getTypeOfSymbolAtLocation(property, declaration)) + members.push({ + kind: 'PropertySignature', + name: property.name, + optional: !!(declaration as ts.PropertySignature).questionToken, + type, + }) + }) + + return { + kind: 'TypeLiteral', + members, + } + } + } + + return unsupportedType(typeChecker, type) +} + +function getSymbolName(typeChecker: ts.TypeChecker, symbol: ts.Symbol): QualifiedName | Identifier { + const name = typeChecker.getFullyQualifiedName(symbol) + const parts = name.split('.') + + return getQualifiedName(parts) +} + +function getQualifiedName(parts: Array): QualifiedName | Identifier { + if (parts.length === 1) { + return { + kind: 'Identifier', + name: parts[0], + } + } + + if (parts.length > 1) { + return { + kind: 'QualifiedName', + left: getQualifiedName(parts.slice(0, -1)), + right: { + kind: 'Identifier', + name: parts[parts.length - 1], + }, + } + } + + throw new Error('Invalid qualified name') +} + +function unsupportedType(typeChecker: ts.TypeChecker, type: ts.Type) { + return { + kind: 'Unsupported', + typeKind: ts.TypeFlags[type.flags], + type: typeChecker.typeToString( + type, + undefined, + ts.TypeFormatFlags.WriteArrayAsGenericType | + ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | + ts.TypeFormatFlags.NoTypeReduction, + ), + } as const +} + +function format(type: Type): string { + if (type.kind === 'ExportSpecifier') { + return `${type.name}: ${format(type.type)}` + } + + if (type.kind === 'TypeAlias') { + return `type ${type.name}${formatTypeParameters(type.typeParameters)} = ${format(type.type)}` + } + + if (type.kind === 'VariableDeclaration') { + return `const ${type.name.name}: ${format(type.type)}` + } + + if (type.kind === 'TypeReference') { + const typeArguments = type.typeArguments.length > 0 ? formatTypeParameters(type.typeArguments) : '' + return `${format(type.typeName)}${typeArguments}` + } + + if (type.kind === 'CallExpression') { + const typeArguments = type.typeArguments.length > 0 ? formatTypeParameters(type.typeArguments) : '' + const args = type.arguments.length > 0 ? `(${type.arguments.map(format).join(', ')})` : '' + return `${format(type.expression)}${typeArguments}${args}` + } + + if (type.kind === 'PropertyAccessExpression') { + return `${format(type.expression)}.${type.name.name}` + } + + if (type.kind === 'Identifier') { + return type.name + } + + if (type.kind === 'QualifiedName') { + return `${format(type.left)}.${type.right.name}` + } + + if (type.kind === 'TypeParameter') { + const constraint = type.constraint ? ` extends ${format(type.constraint)}` : '' + const defaultType = type.default ? ` = ${format(type.default)}` : '' + return `${type.name}${constraint}${defaultType}` + } + + if (type.kind === 'PropertySignature') { + return `${type.name}${type.optional ? '?' : ''}: ${format(type.type)}` + } + + if (type.kind === 'TypeLiteral') { + return `{ ${type.members.map(format).join(', ')} }` + } + + if (type.kind === 'Intersection') { + return type.members.map(format).join(' & ') + } + + if (type.kind === 'Union') { + return type.members.map(format).join(' | ') + } + + if (type.kind === 'Tuple') { + return `[${type.elements.map(format).join(', ')}]` + } + + if (type.kind === 'BooleanLiteral') { + return type.value + } + + if (type.kind === 'StringLiteral') { + return `"${type.value}"` + } + + if (type.kind === 'NumericLiteral') { + return type.value + } + + if (type.kind === 'Boolean') { + return 'boolean' + } + + if (type.kind === 'Number') { + return 'number' + } + + if (type.kind === 'String') { + return 'string' + } + + if (type.kind === 'Never') { + return 'never' + } + + if (type.kind === 'Null') { + return 'null' + } + + if (type.kind === 'Undefined') { + return 'undefined' + } + + if (type.kind === 'Any') { + return 'any' + } + + if (type.kind === 'Void') { + return 'void' + } + + if (type.kind === 'Unknown') { + return 'unknown' + } + + if (type.kind === 'Function') { + const parameters = type.parameters.map(parameter => { + return `${parameter.name.name}${parameter.optional ? '?' : ''}: ${format(parameter.type)}` + }) + const typeParameters = formatTypeParameters(type.typeParameters) + return `(${parameters.join(', ')}) => ${format(type.returnType)}` + } + + if (type.kind === 'Signature') { + const parameters = type.parameters.map(parameter => { + return `${parameter.name.name}${parameter.optional ? '?' : ''}: ${format(parameter.type)}` + }) + const typeParameters = formatTypeParameters(type.typeParameters) + return `(${parameters.join(', ')}) => ${format(type.returnType)}` + } + + if (type.kind === 'Parameter') { + if (type.name.kind === 'ObjectBindingPattern') { + const elements = type.name.elements.map(element => { + if (element.propertyName) { + return `${element.propertyName.name}: ${element.name.name}` + } + return `${element.name.name}` + }) + return `{ ${elements.join(', ')} }${type.optional ? '?' : ''}: ${format(type.type)}` + } + + if (type.name.kind === 'ArrayBindingPattern') { + const elements = type.name.elements.map(element => { + if (element.kind === 'OmittedExpression') { + return `...` + } + return `${element.name.name}` + }) + return `[${elements.join(', ')}]${type.optional ? '?' : ''}: ${format(type.type)}` + } + + return `${type.name.name}${type.optional ? '?' : ''}: ${format(type.type)}` + } + + if (type.kind === 'FunctionDeclaration') { + const signatures = type.signatures.map(signature => { + const parameters = signature.parameters.map(parameter => { + return `${parameter.name.name}${parameter.optional ? '?' : ''}: ${format(parameter.type)}` + }) + const typeParameters = formatTypeParameters(signature.typeParameters) + return `(${parameters.join(', ')}) => ${format(signature.returnType)}` + }) + return `function ${type.name}(${signatures.join(' | ')})` + } + + if (type.kind === 'Unsupported') { + return type.type + } + + exhaustiveCheck(type) +} + +function formatTypeParameters(typeParameters: Array): string { + if (typeParameters.length === 0) { + return '' + } + + const formattedTypeParameters = typeParameters.map(typeParameter => { + const constraint = typeParameter.constraint ? ` extends ${format(typeParameter.constraint)}` : '' + const defaultType = typeParameter.default ? ` = ${format(typeParameter.default)}` : '' + return `${typeParameter.name}${constraint}${defaultType}` + }) + + return `<${formattedTypeParameters.join(', ')}>` +} + +function exhaustiveCheck(value: never): never { + throw new Error(`Unexpected value: ${value}`) +} + +export {getMetadataFromSourceFile, format} +export type {Metadata, Type} diff --git a/packages/react-metadata/src/isNodeExported.ts b/packages/react-metadata/src/isNodeExported.ts new file mode 100644 index 00000000000..02fb0833c9e --- /dev/null +++ b/packages/react-metadata/src/isNodeExported.ts @@ -0,0 +1,40 @@ +import ts from 'typescript' + +function isNodeExported(node: ts.Node): boolean { + // export default + if (ts.isExportAssignment(node)) { + return true + } + + // export { a } + // export { a as b } + // export { a } from 'mod' + // expor type { a } + if (ts.isExportDeclaration(node)) { + return true + } + + // export const a = 1 + // export function a() {} + // export class A {} + // export interface A {} + // export type A = {} + // export enum A {} + if ( + ts.isVariableStatement(node) || + ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isEnumDeclaration(node) + ) { + const exportKeyword = node.modifiers?.find(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) + if (exportKeyword) { + return true + } + } + + return false +} + +export {isNodeExported} diff --git a/packages/react-metadata/tsconfig.json b/packages/react-metadata/tsconfig.json new file mode 100644 index 00000000000..564a5990051 --- /dev/null +++ b/packages/react-metadata/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/react-metadata/vitest.config.ts b/packages/react-metadata/vitest.config.ts new file mode 100644 index 00000000000..e2d1588e1f2 --- /dev/null +++ b/packages/react-metadata/vitest.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vitest/config' + +export default defineConfig({ + test: { + include: ['**/*.test.ts'], + }, +}) diff --git a/packages/react/script/generate-metadata.mts b/packages/react/script/generate-metadata.mts new file mode 100644 index 00000000000..e37440e589d --- /dev/null +++ b/packages/react/script/generate-metadata.mts @@ -0,0 +1,25 @@ +import path from 'node:path' +import {getMetadataFromSourceFile, format} from '@primer/react-metadata' +import ts from 'typescript' + +async function main() { + const config = ts.readConfigFile('tsconfig.json', ts.sys.readFile) + const parsedConfig = ts.parseJsonConfigFileContent(config.config, ts.sys, process.cwd(), undefined, 'tsconfig.json') + const program = ts.createProgram({ + rootNames: parsedConfig.fileNames, + options: parsedConfig.options, + }) + const typeChecker = program.getTypeChecker() + // const sourceFile = program.getSourceFile(path.resolve('./src/Banner/Banner.tsx')) + const sourceFile = program.getSourceFile(path.resolve('./src/index.ts')) + const metadata = getMetadataFromSourceFile(typeChecker, sourceFile) + + for (const exportInfo of metadata.exports) { + console.log(format(exportInfo)) + } +} + +main().catch(error => { + console.log(error) + process.exit(1) +})