diff --git a/fixtures/components/system-tag/button/index.tsx b/fixtures/components/system-tag/button/index.tsx new file mode 100644 index 0000000..bdcb523 --- /dev/null +++ b/fixtures/components/system-tag/button/index.tsx @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { ButtonProps } from './interfaces'; + +export { ButtonProps }; + +export default function Button(props: ButtonProps) { + return
; +} diff --git a/fixtures/components/system-tag/button/interfaces.ts b/fixtures/components/system-tag/button/interfaces.ts new file mode 100644 index 0000000..eca6865 --- /dev/null +++ b/fixtures/components/system-tag/button/interfaces.ts @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +export interface ButtonProps { + variant?: ButtonProps.Variant; + + /** + * @awsuiSystem core + */ + size: 'small' | 'medium' | 'large'; + + color?: + | 'normal' + /** @awsuiSystem core */ + | 'danger'; +} + +export namespace ButtonProps { + export type Variant = + | 'primary' + | 'secondary' + /** @awsuiSystem core */ + | 'fire' + /** + * @awsuiSystem core + * @awsuiSystem experimental + */ + | 'ultra'; +} diff --git a/fixtures/components/system-tag/tree/index.tsx b/fixtures/components/system-tag/tree/index.tsx new file mode 100644 index 0000000..9140f82 --- /dev/null +++ b/fixtures/components/system-tag/tree/index.tsx @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface TreeProps {} + +/** + * @awsuiSystem core + */ +export default function Tree() { + return
; +} diff --git a/fixtures/components/system-tag/tsconfig.json b/fixtures/components/system-tag/tsconfig.json new file mode 100644 index 0000000..82c6d86 --- /dev/null +++ b/fixtures/components/system-tag/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["./**/*.tsx"] +} diff --git a/src/components/component-definition.ts b/src/components/component-definition.ts index 9ea8e3a..8cd56ed 100644 --- a/src/components/component-definition.ts +++ b/src/components/component-definition.ts @@ -10,14 +10,26 @@ import type { ComponentRegion, EventHandler, } from './interfaces'; -import type { ExpandedProp } from './extractor'; +import type { ExpandedProp, ExtractedDescription } from './extractor'; import { getObjectDefinition } from './object-definition'; -function getCommentTag(property: ExpandedProp, name: string) { - const tag = property.description.tags.find(tag => tag.name === name); +function getCommentTag(description: ExtractedDescription, name: string) { + const tag = description.tags.find(tag => tag.name === name); return tag ? tag.text ?? '' : undefined; } +function getCommentTags(description: ExtractedDescription, name: string) { + const tags = description.tags + .filter(tag => tag.name === name) + .map(tag => { + if (!tag.text) { + throw new Error(`Tag ${name} is missing text`); + } + return tag.text; + }); + return tags.length > 0 ? tags : undefined; +} + function castI18nTag(tag: string | undefined) { return tag === undefined ? undefined : true; } @@ -27,6 +39,7 @@ export function buildComponentDefinition( props: Array, functions: Array, defaultValues: Record, + componentDescription: ExtractedDescription, checker: ts.TypeChecker ): ComponentDefinition { const regions = props.filter(prop => prop.type === 'React.ReactNode'); @@ -36,15 +49,18 @@ export function buildComponentDefinition( return { name, releaseStatus: 'stable', + description: componentDescription.text, + systemTags: getCommentTags(componentDescription, 'awsuiSystem'), regions: regions.map( (region): ComponentRegion => ({ name: region.name, - displayName: getCommentTag(region, 'displayname'), + displayName: getCommentTag(region.description, 'displayname'), description: region.description.text, isDefault: region.name === 'children', - visualRefreshTag: getCommentTag(region, 'visualrefresh'), - deprecatedTag: getCommentTag(region, 'deprecated'), - i18nTag: castI18nTag(getCommentTag(region, 'i18n')), + systemTags: getCommentTags(region.description, 'awsuiSystem'), + visualRefreshTag: getCommentTag(region.description, 'visualrefresh'), + deprecatedTag: getCommentTag(region.description, 'deprecated'), + i18nTag: castI18nTag(getCommentTag(region.description, 'i18n')), }) ), functions: functions.map( @@ -66,7 +82,7 @@ export function buildComponentDefinition( }) ), properties: onlyProps.map((property): ComponentProperty => { - const { type, inlineType } = getObjectDefinition(property.type, property.rawType, checker); + const { type, inlineType } = getObjectDefinition(property.type, property.rawType, property.rawTypeNode, checker); return { name: property.name, type: type, @@ -74,27 +90,33 @@ export function buildComponentDefinition( 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')), + systemTags: getCommentTags(property.description, 'awsuiSystem'), + visualRefreshTag: getCommentTag(property.description, 'visualrefresh'), + deprecatedTag: getCommentTag(property.description, 'deprecated'), + analyticsTag: getCommentTag(property.description, 'analytics'), + i18nTag: castI18nTag(getCommentTag(property.description, 'i18n')), }; }), events: events.map((event): EventHandler => { - const { detailType, detailInlineType, cancelable } = extractEventDetails(event.rawType, checker); + const { detailType, detailInlineType, cancelable } = extractEventDetails( + event.rawType, + event.rawTypeNode, + checker + ); return { name: event.name, description: event.description.text, cancelable, detailType, detailInlineType, - deprecatedTag: getCommentTag(event, 'deprecated'), + systemTags: getCommentTags(event.description, 'awsuiSystem'), + deprecatedTag: getCommentTag(event.description, 'deprecated'), }; }), }; } -function extractEventDetails(type: ts.Type, checker: ts.TypeChecker) { +function extractEventDetails(type: ts.Type, typeNode: ts.TypeNode | undefined, checker: ts.TypeChecker) { const realType = type.getNonNullableType(); const handlerName = realType.aliasSymbol?.getName(); if (handlerName !== 'CancelableEventHandler' && handlerName !== 'NonCancelableEventHandler') { @@ -103,7 +125,7 @@ function extractEventDetails(type: ts.Type, checker: ts.TypeChecker) { const cancelable = handlerName === 'CancelableEventHandler'; const detailType = realType.aliasTypeArguments?.[0]; if (detailType && detailType.getProperties().length > 0) { - const { type, inlineType } = getObjectDefinition(stringifyType(detailType, checker), detailType, checker); + const { type, inlineType } = getObjectDefinition(stringifyType(detailType, checker), detailType, typeNode, checker); return { detailType: type, detailInlineType: inlineType, diff --git a/src/components/extractor.ts b/src/components/extractor.ts index 437faef..6714812 100644 --- a/src/components/extractor.ts +++ b/src/components/extractor.ts @@ -10,15 +10,18 @@ import { unwrapNamespaceDeclaration, } from './type-utils'; +export interface ExtractedDescription { + text: string | undefined; + tags: Array<{ name: string; text: string | undefined }>; +} + export interface ExpandedProp { name: string; type: string; isOptional: boolean; rawType: ts.Type; - description: { - text: string | undefined; - tags: Array<{ name: string; text: string | undefined }>; - }; + rawTypeNode: ts.TypeNode | undefined; + description: ExtractedDescription; } export function extractDefaultValues(exportSymbol: ts.Symbol, checker: ts.TypeChecker) { @@ -87,6 +90,7 @@ export function extractProps(propsSymbol: ts.Symbol, checker: ts.TypeChecker) { name: value.name, type: stringifyType(type, checker), rawType: type, + rawTypeNode: (declaration as ts.PropertyDeclaration).type, isOptional: isOptional(type), description: getDescription(value.getDocumentationComment(checker), declaration), }; @@ -124,6 +128,7 @@ export function extractFunctions(propsSymbol: ts.Symbol, checker: ts.TypeChecker name: value.name, type: stringifyType(realType, checker), rawType: realType, + rawTypeNode: (declaration as ts.PropertyDeclaration).type, isOptional: isOptional(type), description: getDescription(value.getDocumentationComment(checker), declaration), }; diff --git a/src/components/index.ts b/src/components/index.ts index 85528fa..643015c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -8,6 +8,7 @@ import { buildComponentDefinition } from './component-definition'; import { extractDefaultValues, extractExports, extractFunctions, extractProps } from './extractor'; import type { ComponentDefinition } from './interfaces'; import { bootstrapTypescriptProject } from '../bootstrap/typescript'; +import { extractDeclaration, getDescription } from './type-utils'; function componentNameFromPath(componentPath: string) { const directoryName = pathe.dirname(componentPath); @@ -51,7 +52,11 @@ export function documentComponents( const props = extractProps(propsSymbol, checker); const functions = extractFunctions(propsSymbol, checker); const defaultValues = extractDefaultValues(componentSymbol, checker); + const componentDescription = getDescription( + componentSymbol.getDocumentationComment(checker), + extractDeclaration(componentSymbol) + ); - return buildComponentDefinition(name, props, functions, defaultValues, checker); + return buildComponentDefinition(name, props, functions, defaultValues, componentDescription, checker); }); } diff --git a/src/components/interfaces.ts b/src/components/interfaces.ts index f7ea1e5..c5e473e 100644 --- a/src/components/interfaces.ts +++ b/src/components/interfaces.ts @@ -8,13 +8,25 @@ export interface ComponentDefinition { version?: string; /** @deprecated */ description?: string; + systemTags?: Array; properties: ComponentProperty[]; regions: ComponentRegion[]; functions: ComponentFunction[]; events: EventHandler[]; } -export interface ComponentProperty { +interface Taggable { + deprecatedTag?: string; + visualRefreshTag?: string; + i18nTag?: true | undefined; + systemTags?: Array; +} + +export interface ValueDescription { + systemTags: Array; +} + +export interface ComponentProperty extends Taggable { name: string; description?: string; optional: boolean; @@ -22,19 +34,13 @@ export interface ComponentProperty { inlineType?: TypeDefinition; defaultValue?: string; analyticsTag?: string; - deprecatedTag?: string; - visualRefreshTag?: string; - i18nTag?: true | undefined; } -export interface ComponentRegion { +export interface ComponentRegion extends Taggable { name: string; description?: string; displayName?: string; isDefault: boolean; - deprecatedTag?: string; - visualRefreshTag?: string; - i18nTag?: true | undefined; } export interface ComponentFunction { @@ -73,14 +79,14 @@ export interface FunctionParameter { export interface UnionTypeDefinition { name: string; type: 'union'; + valueDescriptions?: Record; values: string[]; } -export interface EventHandler { +export interface EventHandler extends Taggable { name: string; description?: string; detailType?: string; detailInlineType?: TypeDefinition; cancelable: boolean; - deprecatedTag?: string; } diff --git a/src/components/object-definition.ts b/src/components/object-definition.ts index 84adfc4..24da72d 100644 --- a/src/components/object-definition.ts +++ b/src/components/object-definition.ts @@ -1,8 +1,8 @@ // 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'; +import type { TypeDefinition, UnionTypeDefinition, ValueDescription } from './interfaces'; +import { extractDeclaration, extractValueDescriptions, isOptional, stringifyType } from './type-utils'; function isArrayType(type: ts.Type) { const symbol = type.getSymbol(); @@ -15,6 +15,7 @@ function isArrayType(type: ts.Type) { export function getObjectDefinition( type: string, rawType: ts.Type, + rawTypeNode: ts.TypeNode | undefined, checker: ts.TypeChecker ): { type: string; inlineType?: TypeDefinition } { const realType = rawType.getNonNullableType(); @@ -31,7 +32,7 @@ export function getObjectDefinition( return { type }; } if (realType.isUnionOrIntersection()) { - return getUnionTypeDefinition(realTypeName, realType, checker); + return getUnionTypeDefinition(realTypeName, realType, rawTypeNode, checker); } if (realType.getProperties().length > 0) { return { @@ -78,36 +79,46 @@ export function getObjectDefinition( return { type }; } +function getPrimitiveType(type: ts.UnionOrIntersectionType) { + if (type.types.every(subtype => subtype.isStringLiteral())) { + return 'string'; + } + if (type.types.every(subtype => subtype.isNumberLiteral())) { + return 'number'; + } + return undefined; +} + function getUnionTypeDefinition( realTypeName: string, realType: ts.UnionOrIntersectionType, + typeNode: ts.TypeNode | undefined, 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()), - }, - }; - } + const valueDescriptions = extractValueDescriptions(realType, typeNode); + const primitiveType = getPrimitiveType(realType); + const values = realType.types.map(subtype => + primitiveType ? (subtype as ts.LiteralType).value.toString() : stringifyType(subtype, checker) + ); + return { - type: realTypeName, + type: primitiveType ?? realTypeName, inlineType: { name: realTypeName, type: 'union', - values: realType.types.map(subtype => stringifyType(subtype, checker)), + valueDescriptions: valueDescriptions.length > 0 ? zipValueDescriptions(values, valueDescriptions) : undefined, + values: values, }, }; } + +function zipValueDescriptions(values: Array, descriptions: Array) { + const descriptionsMap: Record = {}; + values.forEach((value, index) => { + const description = descriptions[index]; + if (description) { + descriptionsMap[value] = description; + } + }); + return descriptionsMap; +} diff --git a/src/components/type-utils.ts b/src/components/type-utils.ts index ce634fe..1834751 100644 --- a/src/components/type-utils.ts +++ b/src/components/type-utils.ts @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import ts from 'typescript'; +import { ValueDescription } from './interfaces'; export function isOptional(type: ts.Type) { if (!type.isUnionOrIntersection()) { @@ -54,6 +55,46 @@ export function getDescription(docComment: Array, declarat }; } +export function extractValueDescriptions(type: ts.UnionOrIntersectionType, typeNode: ts.TypeNode | undefined) { + if (type.aliasSymbol) { + // Traverse from "variant: ButtonProps.Variant" to "type Variant = ..." + const aliasDeclaration = extractDeclaration(type.aliasSymbol); + if (ts.isTypeAliasDeclaration(aliasDeclaration)) { + typeNode = aliasDeclaration.type; + } + } + + if (!typeNode) { + return []; + } + + const maybeList = typeNode.getChildren()[0]; + // based on similar code in typedoc + // https://github.com/TypeStrong/typedoc/blob/6090b3e31471cea3728db1b03888bca5703b437e/src/lib/converter/symbols.ts#L406-L438 + if (maybeList.kind !== ts.SyntaxKind.SyntaxList) { + return []; + } + const rawComments: Array = []; + let memberIndex = 0; + for (const child of maybeList.getChildren()) { + const text = child.getFullText(); + if (text.includes('/**')) { + rawComments[memberIndex] = (rawComments[memberIndex] ?? '') + child.getFullText(); + } + + if (child.kind !== ts.SyntaxKind.BarToken) { + memberIndex++; + } + } + return rawComments.map((comment): ValueDescription | undefined => + comment + ? { + systemTags: Array.from(comment.matchAll(/@awsuiSystem\s+(\w+)/g), ([_, system]) => system), + } + : undefined + ); +} + export function extractDeclaration(symbol: ts.Symbol) { const declarations = symbol.getDeclarations(); if (!declarations || declarations.length === 0) { diff --git a/test/components/default-values-extractor.test.ts b/test/components/default-values-extractor.test.ts index 10dbb6e..ac07465 100644 --- a/test/components/default-values-extractor.test.ts +++ b/test/components/default-values-extractor.test.ts @@ -1,20 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import ts from 'typescript'; import { extractDefaultValues } from '../../src/components/extractor'; +import { getInMemoryProject } from './test-helpers'; 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 { exportSymbol, checker } = getInMemoryProject(source); - const moduleSymbol = checker.getSymbolAtLocation(program.getSourceFile('temp.ts')!)!; - - return extractDefaultValues(checker.getExportsOfModule(moduleSymbol)[0], checker); + return extractDefaultValues(exportSymbol, checker); } test('should throw on unsupported syntax', () => { diff --git a/test/components/simple.test.ts b/test/components/simple.test.ts index f19f430..bb8b511 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).toBeUndefined(); + expect(component.description).toEqual('Component-level description'); expect(component.releaseStatus).toBe('stable'); }); diff --git a/test/components/system-tag.test.ts b/test/components/system-tag.test.ts new file mode 100644 index 0000000..371f91a --- /dev/null +++ b/test/components/system-tag.test.ts @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentDefinition } from '../../src/components/interfaces'; +import { buildProject } from './test-helpers'; + +describe('System tag', () => { + let button: ComponentDefinition; + let tree: ComponentDefinition; + beforeAll(() => { + const result = buildProject('system-tag'); + expect(result).toHaveLength(2); + + [button, tree] = result; + }); + + test('should annotate whole components', () => { + expect(button.systemTags).toBeUndefined(); + expect(tree.systemTags).toEqual(['core']); + }); + + test('should annotate individual properties', () => { + expect(button.properties).toEqual([ + { + name: 'color', + type: 'string', + optional: true, + inlineType: { + name: '"normal" | "danger"', + type: 'union', + valueDescriptions: { danger: { systemTags: ['core'] } }, + values: ['normal', 'danger'], + }, + }, + { + name: 'size', + type: 'string', + optional: false, + systemTags: ['core'], + inlineType: { + name: '"small" | "medium" | "large"', + type: 'union', + values: ['small', 'medium', 'large'], + }, + }, + { + name: 'variant', + type: 'string', + optional: true, + inlineType: { + name: 'ButtonProps.Variant', + type: 'union', + valueDescriptions: { + fire: { systemTags: ['core'] }, + ultra: { systemTags: ['core', 'experimental'] }, + }, + values: ['primary', 'secondary', 'fire', 'ultra'], + }, + }, + ]); + }); +}); diff --git a/test/components/test-helpers.ts b/test/components/test-helpers.ts index e83c918..e46ad3b 100644 --- a/test/components/test-helpers.ts +++ b/test/components/test-helpers.ts @@ -4,6 +4,7 @@ import { ProjectReflection } from 'typedoc'; import { ComponentDefinition, documentComponents, documentTestUtils } from '../../src'; import { bootstrapProject } from '../../src/bootstrap'; import { TestUtilsDoc } from '../../src/test-utils/interfaces'; +import ts from 'typescript'; export function buildProject(name: string): ComponentDefinition[] { return documentComponents( @@ -30,3 +31,17 @@ export function buildCustomProject(tsConfig: string, testGlob: string): ProjectR ); return project; } + +export function getInMemoryProject(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')!)!; + const exportSymbol = checker.getExportsOfModule(moduleSymbol)[0]; + + return { exportSymbol, checker }; +} diff --git a/test/components/value-descriptions-extractor.test.ts b/test/components/value-descriptions-extractor.test.ts new file mode 100644 index 0000000..31d4ad5 --- /dev/null +++ b/test/components/value-descriptions-extractor.test.ts @@ -0,0 +1,104 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import ts from 'typescript'; +import { extractDeclaration, extractValueDescriptions } from '../../src/components/type-utils'; +import { getInMemoryProject } from './test-helpers'; + +function extractFromSource(source: string) { + const { exportSymbol, checker } = getInMemoryProject(source); + + const exportType = extractDeclaration(exportSymbol) as ts.TypeAliasDeclaration; + return extractValueDescriptions(checker.getTypeAtLocation(exportType) as ts.UnionOrIntersectionType, exportType.type); +} + +test('does not extract anything if union type has no comments', () => { + const source = `export type MyUnion = + | 'foo' + | 'bar';`; + + expect(extractFromSource(source)).toEqual([]); +}); + +test('does not extract anything if this is not a type', () => { + const source = `export const test = 'true'`; + + expect(extractFromSource(source)).toEqual([]); +}); + +test('extract description comments', () => { + const source = `export type MyUnion = + /** @awsuiSystem fooSystem */ + | 'foo' + /** @awsuiSystem barSystem */ + | 'bar';`; + + expect(extractFromSource(source)).toEqual([{ systemTags: ['fooSystem'] }, { systemTags: ['barSystem'] }]); +}); + +test('extract description comments from a type alias', () => { + const source = ` + export type MyUnion = InternalUnion; + type InternalUnion = + /** @awsuiSystem fooSystem */ + | 'foo' + /** @awsuiSystem barSystem */ + | 'bar';`; + + expect(extractFromSource(source)).toEqual([{ systemTags: ['fooSystem'] }, { systemTags: ['barSystem'] }]); +}); + +test('allows some members without comments', () => { + const source = `export type MyUnion = + 'foo' + /** @awsuiSystem barSystem */ + | 'bar' + | 'baz' + /** @awsuiSystem quxSystem */ + | 'qux' + ;`; + + expect(extractFromSource(source)).toEqual([ + undefined, + { systemTags: ['barSystem'] }, + undefined, + { systemTags: ['quxSystem'] }, + ]); +}); + +test('ignores leading and trailing comments', () => { + const source = ` + /** @awsuiSystem willNotBeParsed */ + export type MyUnion = + /** @awsuiSystem fooSystem */ + | 'foo' + /** @awsuiSystem barSystem */ + | 'bar' + /** @awsuiSystem willNotBeParsedToo */ + ;`; + + expect(extractFromSource(source)).toEqual([{ systemTags: ['fooSystem'] }, { systemTags: ['barSystem'] }]); +}); + +test('merges comments before and after bar character', () => { + const source = `export type MyUnion = + /** @awsuiSystem fooBefore */ + | /** @awsuiSystem fooAfter */ 'foo' + /** @awsuiSystem barSystem */ + | 'bar' + ;`; + + expect(extractFromSource(source)).toEqual([{ systemTags: ['fooBefore', 'fooAfter'] }, { systemTags: ['barSystem'] }]); +}); + +test('extracts multiple tags from a single comment', () => { + const source = `export type MyUnion = + | 'foo' + /** + * @awsuiSystem barFirst + * @awsuiSystem barSecond + */ + | 'bar' + ;`; + + expect(extractFromSource(source)).toEqual([undefined, { systemTags: ['barFirst', 'barSecond'] }]); +});