Skip to content
Open
21 changes: 21 additions & 0 deletions fixtures/components/string-intersection/code-editor/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';

export namespace CodeEditorProps {
// This simulates the pattern used in the code editor where we want:
// 1. Autocomplete for known language literals
// 2. Allow custom string values
export type Language = 'javascript' | 'html' | 'ruby' | 'python' | 'java' | (string & { _?: undefined });
}

export interface CodeEditorProps {
/**
* Specifies the programming language.
*/
language: CodeEditorProps.Language;
}

export default function CodeEditor({ language }: CodeEditorProps) {
return <div data-language={language}>Code Editor</div>;
}
4 changes: 4 additions & 0 deletions fixtures/components/string-intersection/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.json",
"include": ["./**/*.tsx"]
}
39 changes: 32 additions & 7 deletions src/components/object-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,25 @@ export function getObjectDefinition(
return { type };
}

function getPrimitiveType(type: ts.UnionOrIntersectionType) {
if (type.types.every(subtype => subtype.isStringLiteral())) {
function isStringLiteralOrStringIntersection(subtype: ts.Type, checker: ts.TypeChecker): boolean {
// Check if it's a string literal
if (subtype.isStringLiteral() || subtype.flags & ts.TypeFlags.StringLiteral) {
return true;
}
if (subtype.isIntersection()) {
const stringified = stringifyType(subtype, checker);
if (stringified.startsWith('string &')) {
return true;
}
}
return false;
}

function getPrimitiveType(type: ts.UnionOrIntersectionType, checker: ts.TypeChecker) {
if (type.types.every(subtype => isStringLiteralOrStringIntersection(subtype, checker))) {
return 'string';
}
if (type.types.every(subtype => subtype.isNumberLiteral())) {
if (type.types.every(subtype => subtype.isNumberLiteral() || subtype.flags & ts.TypeFlags.NumberLiteral)) {
return 'number';
}
return undefined;
Expand All @@ -103,10 +117,21 @@ function getUnionTypeDefinition(
checker: ts.TypeChecker
): { type: string; inlineType: UnionTypeDefinition } {
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)
);
const primitiveType = getPrimitiveType(realType, checker);
const values = realType.types.map(subtype => {
if (primitiveType === 'string') {
if (subtype.isStringLiteral()) {
return (subtype as ts.LiteralType).value.toString();
}
if (subtype.isIntersection()) {
return 'string';
}
}
if (primitiveType === 'number' && subtype.isNumberLiteral()) {
return (subtype as ts.LiteralType).value.toString();
}
return stringifyType(subtype, checker);
});

return {
type: primitiveType ?? realTypeName,
Expand Down
105 changes: 105 additions & 0 deletions test/components/object-definition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { expect, test, beforeAll } from 'vitest';
import { ComponentDefinition } from '../../src/components/interfaces';
import { buildProject } from './test-helpers';

let simpleComponent: ComponentDefinition;
let complexTypesComponents: ComponentDefinition[];

beforeAll(() => {
const simpleResult = buildProject('simple');
expect(simpleResult).toHaveLength(1);
[simpleComponent] = simpleResult;

complexTypesComponents = buildProject('complex-types');
expect(complexTypesComponents.length).toBeGreaterThan(0);
});

test('object definition should handle basic types', () => {
expect(simpleComponent.name).toBe('Simple');
expect(simpleComponent.properties).toBeDefined();
});

test('object definition should handle union types correctly', () => {
// Find a component with union types
const componentWithUnions = complexTypesComponents.find(comp =>
comp.properties.some(prop => prop.inlineType?.type === 'union')
);

if (componentWithUnions) {
const unionProp = componentWithUnions.properties.find(def => def.inlineType?.type === 'union');

if (unionProp?.inlineType?.type === 'union') {
expect(unionProp.inlineType.values).toBeDefined();
expect(Array.isArray(unionProp.inlineType.values)).toBe(true);
expect(unionProp.inlineType.values.length).toBeGreaterThan(0);
}
}
});

test('object definition should handle string literal unions', () => {
// Test string literal union handling
const componentWithStringUnions = complexTypesComponents.find(comp =>
comp.properties.some(prop => prop.inlineType?.type === 'union' && prop.type === 'string')
);

if (componentWithStringUnions) {
const stringUnionProp = componentWithStringUnions.properties.find(
prop => prop.inlineType?.type === 'union' && prop.type === 'string'
);

if (stringUnionProp?.inlineType?.type === 'union') {
expect(stringUnionProp.type).toBe('string');
expect(stringUnionProp.inlineType.values).toBeDefined();
// Should contain string values
expect(stringUnionProp.inlineType.values.some(v => typeof v === 'string')).toBe(true);
}
}
});

test('object definition should handle number literal unions', () => {
// Test number literal union handling
const componentWithNumberUnions = complexTypesComponents.find(comp =>
comp.properties.some(prop => prop.inlineType?.type === 'union' && prop.type === 'number')
);

if (componentWithNumberUnions) {
const numberUnionProp = componentWithNumberUnions.properties.find(
prop => prop.inlineType?.type === 'union' && prop.type === 'number'
);

if (numberUnionProp?.inlineType?.type === 'union') {
expect(numberUnionProp.type).toBe('number');
expect(numberUnionProp.inlineType.values).toBeDefined();
}
}
});

test('object definition should preserve type information', () => {
const props = simpleComponent.properties;
expect(props.length).toBeGreaterThan(0);

props.forEach(prop => {
expect(prop.name).toBeDefined();
expect(prop.type).toBeDefined();
});
});

test('object definition should handle mixed union types', () => {
// Test mixed union types (not primitive)
const componentWithMixedUnions = complexTypesComponents.find(comp =>
comp.properties.some(prop => prop.inlineType?.type === 'union' && prop.type !== 'string' && prop.type !== 'number')
);

if (componentWithMixedUnions) {
const mixedUnionProp = componentWithMixedUnions.properties.find(
prop => prop.inlineType?.type === 'union' && prop.type !== 'string' && prop.type !== 'number'
);

if (mixedUnionProp?.inlineType?.type === 'union') {
expect(mixedUnionProp.inlineType.values).toBeDefined();
expect(mixedUnionProp.inlineType.name).toBeDefined();
}
}
});
101 changes: 101 additions & 0 deletions test/components/string-intersection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { expect, test, beforeAll } from 'vitest';
import { ComponentDefinition } from '../../src/components/interfaces';
import { buildProject } from './test-helpers';

let codeEditor: ComponentDefinition;

beforeAll(() => {
const result = buildProject('string-intersection');
expect(result).toHaveLength(1);
[codeEditor] = result;
});

test('should properly handle union types with string intersection for custom values', () => {
const languageProp = codeEditor.properties.find(def => def.name === 'language');

expect(languageProp?.name).toBe('language');
expect(languageProp?.description).toBe('Specifies the programming language.');
expect(languageProp?.optional).toBe(false);
expect(languageProp?.type).toBe('string');

// Check inline type structure
expect(languageProp?.inlineType?.name).toBe('CodeEditorProps.Language');
expect(languageProp?.inlineType?.type).toBe('union');
if (languageProp?.inlineType?.type === 'union') {
expect(languageProp.inlineType.valueDescriptions).toBeUndefined();
}

// The intersection type "string & { _?: undefined; }" should be converted to "string"
// String literal values should appear without quotes
const values = (languageProp?.inlineType as any)?.values;
expect(values).toHaveLength(6);
expect(values).toContain('javascript');
expect(values).toContain('html');
expect(values).toContain('ruby');
expect(values).toContain('python');
expect(values).toContain('java');
expect(values).toContain('string'); // The intersection type becomes "string" to indicate custom values are allowed
});

test('should treat the union as primitive string type', () => {
const languageProp = codeEditor.properties.find(def => def.name === 'language');

// The type should be 'string' not the full union name
expect(languageProp?.type).toBe('string');
});

test('should convert intersection helper to "string" in values array', () => {
const languageProp = codeEditor.properties.find(def => def.name === 'language');

// Should not contain the raw "string & { _?: undefined; }" syntax
const hasRawIntersectionType =
languageProp?.inlineType?.type === 'union' &&
languageProp.inlineType.values.some((value: string) => value.includes('string &') || value.includes('_?:'));

expect(hasRawIntersectionType).toBe(false);

// But should contain "string" to indicate custom values are allowed
const hasStringValue =
languageProp?.inlineType?.type === 'union' && languageProp.inlineType.values.includes('string');

expect(hasStringValue).toBe(true);
});

test('should detect intersection types with string & pattern', () => {
const languageProp = codeEditor.properties.find(def => def.name === 'language');

// Verify that the union contains both string literals and the intersection type
expect(languageProp?.inlineType?.type).toBe('union');

if (languageProp?.inlineType?.type === 'union') {
const values = languageProp.inlineType.values;

// Should have the literal values
expect(values).toContain('javascript');
expect(values).toContain('html');
expect(values).toContain('ruby');
expect(values).toContain('python');
expect(values).toContain('java');

// Should have 'string' representing the intersection type
expect(values).toContain('string');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that this is a valid representation of the type. For example, if we follow this through to the website, it would suggest that there would be an item "string" in the language selector dropdown, but that is not valid, as it's not a literal value, it's a type. We need to retain that differentiation between literal values and types in the documenter output.

The more that I think about this problem, the more that I think the problem is maybe not actually in the documenter but in the website: the documenter is currently generating output that accurately represents the underlying typings, the website just needs some extra logic to be able to handle some of the edge-cases better


// All values should be treated as string type (primitive detection)
expect(languageProp.type).toBe('string');
}
});

test('should recognize intersection type as string-compatible', () => {
const languageProp = codeEditor.properties.find(def => def.name === 'language');

// The union with intersection type should be recognized as primitive string
expect(languageProp?.type).toBe('string');

if (languageProp?.inlineType?.type === 'union') {
// All values in the union should be compatible with string type
const allValuesAreStrings = languageProp.inlineType.values.every((value: string) => typeof value === 'string');
expect(allValuesAreStrings).toBe(true);
}
});
Loading