diff --git a/.gitignore b/.gitignore
index 6ca93eb..bec6a4c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
build
lib
-node_modules
+/node_modules
coverage
diff --git a/fixtures/components/complex-types/column-layout/index.tsx b/fixtures/components/complex-types/column-layout/index.tsx
new file mode 100644
index 0000000..bca9b55
--- /dev/null
+++ b/fixtures/components/complex-types/column-layout/index.tsx
@@ -0,0 +1,17 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import * as React from 'react';
+
+export interface ColumnLayoutProps {
+ columns: ColumnLayoutProps.Columns;
+ widths: ColumnLayoutProps.Widths;
+}
+
+export namespace ColumnLayoutProps {
+ export type Columns = 1 | 2 | 3 | 4;
+ export type Widths = 25 | '50%' | 100 | '33%';
+}
+
+export default function ColumnLayout(props: ColumnLayoutProps) {
+ return
;
+}
diff --git a/fixtures/components/complex-types/table/index.tsx b/fixtures/components/complex-types/table/index.tsx
index 4e8ee85..f5ecff6 100644
--- a/fixtures/components/complex-types/table/index.tsx
+++ b/fixtures/components/complex-types/table/index.tsx
@@ -3,6 +3,8 @@
import * as React from 'react';
import { TableProps } from './interfaces';
+export { TableProps };
+
export default function Table(props: TableProps) {
return ;
}
diff --git a/fixtures/components/error-incorrect-component-name/button/index.tsx b/fixtures/components/error-missing-props/button/index.tsx
similarity index 55%
rename from fixtures/components/error-incorrect-component-name/button/index.tsx
rename to fixtures/components/error-missing-props/button/index.tsx
index a5bf252..f76a619 100644
--- a/fixtures/components/error-incorrect-component-name/button/index.tsx
+++ b/fixtures/components/error-missing-props/button/index.tsx
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
-export default function Input() {
- return Surprise, I am not Button!;
+// should fail because ButtonProps export is missing
+export default function Button() {
+ return Test;
}
diff --git a/fixtures/components/error-incorrect-component-name/tsconfig.json b/fixtures/components/error-missing-props/tsconfig.json
similarity index 100%
rename from fixtures/components/error-incorrect-component-name/tsconfig.json
rename to fixtures/components/error-missing-props/tsconfig.json
diff --git a/fixtures/components/error-not-a-component/button/index.tsx b/fixtures/components/error-not-a-component/button/index.tsx
new file mode 100644
index 0000000..9ae54f0
--- /dev/null
+++ b/fixtures/components/error-not-a-component/button/index.tsx
@@ -0,0 +1,7 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export default function Button() {
+ // if a function does not return JSX, we throw
+ return { type: 'button' };
+}
diff --git a/fixtures/components/release-status/tsconfig.json b/fixtures/components/error-not-a-component/tsconfig.json
similarity index 100%
rename from fixtures/components/release-status/tsconfig.json
rename to fixtures/components/error-not-a-component/tsconfig.json
diff --git a/fixtures/components/error-ref-property-type/button/index.tsx b/fixtures/components/error-ref-property-type/button/index.tsx
index 07b5558..8b3a8af 100644
--- a/fixtures/components/error-ref-property-type/button/index.tsx
+++ b/fixtures/components/error-ref-property-type/button/index.tsx
@@ -3,12 +3,12 @@
import * as React from 'react';
export namespace ButtonProps {
- interface Ref {
+ export interface Ref {
value: string;
}
}
-const Button = React.forwardRef((props, ref) => {
+const Button = React.forwardRef((props, ref) => {
return ;
});
diff --git a/fixtures/components/forward-ref/focusable/index.tsx b/fixtures/components/forward-ref/focusable/index.tsx
index 6eb7526..31e7697 100644
--- a/fixtures/components/forward-ref/focusable/index.tsx
+++ b/fixtures/components/forward-ref/focusable/index.tsx
@@ -9,7 +9,7 @@ export interface FocusableProps {
children: React.ReactNode;
}
-export namespace SomethingElse {
+namespace SomethingElse {
export interface Ref {
/**
* Should be ignored
@@ -29,6 +29,11 @@ export namespace FocusableProps {
* Focuses element using the CSS-selector
*/
focusBySelector(selector: string): void;
+
+ /**
+ * Showcase for optional functions
+ */
+ cancelEdit?(): void;
}
}
diff --git a/fixtures/components/import-types/dependency/index.tsx b/fixtures/components/import-types/dependency/index.tsx
index 50a3a06..9395eb6 100644
--- a/fixtures/components/import-types/dependency/index.tsx
+++ b/fixtures/components/import-types/dependency/index.tsx
@@ -1,20 +1,9 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
+import { DependencyProps } from './interfaces';
-export interface DependencyProps {
- name?: string;
- variant?: DependencyProps.Variant;
-}
-
-// should not be included in the Main component API definition
-export interface MainProps {
- randomValue: string;
-}
-
-export namespace DependencyProps {
- export type Variant = 'button' | 'link';
-}
+export { DependencyProps };
export default function Dependency({ name, variant = 'button' }: DependencyProps) {
return {name}
;
diff --git a/fixtures/components/import-types/dependency/interfaces.ts b/fixtures/components/import-types/dependency/interfaces.ts
new file mode 100644
index 0000000..44ac533
--- /dev/null
+++ b/fixtures/components/import-types/dependency/interfaces.ts
@@ -0,0 +1,15 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+export interface DependencyProps {
+ name?: string;
+ variant?: DependencyProps.Variant;
+}
+
+// should not be included in the Main component API definition
+export interface MainProps {
+ randomValue: string;
+}
+
+export namespace DependencyProps {
+ export type Variant = 'button' | 'link';
+}
diff --git a/fixtures/components/release-status/beta/index.tsx b/fixtures/components/release-status/beta/index.tsx
deleted file mode 100644
index aa20ffc..0000000
--- a/fixtures/components/release-status/beta/index.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-import * as React from 'react';
-
-/**
- * Component-level description
- * @version 1.0-beta
- * @beta This component is in beta
- */
-const Beta: React.FC = () => {
- return Beta component
;
-};
-
-export default Beta;
diff --git a/fixtures/components/third-party-import-types/button/interfaces.ts b/fixtures/components/third-party-import-types/button/interfaces.ts
index bcdcd9f..7173ed8 100644
--- a/fixtures/components/third-party-import-types/button/interfaces.ts
+++ b/fixtures/components/third-party-import-types/button/interfaces.ts
@@ -1,6 +1,6 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
-import { IconProps } from '../node_modules_mock/icon';
+import { IconProps } from 'icon';
export interface ButtonProps {
/**
diff --git a/fixtures/components/third-party-import-types/node_modules_mock/icon/index.d.ts b/fixtures/components/third-party-import-types/node_modules/icon/index.d.ts
similarity index 100%
rename from fixtures/components/third-party-import-types/node_modules_mock/icon/index.d.ts
rename to fixtures/components/third-party-import-types/node_modules/icon/index.d.ts
diff --git a/fixtures/components/third-party-import-types/node_modules_mock/icon/interfaces.d.ts b/fixtures/components/third-party-import-types/node_modules/icon/interfaces.d.ts
similarity index 100%
rename from fixtures/components/third-party-import-types/node_modules_mock/icon/interfaces.d.ts
rename to fixtures/components/third-party-import-types/node_modules/icon/interfaces.d.ts
diff --git a/fixtures/components/with-internals/with-internals/index.tsx b/fixtures/components/with-internals/with-internals/index.tsx
index 001a656..211a8bc 100644
--- a/fixtures/components/with-internals/with-internals/index.tsx
+++ b/fixtures/components/with-internals/with-internals/index.tsx
@@ -10,6 +10,11 @@ function InternalSameFile() {
return ;
}
-export default function WithInternals() {
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface WithInternalsProps {
+ // nothing here
+}
+
+export default function WithInternals(props: WithInternalsProps) {
return ;
}
diff --git a/src/bootstrap/typescript.ts b/src/bootstrap/typescript.ts
new file mode 100644
index 0000000..c95bbbd
--- /dev/null
+++ b/src/bootstrap/typescript.ts
@@ -0,0 +1,44 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import ts from 'typescript';
+import pathe from 'pathe';
+
+function printDiagnostics(diagnostics: readonly ts.Diagnostic[]): void {
+ for (const diagnostic of diagnostics) {
+ const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
+ if (diagnostic.file) {
+ const { line, character } = ts.getLineAndCharacterOfPosition(diagnostic.file, diagnostic.start!);
+ console.error(`${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`);
+ } else {
+ console.error(message);
+ }
+ }
+}
+
+function loadTSConfig(tsconfigPath: string): ts.ParsedCommandLine {
+ const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
+ if (configFile.error) {
+ throw new Error('Failed to read tsconfig.json');
+ }
+ const config = ts.parseJsonConfigFileContent(configFile.config, ts.sys, pathe.dirname(tsconfigPath));
+ if (config.errors.length > 0) {
+ throw new Error('Failed to parse tsconfig.json');
+ }
+ // this prints a warning that incremental mode is not supported in programmatic API
+ config.options.incremental = false;
+ delete config.options.tsBuildInfoFile;
+ return config;
+}
+
+export function bootstrapTypescriptProject(tsconfigPath: string) {
+ const tsconfig = loadTSConfig(tsconfigPath);
+ const program = ts.createProgram(tsconfig.fileNames, tsconfig.options);
+
+ const diagnostics = ts.getPreEmitDiagnostics(program);
+ if (diagnostics.length > 0) {
+ printDiagnostics(diagnostics);
+ throw new Error('Compilation failed');
+ }
+
+ return program;
+}
diff --git a/src/components/build-definition.ts b/src/components/build-definition.ts
deleted file mode 100644
index 33bcfbd..0000000
--- a/src/components/build-definition.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-import { DeclarationReflection, ReflectionKind } from 'typedoc';
-import { Type } from 'typedoc/dist/lib/models';
-import { ComponentDefinition, ComponentFunction, ComponentProperty } from './interfaces';
-import schema from '../schema';
-import buildTypeDefinition from './build-type-definition';
-import extractDefaultValues from './default-values-extractor';
-
-function buildEventInfo(handler: DeclarationReflection) {
- if (!schema.types.isReferenceType(handler.type)) {
- throw new Error(
- `Unknown event handler type: ${handler.type && handler.type.type} at ${schema.utils.getDeclarationSourceFilename(
- handler
- )}`
- );
- }
- const detailType = handler.type.typeArguments?.[0];
- const { typeName, typeDefinition } = detailType
- ? getPropertyType(detailType)
- : { typeName: undefined, typeDefinition: undefined };
- return {
- name: handler.name,
- description: schema.code.buildNodeDescription(handler),
- cancelable: handler.type.name !== 'NonCancelableEventHandler',
- detailType: typeName,
- detailInlineType: typeDefinition,
- deprecatedTag: handler.comment?.tags?.find(tag => tag.tagName === 'deprecated')?.text.trim(),
- };
-}
-
-function buildMethodsDefinition(refType?: DeclarationReflection): ComponentFunction[] {
- if (!refType || !refType.children) {
- return [];
- }
- return refType.children.map(child => {
- if (!child.signatures) {
- throw new Error(
- `${schema.code.buildFullName(child)} should contain only methods, "${
- child.name
- }" has a "${schema.code.buildType(child.type)}" type`
- );
- }
- if (child.signatures.length > 1) {
- throw new Error(
- `Method overloads are not supported, found multiple signatures at ${schema.utils.getDeclarationSourceFilename(
- child
- )}`
- );
- }
- const signature = child.signatures[0];
- return {
- name: child.name,
- description: schema.code.buildNodeDescription(signature),
- returnType: schema.code.buildType(signature.type),
- parameters:
- signature.parameters?.map(parameter => ({
- name: parameter.name,
- type: schema.code.buildType(parameter.type),
- })) ?? [],
- };
- });
-}
-
-function getPropertyType(type?: Type) {
- const typeAlias = schema.types.isReferenceType(type) && (type.reflection as DeclarationReflection | undefined);
- const resolvedType = typeAlias ? typeAlias.type : type;
-
- if (schema.types.isUnionType(resolvedType)) {
- const subTypes = schema.utils.excludeUndefinedTypeFromUnion(resolvedType);
- if (subTypes.length > 1) {
- if (subTypes.every(type => schema.types.isStringLiteralType(type))) {
- const referenceTypeName = schema.types.isReferenceType(type) ? schema.code.buildType(type) : '';
- const declaration = new DeclarationReflection(referenceTypeName, ReflectionKind.TypeLiteral);
- declaration.type = resolvedType;
- return {
- typeName: 'string',
- typeDefinition: buildTypeDefinition(declaration),
- };
- }
- if (
- subTypes.every(type => schema.types.isIntrinsicType(type) && (type.name === 'true' || type.name === 'false'))
- ) {
- return { typeName: 'boolean' };
- }
- }
- }
- // Treat string literal type as a union with a single element.
- if (schema.types.isStringLiteralType(resolvedType)) {
- const referenceTypeName = schema.types.isReferenceType(type) ? schema.code.buildType(type) : '';
- const declaration = new DeclarationReflection(referenceTypeName, ReflectionKind.TypeLiteral);
- declaration.type = resolvedType;
- return {
- typeName: 'string',
- typeDefinition: buildTypeDefinition(declaration),
- };
- }
-
- return {
- typeName: schema.code.buildType(type),
- typeDefinition: typeAlias ? buildTypeDefinition(typeAlias) : undefined,
- };
-}
-
-export default function buildDefinition(
- component: DeclarationReflection,
- props: DeclarationReflection[],
- objects: DeclarationReflection[]
-): ComponentDefinition {
- const events = props.filter(prop => prop.name.match(/^on[A-Z]/));
- const regions = props.filter(prop => ['React.ReactNode', 'ReactNode'].includes(schema.code.buildType(prop.type)));
- const onlyProps = props.filter(prop => !events.includes(prop) && !regions.includes(prop));
- const defaultValues = extractDefaultValues(component);
-
- const betaTag = component.signatures && component.signatures[0].comment?.tags?.find(tag => tag.tagName === 'beta');
- const versionTag =
- component.signatures && component.signatures[0].comment?.tags?.find(tag => tag.tagName === 'version');
-
- return {
- name: component.name,
- version: versionTag?.text.trim().replace('\n', ''),
- releaseStatus: betaTag ? 'beta' : 'stable',
- description: schema.code.buildDeclarationDescription(component),
- regions: regions.map(region => {
- return {
- name: region.name,
- displayName: region.comment?.tags?.find(tag => tag.tagName === 'displayname')?.text.trim(),
- description: schema.code.buildNodeDescription(region),
- isDefault: region.name === 'children',
- visualRefreshTag: region.comment?.tags?.find(tag => tag.tagName === 'visualrefresh')?.text.trim(),
- deprecatedTag: region.comment?.tags?.find(tag => tag.tagName === 'deprecated')?.text.trim(),
- i18nTag: region.comment?.tags?.some(tag => tag.tagName === 'i18n') || undefined,
- };
- }),
- functions: buildMethodsDefinition(objects.find(def => def.name === 'Ref')),
- properties: onlyProps.map(prop => {
- const { typeName, typeDefinition } = getPropertyType(prop.type);
- return {
- name: prop.name,
- type: typeName,
- inlineType: typeDefinition,
- optional: schema.utils.isOptionalDeclaration(prop),
- description: schema.code.buildNodeDescription(prop),
- defaultValue: defaultValues[prop.name],
- visualRefreshTag: prop.comment?.tags?.find(tag => tag.tagName === 'visualrefresh')?.text.trim(),
- deprecatedTag: prop.comment?.tags?.find(tag => tag.tagName === 'deprecated')?.text.trim(),
- i18nTag: prop.comment?.tags?.some(tag => tag.tagName === 'i18n') || undefined,
- analyticsTag: prop.comment?.tags?.find(tag => tag.tagName === 'analytics')?.text.trim(),
- };
- }),
- events: events.map(handler => buildEventInfo(handler)),
- };
-}
diff --git a/src/components/build-type-definition.ts b/src/components/build-type-definition.ts
deleted file mode 100644
index afd52c9..0000000
--- a/src/components/build-type-definition.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-import { DeclarationReflection, Reflection, SignatureReflection } from 'typedoc';
-import { FunctionDefinition, ObjectDefinition, UnionTypeDefinition } from './interfaces';
-import schema from '../schema';
-import { StringLiteralType, UnionType } from 'typedoc/dist/lib/models';
-
-function buildObjectDefinition(obj: DeclarationReflection): ObjectDefinition {
- return {
- name: schema.code.buildFullName(obj),
- type: 'object',
- properties:
- obj.children?.map(prop => ({
- name: prop.name,
- type: prop.signatures ? schema.code.buildCallSignature(prop.signatures[0]) : schema.code.buildType(prop.type),
- optional: schema.utils.isOptionalDeclaration(prop),
- })) ?? [],
- };
-}
-
-function buildFunctionDefinition(obj: Reflection, signature: SignatureReflection): FunctionDefinition {
- return {
- name: schema.code.buildFullName(obj),
- type: 'function',
- returnType: schema.code.buildType(signature.type),
- parameters:
- signature.parameters?.map(parameter => ({
- name: parameter.name,
- type: schema.code.buildType(parameter.type),
- })) ?? [],
- };
-}
-
-function buildUnionTypeDefinition(obj: DeclarationReflection, type: UnionType): UnionTypeDefinition {
- return {
- name: schema.code.buildFullName(obj),
- type: 'union',
- values: type.types.map(type => {
- const result = schema.code.buildType(type);
- try {
- return JSON.parse(result);
- } catch (e) {
- // ignore json parse errors
- }
- return result;
- }),
- };
-}
-
-// Treat string literal type as a union with a single element.
-function buildStringLiteralTypeDefinition(obj: DeclarationReflection, type: StringLiteralType): UnionTypeDefinition {
- return {
- name: schema.code.buildFullName(obj),
- type: 'union',
- values: [
- (() => {
- const result = schema.code.buildType(type);
- try {
- return JSON.parse(result);
- } catch (e) {
- // ignore json parse errors
- }
- /* istanbul ignore next */
- return result;
- })(),
- ],
- };
-}
-
-export default function buildTypeDefinition(
- obj: DeclarationReflection
-): ObjectDefinition | FunctionDefinition | UnionTypeDefinition {
- // Use the original public name (e.g. ComponentProps.Something) for type aliases
- const fullName = schema.code.buildFullName(obj);
- if (schema.types.isReflectionType(obj.type)) {
- obj = obj.type.declaration;
- }
-
- let definition;
- if (schema.types.isUnionType(obj.type)) {
- definition = buildUnionTypeDefinition(obj, obj.type);
- }
-
- if (schema.types.isStringLiteralType(obj.type)) {
- definition = buildStringLiteralTypeDefinition(obj, obj.type);
- }
-
- if (obj.signatures) {
- definition = buildFunctionDefinition(obj.parent!, obj.signatures[0]);
- }
-
- if (schema.types.isReferenceType(obj.type) && obj.type.reflection) {
- definition = buildObjectDefinition(obj.type.reflection as DeclarationReflection);
- }
-
- if (!definition) {
- definition = buildObjectDefinition(obj);
- }
-
- definition.name = fullName;
- return definition;
-}
diff --git a/src/components/component-definition.ts b/src/components/component-definition.ts
new file mode 100644
index 0000000..9ea8e3a
--- /dev/null
+++ b/src/components/component-definition.ts
@@ -0,0 +1,114 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import ts from 'typescript';
+
+import { extractDeclaration, stringifyType } from './type-utils';
+import type {
+ ComponentDefinition,
+ ComponentFunction,
+ ComponentProperty,
+ ComponentRegion,
+ EventHandler,
+} from './interfaces';
+import type { ExpandedProp } from './extractor';
+import { getObjectDefinition } from './object-definition';
+
+function getCommentTag(property: ExpandedProp, name: string) {
+ const tag = property.description.tags.find(tag => tag.name === name);
+ return tag ? tag.text ?? '' : undefined;
+}
+
+function castI18nTag(tag: string | undefined) {
+ return tag === undefined ? undefined : true;
+}
+
+export function buildComponentDefinition(
+ name: string,
+ props: Array,
+ functions: Array,
+ defaultValues: Record,
+ checker: ts.TypeChecker
+): ComponentDefinition {
+ const regions = props.filter(prop => prop.type === 'React.ReactNode');
+ const events = props.filter(prop => prop.name.match(/^on[A-Z]/));
+ const onlyProps = props.filter(prop => !events.includes(prop) && !regions.includes(prop));
+
+ return {
+ name,
+ releaseStatus: 'stable',
+ regions: regions.map(
+ (region): ComponentRegion => ({
+ name: region.name,
+ displayName: getCommentTag(region, 'displayname'),
+ description: region.description.text,
+ isDefault: region.name === 'children',
+ visualRefreshTag: getCommentTag(region, 'visualrefresh'),
+ deprecatedTag: getCommentTag(region, 'deprecated'),
+ i18nTag: castI18nTag(getCommentTag(region, 'i18n')),
+ })
+ ),
+ functions: functions.map(
+ (func): ComponentFunction => ({
+ name: func.name,
+ description: func.description.text,
+ returnType: stringifyType(func.rawType.getNonNullableType().getCallSignatures()[0].getReturnType(), checker),
+ parameters: func.rawType
+ .getNonNullableType()
+ .getCallSignatures()[0]
+ .getParameters()
+ .map((param): ComponentFunction['parameters'][0] => {
+ const paramType = checker.getTypeAtLocation(extractDeclaration(param));
+ return {
+ name: param.name,
+ type: stringifyType(paramType, checker),
+ };
+ }),
+ })
+ ),
+ properties: onlyProps.map((property): ComponentProperty => {
+ const { type, inlineType } = getObjectDefinition(property.type, property.rawType, checker);
+ return {
+ name: property.name,
+ type: type,
+ inlineType: inlineType,
+ optional: property.isOptional,
+ description: property.description.text,
+ defaultValue: defaultValues[property.name],
+ visualRefreshTag: getCommentTag(property, 'visualrefresh'),
+ deprecatedTag: getCommentTag(property, 'deprecated'),
+ analyticsTag: getCommentTag(property, 'analytics'),
+ i18nTag: castI18nTag(getCommentTag(property, 'i18n')),
+ };
+ }),
+ events: events.map((event): EventHandler => {
+ const { detailType, detailInlineType, cancelable } = extractEventDetails(event.rawType, checker);
+ return {
+ name: event.name,
+ description: event.description.text,
+ cancelable,
+ detailType,
+ detailInlineType,
+ deprecatedTag: getCommentTag(event, 'deprecated'),
+ };
+ }),
+ };
+}
+
+function extractEventDetails(type: ts.Type, checker: ts.TypeChecker) {
+ const realType = type.getNonNullableType();
+ const handlerName = realType.aliasSymbol?.getName();
+ if (handlerName !== 'CancelableEventHandler' && handlerName !== 'NonCancelableEventHandler') {
+ throw new Error(`Unknown event handler type: ${checker.typeToString(realType)}`);
+ }
+ const cancelable = handlerName === 'CancelableEventHandler';
+ const detailType = realType.aliasTypeArguments?.[0];
+ if (detailType && detailType.getProperties().length > 0) {
+ const { type, inlineType } = getObjectDefinition(stringifyType(detailType, checker), detailType, checker);
+ return {
+ detailType: type,
+ detailInlineType: inlineType,
+ cancelable,
+ };
+ }
+ return { cancelable };
+}
diff --git a/src/components/components-extractor.ts b/src/components/components-extractor.ts
deleted file mode 100644
index e6e5f2c..0000000
--- a/src/components/components-extractor.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-import * as path from 'path';
-import { resolve } from 'pathe';
-import { matcher } from 'micromatch';
-import { pascalCase } from 'change-case';
-import { DeclarationReflection, ProjectReflection, ReflectionKind } from 'typedoc';
-import { ComponentDefinition } from './interfaces';
-import buildDefinition from './build-definition';
-import schema from '../schema';
-
-function returnsReactContent(node: DeclarationReflection) {
- return node.signatures?.some(
- ({ type }) =>
- schema.types.isReferenceType(type) &&
- (type.symbolFullyQualifiedName.endsWith('JSX.Element') ||
- type.symbolFullyQualifiedName.endsWith('React.ReactPortal'))
- );
-}
-
-function findComponent(module: DeclarationReflection) {
- if (!module.children) {
- return null;
- }
- const components: DeclarationReflection[] = [];
- for (const child of module.children) {
- if (child.flags.isExported) {
- switch (child.kind) {
- case ReflectionKind.Function:
- if (returnsReactContent(child)) {
- components.push(child);
- }
- break;
- case ReflectionKind.Variable:
- if (schema.utils.isForwardRefDeclaration(child)) {
- components.push(child);
- }
- break;
- }
- }
- }
- if (components.length > 1) {
- throw new Error(
- `Found multiple exported components in ${module.name}: ${components.map(child => child.name).join(', ')}`
- );
- }
- return components[0];
-}
-
-function findProps(allDefinitions: DeclarationReflection[], propsName: string, directoryName: string) {
- const props: DeclarationReflection[] = [];
- const objectInterfaces: DeclarationReflection[] = [];
- for (const child of allDefinitions) {
- if (child.name !== propsName || !schema.utils.getDeclarationSourceFilename(child).includes(directoryName)) {
- continue;
- }
-
- if (child.kind === ReflectionKind.Interface && child.children) {
- props.push(...child.children);
- }
-
- if (child.kind === ReflectionKind.Namespace && child.children) {
- for (const subChild of child.children) {
- switch (subChild.kind) {
- case ReflectionKind.TypeAlias:
- case ReflectionKind.Interface:
- objectInterfaces.push(subChild);
- break;
- case ReflectionKind.Property:
- props.push(subChild);
- break;
- default:
- throw new Error(
- `Unknown type ${
- subChild.kindString
- } inside of ${propsName} at ${schema.utils.getDeclarationSourceFilename(subChild)}`
- );
- }
- }
- }
- }
-
- return {
- props: props,
- objects: objectInterfaces,
- };
-}
-
-export default function extractComponents(publicFilesGlob: string, project: ProjectReflection): ComponentDefinition[] {
- const definitions: ComponentDefinition[] = [];
- const isMatch = matcher(resolve(publicFilesGlob));
-
- if (!project.children) {
- return [];
- }
-
- const allDefinitions = project.children.flatMap(module => {
- if (!module.children) {
- throw new Error(`Module ${module.originalName} does not contain a definition.`);
- }
-
- // Hack: Don't include sub version folders as they include duplicate interfaces
- // TODO: Remove once Top Navigation has launched: (AWSUI-15424)
- if (module.originalName.indexOf('-beta') > -1) {
- return [];
- }
-
- return module.children;
- });
- const publicModules = project.children.filter(module => isMatch(module.originalName));
-
- publicModules.forEach(module => {
- const component = findComponent(module);
- if (component) {
- const directoryName = path.dirname(module.originalName);
- if (component.name !== pascalCase(path.basename(directoryName))) {
- throw new Error(`Component ${component.name} is exported from a mismatched folder: ${directoryName}`);
- }
- const propsNamespace = `${component.name}Props`;
- const { props, objects } = findProps(allDefinitions, propsNamespace, directoryName);
- definitions.push(buildDefinition(component, props, objects));
- }
- });
-
- return definitions;
-}
diff --git a/src/components/default-values-extractor.ts b/src/components/default-values-extractor.ts
deleted file mode 100644
index a73cdcc..0000000
--- a/src/components/default-values-extractor.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-import * as ts from 'typescript';
-import { DeclarationReflection } from 'typedoc';
-import { ReflectionType } from 'typedoc/dist/lib/models';
-import schema from '../schema';
-
-export default function extractDefaultValues(component: DeclarationReflection): Record {
- const callSignature: DeclarationReflection | undefined = (
- component.signatures?.[0].parameters?.[0].type as ReflectionType
- )?.declaration;
- if (callSignature) {
- return extractFromCallSignature(callSignature);
- }
- if (schema.utils.isForwardRefDeclaration(component) && component.defaultValue) {
- // TypeDoc does look inside React.forwardRef content, so we need to parse
- // typescript manually to extract values from object destructuring
- return extractFromSource(component.defaultValue);
- }
- return {};
-}
-
-export function extractFromSource(sourceCode: string): Record {
- const node = ts.createSourceFile('temp.ts', sourceCode, ts.ScriptTarget.Latest);
- const statement = node.statements[0];
- if (!ts.isExpressionStatement(statement)) {
- return {};
- }
- let expression = statement.expression;
- if (ts.isAsExpression(expression)) {
- expression = expression.expression;
- }
- if (ts.isCallExpression(expression)) {
- expression = expression.arguments[0];
- }
- if (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression)) {
- return {};
- }
- const props = expression.parameters[0];
- if (!ts.isObjectBindingPattern(props.name)) {
- return {};
- }
- const values: Record = {};
- for (const element of props.name.elements) {
- if (ts.isIdentifier(element.name) && element.initializer) {
- values[element.name.escapedText as string] = sourceCode
- .substring(element.initializer.pos, element.initializer.end)
- .trim();
- }
- }
- return values;
-}
-
-function extractFromCallSignature(callSignature: DeclarationReflection) {
- const values: Record = {};
- for (const child of callSignature.children || []) {
- if (child.defaultValue) {
- values[child.name] = child.defaultValue;
- }
- }
- return values;
-}
diff --git a/src/components/extractor.ts b/src/components/extractor.ts
new file mode 100644
index 0000000..437faef
--- /dev/null
+++ b/src/components/extractor.ts
@@ -0,0 +1,189 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import ts from 'typescript';
+
+import {
+ extractDeclaration,
+ getDescription,
+ isOptional,
+ stringifyType,
+ unwrapNamespaceDeclaration,
+} from './type-utils';
+
+export interface ExpandedProp {
+ name: string;
+ type: string;
+ isOptional: boolean;
+ rawType: ts.Type;
+ description: {
+ text: string | undefined;
+ tags: Array<{ name: string; text: string | undefined }>;
+ };
+}
+
+export function extractDefaultValues(exportSymbol: ts.Symbol, checker: ts.TypeChecker) {
+ let declaration: ts.Node = extractDeclaration(exportSymbol);
+ if (ts.isExportAssignment(declaration)) {
+ // Traverse from "export default Something;" to the actual "Something"
+ const symbol = checker.getSymbolAtLocation(declaration.expression);
+ if (!symbol) {
+ throw new Error('Cannot resolve symbol');
+ }
+ declaration = extractDeclaration(symbol);
+ }
+ // Extract "Something" from "const Component = Something"
+ if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
+ declaration = declaration.initializer;
+ }
+ // Extract "Something" from "Something as MyComponentType"
+ if (ts.isAsExpression(declaration)) {
+ declaration = declaration.expression;
+ }
+ // Extract "Something from React.forwardRef(Something)"
+ if (
+ ts.isCallExpression(declaration) &&
+ (declaration.expression.getText() === 'React.forwardRef' || declaration.expression.getText() === 'forwardRef')
+ ) {
+ declaration = declaration.arguments[0];
+ }
+
+ // In the component function, find arguments destructuring
+ let argument: ts.Node | undefined;
+ if (
+ ts.isFunctionDeclaration(declaration) ||
+ ts.isFunctionExpression(declaration) ||
+ ts.isArrowFunction(declaration)
+ ) {
+ if (declaration.parameters.length === 0) {
+ return {};
+ }
+ argument = declaration.parameters[0].name;
+ }
+ if (!argument) {
+ throw new Error(`Unsupported component declaration type ${ts.SyntaxKind[declaration.kind]}`);
+ }
+ if (!ts.isObjectBindingPattern(argument)) {
+ // if a component does not use props de-structuring, we do not detect default values
+ return {};
+ }
+ const values: Record = {};
+ for (const element of argument.elements) {
+ if (ts.isIdentifier(element.name) && element.initializer) {
+ values[element.name.escapedText as string] = element.initializer.getText();
+ }
+ }
+ return values;
+}
+
+export function extractProps(propsSymbol: ts.Symbol, checker: ts.TypeChecker) {
+ const exportType = checker.getDeclaredTypeOfSymbol(propsSymbol);
+
+ return exportType
+ .getProperties()
+ .map((value): ExpandedProp => {
+ const declaration = extractDeclaration(value);
+ const type = checker.getTypeAtLocation(declaration);
+ return {
+ name: value.name,
+ type: stringifyType(type, checker),
+ rawType: type,
+ isOptional: isOptional(type),
+ description: getDescription(value.getDocumentationComment(checker), declaration),
+ };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name));
+}
+
+export function extractFunctions(propsSymbol: ts.Symbol, checker: ts.TypeChecker) {
+ const propsName = propsSymbol.getName();
+ const namespaceDeclaration = [
+ // if we got the namespace directly
+ ...(propsSymbol.getDeclarations() ?? []),
+ // find namespace declaration from the interface with the same name
+ ...(checker.getDeclaredTypeOfSymbol(propsSymbol).getSymbol()?.getDeclarations() ?? []),
+ ].find(decl => decl.kind === ts.SyntaxKind.ModuleDeclaration);
+ const refType = unwrapNamespaceDeclaration(namespaceDeclaration)
+ .map(child => checker.getTypeAtLocation(child))
+ .find(type => (type.getSymbol() ?? type.aliasSymbol)?.getName() === 'Ref');
+
+ if (!refType) {
+ return [];
+ }
+ return refType
+ .getProperties()
+ .map((value): ExpandedProp => {
+ const declaration = extractDeclaration(value);
+ const type = checker.getTypeAtLocation(declaration);
+ const realType = type.getNonNullableType();
+ if (realType.getCallSignatures().length === 0) {
+ throw new Error(
+ `${propsName}.Ref should contain only methods, "${value.name}" has a "${stringifyType(type, checker)}" type`
+ );
+ }
+ return {
+ name: value.name,
+ type: stringifyType(realType, checker),
+ rawType: realType,
+ isOptional: isOptional(type),
+ description: getDescription(value.getDocumentationComment(checker), declaration),
+ };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name));
+}
+
+export function extractExports(
+ componentName: string,
+ exportSymbols: ts.Symbol[],
+ checker: ts.TypeChecker,
+ extraExports: Record>
+) {
+ let componentSymbol;
+ let propsSymbol;
+ const unknownExports: Array = [];
+ for (const exportSymbol of exportSymbols) {
+ if (exportSymbol.name === 'default') {
+ validateComponentType(componentName, exportSymbol, checker);
+ componentSymbol = exportSymbol;
+ } else if (exportSymbol.name === `${componentName}Props`) {
+ propsSymbol = exportSymbol;
+ } else if (!extraExports[componentName] || !extraExports[componentName].includes(exportSymbol.name)) {
+ unknownExports.push(exportSymbol.name);
+ }
+ }
+ // disabled until migration is complete
+ // if (unknownExports.length > 0) {
+ // throw new Error(`Unexpected exports in ${componentName}: ${unknownExports.join(', ')}`);
+ // }
+ if (!componentSymbol) {
+ throw new Error(`Missing default export for ${componentName}`);
+ }
+ if (!propsSymbol) {
+ throw new Error(`Missing ${componentName}Props export`);
+ }
+ return { componentSymbol, propsSymbol };
+}
+
+function validateComponentType(componentName: string, symbol: ts.Symbol, checker: ts.TypeChecker) {
+ const declaration = extractDeclaration(symbol);
+ let type: ts.Type;
+ if (ts.isExportAssignment(declaration)) {
+ // export default Something;
+ type = checker.getTypeAtLocation(declaration.expression);
+ } else if (ts.isFunctionDeclaration(declaration)) {
+ // export default function Something() {...}
+ type = checker.getTypeAtLocation(declaration);
+ } else {
+ throw new Error(`Unknown default export for ${componentName}`);
+ }
+ if (
+ // React.forwardRef
+ type.getSymbol()?.name !== 'ForwardRefExoticComponent' &&
+ // Plain function returning JSX
+ type.getCallSignatures().some(signature => {
+ const returnTypeName = checker.typeToString(signature.getReturnType());
+ return returnTypeName !== 'Element' && returnTypeName !== 'ReactPortal';
+ })
+ ) {
+ throw new Error(`Unknown default export type ${checker.typeToString(type)}`);
+ }
+}
diff --git a/src/components/index.ts b/src/components/index.ts
index 08b8ec2..85528fa 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,30 +1,57 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
+import { pascalCase } from 'change-case';
+import pathe from 'pathe';
+import { matcher } from 'micromatch';
-import { ComponentDefinition } from './interfaces';
-import extractComponents from './components-extractor';
-import { bootstrapProject } from '../bootstrap';
+import { buildComponentDefinition } from './component-definition';
+import { extractDefaultValues, extractExports, extractFunctions, extractProps } from './extractor';
+import type { ComponentDefinition } from './interfaces';
+import { bootstrapTypescriptProject } from '../bootstrap/typescript';
+
+function componentNameFromPath(componentPath: string) {
+ const directoryName = pathe.dirname(componentPath);
+ return pascalCase(pathe.basename(directoryName));
+}
+
+interface DocumenterOptions {
+ extraExports?: Record>;
+}
-/**
- * @param tsconfigPath Path to tsconfig file
- * @param publicFilesGlob Filter to obtain public files
- * @param nodeModulesDependencyFilePaths node_modules paths of libraries to include in documentation e.g.["dir/node_modules/@cloudscape-design/components/icon/interfaces.d.ts"]
- * @returns Component definitions
- */
export function documentComponents(
tsconfigPath: string,
publicFilesGlob: string,
- nodeModulesDependencyFilePaths?: string[]
-): ComponentDefinition[] {
- const includeNodeModulePaths = Boolean(nodeModulesDependencyFilePaths?.length);
- const project = bootstrapProject(
- {
- tsconfig: tsconfigPath,
- includeDeclarations: includeNodeModulePaths,
- excludeExternals: includeNodeModulePaths,
- },
- undefined,
- nodeModulesDependencyFilePaths
- );
- return extractComponents(publicFilesGlob, project);
+ // deprecated, now unused
+ additionalInputFilePaths?: Array,
+ options?: DocumenterOptions
+): Array {
+ const program = bootstrapTypescriptProject(tsconfigPath);
+ const checker = program.getTypeChecker();
+
+ const isMatch = matcher(pathe.resolve(publicFilesGlob));
+
+ return program
+ .getSourceFiles()
+ .filter(file => isMatch(file.fileName))
+ .map(sourceFile => {
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
+ const name = componentNameFromPath(sourceFile.fileName);
+
+ // istanbul ignore next
+ if (!moduleSymbol) {
+ throw new Error(`Unable to resolve module: ${sourceFile.fileName}`);
+ }
+ const exportSymbols = checker.getExportsOfModule(moduleSymbol);
+ const { propsSymbol, componentSymbol } = extractExports(
+ name,
+ exportSymbols,
+ checker,
+ options?.extraExports ?? {}
+ );
+ const props = extractProps(propsSymbol, checker);
+ const functions = extractFunctions(propsSymbol, checker);
+ const defaultValues = extractDefaultValues(componentSymbol, checker);
+
+ return buildComponentDefinition(name, props, functions, defaultValues, checker);
+ });
}
diff --git a/src/components/interfaces.ts b/src/components/interfaces.ts
index bf02fb7..f7ea1e5 100644
--- a/src/components/interfaces.ts
+++ b/src/components/interfaces.ts
@@ -2,8 +2,11 @@
// SPDX-License-Identifier: Apache-2.0
export interface ComponentDefinition {
name: string;
+ /** @deprecated */
releaseStatus: string;
+ /** @deprecated */
version?: string;
+ /** @deprecated */
description?: string;
properties: ComponentProperty[];
regions: ComponentRegion[];
@@ -19,11 +22,19 @@ export interface ComponentProperty {
inlineType?: TypeDefinition;
defaultValue?: string;
analyticsTag?: string;
+ deprecatedTag?: string;
+ visualRefreshTag?: string;
+ i18nTag?: true | undefined;
}
export interface ComponentRegion {
name: string;
description?: string;
+ displayName?: string;
+ isDefault: boolean;
+ deprecatedTag?: string;
+ visualRefreshTag?: string;
+ i18nTag?: true | undefined;
}
export interface ComponentFunction {
@@ -33,7 +44,7 @@ export interface ComponentFunction {
returnType: string;
}
-type TypeDefinition = ObjectDefinition | FunctionDefinition | UnionTypeDefinition;
+export type TypeDefinition = ObjectDefinition | FunctionDefinition | UnionTypeDefinition;
export interface ObjectDefinition {
name: string;
@@ -71,4 +82,5 @@ export interface EventHandler {
detailType?: string;
detailInlineType?: TypeDefinition;
cancelable: boolean;
+ deprecatedTag?: string;
}
diff --git a/src/components/object-definition.ts b/src/components/object-definition.ts
new file mode 100644
index 0000000..84adfc4
--- /dev/null
+++ b/src/components/object-definition.ts
@@ -0,0 +1,113 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import ts from 'typescript';
+import type { TypeDefinition, UnionTypeDefinition } from './interfaces';
+import { extractDeclaration, isOptional, stringifyType } from './type-utils';
+
+function isArrayType(type: ts.Type) {
+ const symbol = type.getSymbol();
+ if (!symbol) {
+ return false;
+ }
+ return symbol.getName() === 'Array' || symbol.getName() === 'ReadonlyArray';
+}
+
+export function getObjectDefinition(
+ type: string,
+ rawType: ts.Type,
+ checker: ts.TypeChecker
+): { type: string; inlineType?: TypeDefinition } {
+ const realType = rawType.getNonNullableType();
+ const realTypeName = stringifyType(realType, checker);
+ if (
+ realType.flags & ts.TypeFlags.String ||
+ realType.flags & ts.TypeFlags.StringLiteral ||
+ realType.flags & ts.TypeFlags.Boolean ||
+ realType.flags & ts.TypeFlags.Number ||
+ isArrayType(realType) ||
+ realTypeName === 'HTMLElement'
+ ) {
+ // do not expand built-in Javascript methods or primitive values
+ return { type };
+ }
+ if (realType.isUnionOrIntersection()) {
+ return getUnionTypeDefinition(realTypeName, realType, checker);
+ }
+ if (realType.getProperties().length > 0) {
+ return {
+ type: type,
+ inlineType: {
+ name: realTypeName,
+ type: 'object',
+ properties: realType
+ .getProperties()
+ .map(prop => {
+ const propType = checker.getTypeAtLocation(extractDeclaration(prop));
+ return {
+ name: prop.getName(),
+ type: stringifyType(propType, checker),
+ optional: isOptional(propType),
+ };
+ })
+ .sort((a, b) => a.name.localeCompare(b.name)),
+ },
+ };
+ }
+ if (realType.getCallSignatures().length > 0) {
+ if (realType.getCallSignatures().length > 1) {
+ throw new Error('Multiple call signatures are not supported');
+ }
+ const signature = realType.getCallSignatures()[0];
+
+ return {
+ type,
+ inlineType: {
+ name: realTypeName,
+ type: 'function',
+ returnType: stringifyType(signature.getReturnType(), checker),
+ parameters: signature.getParameters().map(param => {
+ const paramType = checker.getTypeAtLocation(extractDeclaration(param));
+ return {
+ name: param.getName(),
+ type: stringifyType(paramType, checker),
+ };
+ }),
+ },
+ };
+ }
+ return { type };
+}
+
+function getUnionTypeDefinition(
+ realTypeName: string,
+ realType: ts.UnionOrIntersectionType,
+ checker: ts.TypeChecker
+): { type: string; inlineType: UnionTypeDefinition } {
+ if (realType.types.every(subtype => subtype.isStringLiteral())) {
+ return {
+ type: 'string',
+ inlineType: {
+ name: realTypeName,
+ type: 'union',
+ values: realType.types.map(subtype => (subtype as ts.StringLiteralType).value),
+ },
+ };
+ } else if (realType.types.every(subtype => subtype.isNumberLiteral())) {
+ return {
+ type: 'number',
+ inlineType: {
+ name: realTypeName,
+ type: 'union',
+ values: realType.types.map(subtype => (subtype as ts.NumberLiteralType).value.toString()),
+ },
+ };
+ }
+ return {
+ type: realTypeName,
+ inlineType: {
+ name: realTypeName,
+ type: 'union',
+ values: realType.types.map(subtype => stringifyType(subtype, checker)),
+ },
+ };
+}
diff --git a/src/components/type-utils.ts b/src/components/type-utils.ts
new file mode 100644
index 0000000..ce634fe
--- /dev/null
+++ b/src/components/type-utils.ts
@@ -0,0 +1,66 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import ts from 'typescript';
+
+export function isOptional(type: ts.Type) {
+ if (!type.isUnionOrIntersection()) {
+ return false;
+ }
+ return !!type.types.find(t => t.flags & ts.TypeFlags.Undefined);
+}
+
+export function unwrapNamespaceDeclaration(declaration: ts.Declaration | undefined) {
+ if (!declaration) {
+ return [];
+ }
+ const namespaceBlock = declaration.getChildren().find(node => node.kind === ts.SyntaxKind.ModuleBlock);
+ if (!namespaceBlock) {
+ return [];
+ }
+ const moduleContent = namespaceBlock.getChildren().find(node => node.kind === ts.SyntaxKind.SyntaxList);
+ if (!moduleContent) {
+ return [];
+ }
+ return moduleContent.getChildren();
+}
+
+function stripUndefined(typeString: string) {
+ return typeString.replace(/\| undefined$/, '').trim();
+}
+
+export function stringifyType(type: ts.Type, checker: ts.TypeChecker) {
+ return stripUndefined(
+ checker.typeToString(
+ type,
+ undefined,
+ ts.TypeFormatFlags.WriteArrayAsGenericType |
+ ts.TypeFormatFlags.UseFullyQualifiedType |
+ ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope
+ )
+ );
+}
+
+function expandTags(extraTags: ReadonlyArray) {
+ return extraTags.map(tag => ({
+ name: tag.tagName.text,
+ text: ts.getTextOfJSDocComment(tag.comment),
+ }));
+}
+
+export function getDescription(docComment: Array, declaration: ts.Node) {
+ return {
+ text: docComment.length > 0 ? ts.displayPartsToString(docComment) : undefined,
+ tags: expandTags(ts.getJSDocTags(declaration)),
+ };
+}
+
+export function extractDeclaration(symbol: ts.Symbol) {
+ const declarations = symbol.getDeclarations();
+ if (!declarations || declarations.length === 0) {
+ throw new Error(`No declaration found for symbol: ${symbol.getName()}`);
+ }
+ if (declarations.length > 1) {
+ throw new Error(`Multiple declarations found for symbol: ${symbol.getName()}`);
+ }
+ return declarations[0];
+}
diff --git a/test/components/analytics-tag.test.ts b/test/components/analytics-tag.test.ts
index 470f60c..6b0902b 100644
--- a/test/components/analytics-tag.test.ts
+++ b/test/components/analytics-tag.test.ts
@@ -17,14 +17,8 @@ describe('Analytics tag', () => {
{
name: 'analyticsMetadata',
analyticsTag: 'View details in Analytics tab',
- defaultValue: undefined,
- deprecatedTag: undefined,
- description: '',
- i18nTag: undefined,
- inlineType: undefined,
optional: true,
type: 'string',
- visualRefreshTag: undefined,
},
]);
});
@@ -33,13 +27,7 @@ describe('Analytics tag', () => {
expect(component.regions).toEqual([
{
name: 'children',
- analyticsTag: undefined,
isDefault: true,
- deprecatedTag: undefined,
- description: undefined,
- displayName: undefined,
- i18nTag: undefined,
- visualRefreshTag: undefined,
},
]);
});
diff --git a/test/components/complex-types.test.ts b/test/components/complex-types.test.ts
index 51350cd..8f0985f 100644
--- a/test/components/complex-types.test.ts
+++ b/test/components/complex-types.test.ts
@@ -5,13 +5,14 @@ import { buildProject } from './test-helpers';
let buttonGroup: ComponentDefinition;
let sideNavigation: ComponentDefinition;
+let columnLayout: ComponentDefinition;
let table: ComponentDefinition;
beforeAll(() => {
const result = buildProject('complex-types');
- expect(result).toHaveLength(3);
+ expect(result).toHaveLength(4);
- [buttonGroup, sideNavigation, table] = result;
+ [buttonGroup, columnLayout, sideNavigation, table] = result;
});
test('should only have expected properties, regions and events', () => {
@@ -39,13 +40,13 @@ test('should have correct property types', () => {
defaultValue: undefined,
description: undefined,
inlineType: {
- name: 'TableProps.AriaLabels',
+ name: 'TableProps.AriaLabels',
type: 'object',
properties: [
{
name: 'allItemsSelectionLabel',
optional: true,
- type: '(data: TableProps.SelectionState) => string',
+ type: '((data: TableProps.SelectionState) => string)',
},
],
},
@@ -63,7 +64,7 @@ test('should have correct property types', () => {
type: 'TableProps.FilteringFunction',
inlineType: {
type: 'function',
- name: 'TableProps.FilteringFunction',
+ name: 'TableProps.FilteringFunction',
returnType: 'boolean',
parameters: [
{
@@ -89,7 +90,7 @@ test('should have correct property types', () => {
type: 'TableProps.TrackBy',
inlineType: {
type: 'union',
- name: 'TableProps.TrackBy',
+ name: 'TableProps.TrackBy',
values: ['string', '(item: T) => boolean'],
},
optional: true,
@@ -121,9 +122,9 @@ test('should have correct detail type in the event', () => {
});
test('should properly display string union types', () => {
- const eventDetail = sideNavigation.events.find(def => def.name === 'onFollow');
- expect(eventDetail?.detailType).toEqual('SideNavigationProps.FollowDetail');
- expect(eventDetail?.detailInlineType).toEqual({
+ const eventDetail = sideNavigation.events.find(def => def.name === 'onFollow')!;
+ expect(eventDetail.detailType).toEqual('SideNavigationProps.FollowDetail');
+ expect(eventDetail.detailInlineType).toEqual({
type: 'object',
name: 'SideNavigationProps.FollowDetail',
properties: [
@@ -135,12 +136,35 @@ test('should properly display string union types', () => {
{
name: 'type',
optional: true,
- type: '"expandable-link-group" | "link" | "link-group"',
+ type: '"link" | "link-group" | "expandable-link-group"',
},
],
});
});
+test('should properly display number and mixed union types', () => {
+ expect(columnLayout.properties.find(def => def.name === 'columns')).toEqual({
+ name: 'columns',
+ optional: false,
+ type: 'number',
+ inlineType: {
+ name: 'ColumnLayoutProps.Columns',
+ type: 'union',
+ values: ['2', '1', '3', '4'],
+ },
+ });
+ expect(columnLayout.properties.find(def => def.name === 'widths')).toEqual({
+ name: 'widths',
+ optional: false,
+ type: 'ColumnLayoutProps.Widths',
+ inlineType: {
+ name: 'ColumnLayoutProps.Widths',
+ type: 'union',
+ values: ['25', '"50%"', '100', '"33%"'],
+ },
+ });
+});
+
test('should parse string literal type as single-value union', () => {
expect(buttonGroup.properties).toEqual([
{
@@ -152,12 +176,7 @@ test('should parse string literal type as single-value union', () => {
{
name: 'variant',
description: 'This is variant',
- type: 'string',
- inlineType: {
- name: 'ButtonGroupProps.Variant',
- type: 'union',
- values: ['icon'],
- },
+ type: '"icon"',
optional: false,
},
]);
diff --git a/test/components/default-values-extractor.test.ts b/test/components/default-values-extractor.test.ts
index bbf0275..10dbb6e 100644
--- a/test/components/default-values-extractor.test.ts
+++ b/test/components/default-values-extractor.test.ts
@@ -1,40 +1,131 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
-import { extractFromSource } from '../../src/components/default-values-extractor';
+import ts from 'typescript';
+import { extractDefaultValues } from '../../src/components/extractor';
-test('should return empty result for unrecognized value', () => {
- expect(extractFromSource('class Something {}')).toEqual({});
-});
+function extractFromSource(source: string) {
+ const host = ts.createCompilerHost({});
+ const mockFs = new Map([['temp.ts', source]]);
+ // mock file system access
+ host.readFile = name => mockFs.get(name);
+ host.writeFile = () => {};
+ const program = ts.createProgram(['temp.ts'], {}, host);
+ const checker = program.getTypeChecker();
+
+ const moduleSymbol = checker.getSymbolAtLocation(program.getSourceFile('temp.ts')!)!;
+
+ return extractDefaultValues(checker.getExportsOfModule(moduleSymbol)[0], checker);
+}
-test('should return empty result if there is no argument destructuring', () => {
- expect(extractFromSource('React.forwardRef((props) => {})')).toEqual({});
+test('should throw on unsupported syntax', () => {
+ expect(() => extractFromSource('export class Component {}')).toThrow(
+ /Unsupported component declaration type ClassDeclaration/
+ );
});
-test('should extract boolean values', () => {
- expect(extractFromSource('React.forwardRef(({truthy = true, falsy = false}) => {})')).toEqual({
- truthy: 'true',
- falsy: 'false',
+describe('forwardRef', () => {
+ test('should return empty result if there is no argument destructuring', () => {
+ expect(extractFromSource('export const Component = React.forwardRef((props) => {})')).toEqual({});
});
-});
-test('should extract string values', () => {
- expect(extractFromSource('React.forwardRef(({variant = "aaa"}) => {})')).toEqual({ variant: '"aaa"' });
-});
+ test('should extract default values', () => {
+ expect(
+ extractFromSource(
+ `export const Component = React.forwardRef(({
+ truthy = true,
+ falsy = false,
+ variant = "aaa",
+ columns = 4,
+ items = [],
+ nothing,
+ }) => {})`
+ )
+ ).toEqual({
+ truthy: 'true',
+ falsy: 'false',
+ variant: '"aaa"',
+ columns: '4',
+ items: '[]',
+ });
+ });
-test('should extract number values', () => {
- expect(extractFromSource('React.forwardRef(({columns = 4}) => {})')).toEqual({ columns: '4' });
-});
+ test('should work with es5-functions', () => {
+ expect(
+ extractFromSource('export const Component = React.forwardRef(function ({works = "ok"}) {return {}})')
+ ).toEqual({
+ works: '"ok"',
+ });
+ });
-test('should extract other data types as raw source', () => {
- expect(extractFromSource('React.forwardRef(({items = []}) => {})')).toEqual({ items: '[]' });
+ test('should extract values from type-casted forwardRefs', () => {
+ expect(
+ extractFromSource(
+ `export const Component = React.forwardRef(({ works = "ok" }) => {}) as (props: any) => JSX.Element`
+ )
+ ).toEqual({
+ works: '"ok"',
+ });
+ });
});
-test('should work with es5-functions', () => {
- expect(extractFromSource('React.forwardRef(function ({works = "ok"}) {return {}})')).toEqual({ works: '"ok"' });
+describe('arrow function', () => {
+ test('should return empty result if there is no argument', () => {
+ expect(extractFromSource('export const Component = () => {}')).toEqual({});
+ });
+
+ test('should return empty result if there is no argument destructuring', () => {
+ expect(extractFromSource('export const Component = props => {}')).toEqual({});
+ });
+
+ test('should extract default values', () => {
+ expect(
+ extractFromSource(
+ `export const Component = ({
+ truthy = true,
+ falsy = false,
+ variant = "aaa",
+ columns = 4,
+ items = [],
+ nothing,
+ }) => {}`
+ )
+ ).toEqual({
+ truthy: 'true',
+ falsy: 'false',
+ variant: '"aaa"',
+ columns: '4',
+ items: '[]',
+ });
+ });
});
-test('should extract values from type-casted forwardRefs', () => {
- expect(extractFromSource(`React.forwardRef(({ works = "ok" }) => {}) as (props: any) => JSX.Element`)).toEqual({
- works: '"ok"',
+describe('function declaration', () => {
+ test('should return empty result if there are no arguments', () => {
+ expect(extractFromSource('export function Component () {}')).toEqual({});
+ });
+
+ test('should return empty result if there is no argument destructuring', () => {
+ expect(extractFromSource('export function Component (props) {}')).toEqual({});
+ });
+
+ test('should extract default values', () => {
+ expect(
+ extractFromSource(
+ `export function Component ({
+ truthy = true,
+ falsy = false,
+ variant = "aaa",
+ columns = 4,
+ items = [],
+ nothing,
+ }) {}`
+ )
+ ).toEqual({
+ truthy: 'true',
+ falsy: 'false',
+ variant: '"aaa"',
+ columns: '4',
+ items: '[]',
+ });
});
});
diff --git a/test/components/errors.test.ts b/test/components/errors.test.ts
index 6453cd6..ac75baa 100644
--- a/test/components/errors.test.ts
+++ b/test/components/errors.test.ts
@@ -3,37 +3,35 @@
import { buildProject } from './test-helpers';
test('should throw in case of configuration errors', () => {
- expect(() => buildProject('errors-config')).toThrow('Errors during parsing configuration');
+ expect(() => buildProject('errors-config')).toThrow('Failed to parse tsconfig.json');
});
test('should throw in case of type errors', () => {
- expect(() => buildProject('errors-types')).toThrow('Project generation failed');
+ expect(() => buildProject('errors-types')).toThrow('Compilation failed');
});
test('should throw error when multiple components exported from the main file', () => {
- expect(() => buildProject('ambiguous-exports')).toThrow(
- 'Found multiple exported components in "index": AlsoButton, Button'
- );
+ expect(() => buildProject('ambiguous-exports')).toThrow('Missing default export for Component');
+});
+
+test('should throw error if default export is not a react component', () => {
+ expect(() => buildProject('error-not-a-component')).toThrow('Unknown default export type () => { type: string; }');
});
test('should throw error if event handler has an invalid type', () => {
- expect(() => buildProject('unknown-event-handler')).toThrow('Unknown event handler type: intrinsic');
+ expect(() => buildProject('unknown-event-handler')).toThrow('Unknown event handler type: string');
});
test('should throw error if component ref contains function overloads', () => {
- expect(() => buildProject('error-ref-overload')).toThrow(
- 'Method overloads are not supported, found multiple signatures'
- );
+ expect(() => buildProject('error-ref-overload')).toThrow('Multiple declarations found for symbol: focus');
});
test('should throw error if component ref contains non-method properties', () => {
expect(() => buildProject('error-ref-property-type')).toThrow(
- 'ButtonProps.Ref.value should contain only methods, "value" has a "string" type'
+ 'ButtonProps.Ref should contain only methods, "value" has a "string" type'
);
});
-test('should throw error if component name does not match the folder name', () => {
- expect(() => buildProject('error-incorrect-component-name')).toThrow(
- /Component Input is exported from a mismatched folder: .*\/button/
- );
+test('should throw error if component does not export a props namespace', () => {
+ expect(() => buildProject('error-missing-props')).toThrow(/Missing ButtonProps export/);
});
diff --git a/test/components/forward-ref.test.ts b/test/components/forward-ref.test.ts
index 4e2cd69..3f30cc2 100644
--- a/test/components/forward-ref.test.ts
+++ b/test/components/forward-ref.test.ts
@@ -40,6 +40,12 @@ test('should detect default properties from forwardRef', () => {
test('should provide correct function definition', () => {
expect(component.functions).toEqual([
+ {
+ description: 'Showcase for optional functions',
+ name: 'cancelEdit',
+ parameters: [],
+ returnType: 'void',
+ },
{
description: 'Focuses the primary element',
name: 'focus',
diff --git a/test/components/import-types.test.ts b/test/components/import-types.test.ts
index 1ac1460..79b3da7 100644
--- a/test/components/import-types.test.ts
+++ b/test/components/import-types.test.ts
@@ -19,7 +19,7 @@ test('should resolve object type', () => {
inlineType: {
name: 'DependencyProps.Variant',
type: 'union',
- values: ['button', 'link'],
+ values: ['link', 'button'],
},
optional: false,
defaultValue: undefined,
@@ -33,9 +33,9 @@ test('should resolve event detail types', () => {
{
name: 'onChange',
cancelable: false,
- detailType: 'MainProps.ChangeDetail',
+ detailType: 'BaseChangeDetail',
detailInlineType: {
- name: 'MainProps.ChangeDetail',
+ name: 'BaseChangeDetail',
properties: [
{
name: 'value',
diff --git a/test/components/portals.test.ts b/test/components/portals.test.ts
index 7f20769..f1b5575 100644
--- a/test/components/portals.test.ts
+++ b/test/components/portals.test.ts
@@ -13,7 +13,6 @@ beforeAll(() => {
test('should detect component which uses createPortals', () => {
expect(component.name).toEqual('SimplePortal');
- expect(component.description).toEqual('Component-level description');
});
test('should have correct properties', () => {
@@ -44,12 +43,12 @@ test('should have correct properties', () => {
description: 'This is variant',
type: 'string',
inlineType: {
- name: '',
+ name: '"link" | "button"',
type: 'union',
- values: ['button', 'link'],
+ values: ['link', 'button'],
},
optional: true,
- defaultValue: '"button"',
+ defaultValue: "'button'",
},
]);
});
diff --git a/test/components/release-status.test.ts b/test/components/release-status.test.ts
deleted file mode 100644
index 790e0b8..0000000
--- a/test/components/release-status.test.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
-// SPDX-License-Identifier: Apache-2.0
-import { ComponentDefinition } from '../../src';
-import { buildProject } from './test-helpers';
-
-let component: ComponentDefinition;
-beforeAll(() => {
- const result = buildProject('release-status');
- expect(result).toHaveLength(1);
-
- component = result[0];
-});
-
-test('should have beta release status & version', () => {
- expect(component.releaseStatus).toBe('beta');
- expect(component.version).toBe('1.0-beta');
-});
diff --git a/test/components/simple.test.ts b/test/components/simple.test.ts
index b7b4e7c..f19f430 100644
--- a/test/components/simple.test.ts
+++ b/test/components/simple.test.ts
@@ -13,7 +13,7 @@ beforeAll(() => {
test('should have correct name, description and release status', () => {
expect(component.name).toBe('Simple');
- expect(component.description).toBe('Component-level description');
+ expect(component.description).toBeUndefined();
expect(component.releaseStatus).toBe('stable');
});
@@ -45,12 +45,12 @@ test('should have correct properties', () => {
description: 'This is variant',
type: 'string',
inlineType: {
- name: '',
+ name: '"link" | "button"',
type: 'union',
- values: ['button', 'link'],
+ values: ['link', 'button'],
},
optional: true,
- defaultValue: '"button"',
+ defaultValue: "'button'",
},
]);
});
diff --git a/test/components/test-helpers.ts b/test/components/test-helpers.ts
index 1741496..e83c918 100644
--- a/test/components/test-helpers.ts
+++ b/test/components/test-helpers.ts
@@ -5,13 +5,10 @@ import { ComponentDefinition, documentComponents, documentTestUtils } from '../.
import { bootstrapProject } from '../../src/bootstrap';
import { TestUtilsDoc } from '../../src/test-utils/interfaces';
-// TODO: Move this file into common location, improve naming
-
-export function buildProject(name: string, nodeModulesDependencyFilePaths?: string[]): ComponentDefinition[] {
+export function buildProject(name: string): ComponentDefinition[] {
return documentComponents(
require.resolve(`../../fixtures/components/${name}/tsconfig.json`),
- `fixtures/components/${name}/*/index.tsx`,
- nodeModulesDependencyFilePaths
+ `fixtures/components/${name}/*/index.tsx`
);
}
diff --git a/test/components/third-party-import-types.test.ts b/test/components/third-party-import-types.test.ts
index 7702335..1217847 100644
--- a/test/components/third-party-import-types.test.ts
+++ b/test/components/third-party-import-types.test.ts
@@ -1,35 +1,12 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { buildProject } from './test-helpers';
-import { ComponentDefinition } from '../../src';
-import * as bootstrap from '../../src/bootstrap';
-import process from 'node:process';
-const cwd = process.cwd();
-const nodeModulesPath = `${cwd}/fixtures/components/third-party-import-types/node_modules_mock/icon/interfaces.d.ts`;
-test('should resolve object type to string', () => {
- const resultBefore = buildProject('third-party-import-types');
- const buttonBefore: ComponentDefinition | undefined = resultBefore.find(component => component.name === 'Button');
+test('should resolve object type coming from node_modules', () => {
+ const resultAfter = buildProject('third-party-import-types');
+ const buttonAfter = resultAfter.find(component => component.name === 'Button')!;
- expect(buttonBefore?.properties).toEqual([
- {
- name: 'iconName',
- type: 'IconProps.Name',
- inlineType: undefined,
- optional: false,
- description: 'This is icon name',
- defaultValue: undefined,
- visualRefreshTag: undefined,
- deprecatedTag: undefined,
- i18nTag: undefined,
- analyticsTag: undefined,
- },
- ]);
-
- const resultAfter = buildProject('third-party-import-types', [nodeModulesPath]);
- const buttonAfter: ComponentDefinition | undefined = resultAfter.find(component => component.name === 'Button');
-
- expect(buttonAfter?.properties).toEqual([
+ expect(buttonAfter.properties).toEqual([
{
name: 'iconName',
type: 'string',
@@ -44,13 +21,3 @@ test('should resolve object type to string', () => {
},
]);
});
-
-test('passing nodeModulesDependencyFilePaths should enable includeDeclarations and excludeExternals', () => {
- const bootstrapProjectSpy = jest.spyOn(bootstrap, 'bootstrapProject');
- buildProject('third-party-import-types', [nodeModulesPath]);
-
- expect(bootstrapProjectSpy.mock.calls[0][0].includeDeclarations).toBe(true);
- expect(bootstrapProjectSpy.mock.calls[0][0].excludeExternals).toBe(true);
-
- jest.restoreAllMocks();
-});
diff --git a/test/components/visual-refresh-tag.test.ts b/test/components/visual-refresh-tag.test.ts
index 4a09ded..ba89028 100644
--- a/test/components/visual-refresh-tag.test.ts
+++ b/test/components/visual-refresh-tag.test.ts
@@ -18,7 +18,7 @@ test('should have correct region definitions', () => {
type: 'string',
inlineType: undefined,
optional: true,
- description: 'Footer\nMore text',
+ description: 'Footer\n\nMore text',
visualRefreshTag: '',
defaultValue: undefined,
},